diff --git a/.strict-typing b/.strict-typing index 4b56c5b2c206..5b5f29e7ed2f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -395,6 +395,7 @@ homeassistant.components.nam.* homeassistant.components.namecheapdns.* homeassistant.components.nasweb.* homeassistant.components.neato.* +homeassistant.components.neopool.* homeassistant.components.nest.* homeassistant.components.netatmo.* homeassistant.components.network.* diff --git a/CODEOWNERS b/CODEOWNERS index 3b00a23d0592..402b04d339c7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1193,6 +1193,8 @@ CLAUDE.md @home-assistant/core /tests/components/nasweb/ @nasWebio /homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul /tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul +/homeassistant/components/neopool/ @svasek +/tests/components/neopool/ @svasek /homeassistant/components/ness_alarm/ @nickw444 @poshy163 /tests/components/ness_alarm/ @nickw444 @poshy163 /homeassistant/components/nest/ @allenporter diff --git a/homeassistant/components/neopool/__init__.py b/homeassistant/components/neopool/__init__.py new file mode 100644 index 000000000000..166f04f02d20 --- /dev/null +++ b/homeassistant/components/neopool/__init__.py @@ -0,0 +1,28 @@ +"""NeoPool integration for Home Assistant.""" + +from neopool_modbus import NeoPoolModbusClient + +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS +from .coordinator import NeoPoolConfigEntry, NeoPoolCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: NeoPoolConfigEntry) -> bool: + """Set up the NeoPool integration from a config entry.""" + client = NeoPoolModbusClient(entry.data) + coordinator = NeoPoolCoordinator(hass, client, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: NeoPoolConfigEntry) -> bool: + """Unload a NeoPool config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await entry.runtime_data.client.close() + return unload_ok diff --git a/homeassistant/components/neopool/config_flow.py b/homeassistant/components/neopool/config_flow.py new file mode 100644 index 000000000000..13a4c547f150 --- /dev/null +++ b/homeassistant/components/neopool/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for the NeoPool integration.""" + +from typing import Any, override + +from neopool_modbus import async_probe_serial +from neopool_modbus.exceptions import ( + NeoPoolConnectionError, + NeoPoolModbusError, + NeoPoolTimeoutError, +) +from neopool_modbus.registers import DEFAULT_MODBUS_FRAMER +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import CURRENT_VERSION, DEFAULT_PORT, DEFAULT_UNIT_ID, DOMAIN + + +async def _async_probe(user_input: dict[str, Any]) -> tuple[str | None, str | None]: + """Probe a device using user-supplied connection parameters.""" + try: + serial = await async_probe_serial( + user_input[CONF_HOST], + port=user_input[CONF_PORT], + unit_id=user_input["unit_id"], + framer=user_input["modbus_framer"], + ) + except NeoPoolConnectionError, NeoPoolTimeoutError: + return None, "cannot_connect" + except NeoPoolModbusError: + return None, "cannot_read_modbus" + return serial, None + + +class NeoPoolConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for NeoPool.""" + + VERSION = CURRENT_VERSION + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step of the configuration flow.""" + data_schema = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + vol.Optional("unit_id", default=DEFAULT_UNIT_ID): vol.Coerce(int), + vol.Optional( + "modbus_framer", + default=DEFAULT_MODBUS_FRAMER, + ): vol.In(("tcp", "rtu")), + } + ) + errors: dict[str, str] = {} + if user_input is not None: + serial, error_key = await _async_probe(user_input) + if error_key: + errors[CONF_HOST] = error_key + else: + assert serial is not None + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/neopool/const.py b/homeassistant/components/neopool/const.py new file mode 100644 index 000000000000..0307581f2da2 --- /dev/null +++ b/homeassistant/components/neopool/const.py @@ -0,0 +1,14 @@ +"""Constants for the NeoPool integration.""" + +from homeassistant.const import Platform + +DOMAIN = "neopool" +NAME = "NeoPool" + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +DEFAULT_SCAN_INTERVAL = 20 # in seconds +DEFAULT_PORT = 502 +DEFAULT_UNIT_ID = 1 + +CURRENT_VERSION = 6 diff --git a/homeassistant/components/neopool/coordinator.py b/homeassistant/components/neopool/coordinator.py new file mode 100644 index 000000000000..01b3d62dcf6c --- /dev/null +++ b/homeassistant/components/neopool/coordinator.py @@ -0,0 +1,100 @@ +"""Data update coordinator for the NeoPool integration.""" + +from datetime import timedelta +import logging +from typing import Any, override + +from neopool_modbus import NeoPoolModbusClient +from neopool_modbus.exceptions import NeoPoolError +from neopool_modbus.registers import MAX_RELAY_GPIO, find_corrupted_gpio_registers + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +type NeoPoolConfigEntry = ConfigEntry["NeoPoolCoordinator"] + + +class NeoPoolCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for NeoPool platform.""" + + client: NeoPoolModbusClient + config_entry: NeoPoolConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: NeoPoolModbusClient, + entry: NeoPoolConfigEntry, + ) -> None: + """Initialise the NeoPool data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} coordinator", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=entry, + ) + self.client = client + self._corrupted_gpio_state: frozenset[tuple[str, int]] | None = None + + def _check_gpio_registers(self, data: dict[str, Any]) -> None: + """Validate GPIO register values and (re-)raise or clear the repair issue.""" + corrupted = find_corrupted_gpio_registers(data) + corrupted_state = frozenset((key, value) for key, _, value in corrupted) + + if corrupted_state == self._corrupted_gpio_state: + return + + for key, label, value in corrupted: + _LOGGER.error( + "Corrupted GPIO register %s (%s): value %d (0x%04X) is outside " + "valid range 0-%d. The pool controller may malfunction", + key, + label, + value, + value & 0xFFFF, + MAX_RELAY_GPIO, + ) + + self._corrupted_gpio_state = corrupted_state + + if corrupted: + details = "\n".join( + f"- **{label}** (`{key}`): value **{value}** (expected 0-{MAX_RELAY_GPIO})" + for key, label, value in corrupted + ) + ir.async_create_issue( + self.hass, + DOMAIN, + "corrupted_gpio", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="corrupted_gpio", + translation_placeholders={"details": details}, + ) + else: + # Clear a previously raised repair issue once the device is healthy. + ir.async_delete_issue(self.hass, DOMAIN, "corrupted_gpio") + + @override + async def _async_update_data(self) -> dict[str, Any]: + """Fetch the latest data from the pool controller.""" + try: + data = await self.client.async_read_all() + except (NeoPoolError, OSError, TimeoutError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="modbus_communication_error", + translation_placeholders={"error": str(err)}, + ) from err + + self._check_gpio_registers(data) + + return data diff --git a/homeassistant/components/neopool/entity.py b/homeassistant/components/neopool/entity.py new file mode 100644 index 000000000000..3c3fdd3e7a1c --- /dev/null +++ b/homeassistant/components/neopool/entity.py @@ -0,0 +1,36 @@ +"""Base entity class for the NeoPool integration.""" + +from typing import override + +from neopool_modbus.decoders import get_machine_name, parse_version + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, NAME +from .coordinator import NeoPoolCoordinator + + +class NeoPoolEntity(CoordinatorEntity[NeoPoolCoordinator]): + """Base class for NeoPool entities.""" + + _attr_has_entity_name = True + + @property + @override + def device_info(self) -> DeviceInfo: + """Return device information for the entity.""" + data = self.coordinator.data or {} + unique_id = self.coordinator.config_entry.unique_id + assert unique_id is not None + machine_type = (get_machine_name(data) or "").strip() + model_prefix = "NeoPool Compatible: " if machine_type else "NeoPool Compatible" + + return DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=NAME, + model=f"{model_prefix}{machine_type}".strip(), + manufacturer="Hayward (Sugar Valley)", + sw_version=f"v{parse_version(data.get('MBF_POWER_MODULE_VERSION'))} (v{parse_version(data.get('MBF_PAR_VERSION'))})", + serial_number=unique_id, + ) diff --git a/homeassistant/components/neopool/icons.json b/homeassistant/components/neopool/icons.json new file mode 100644 index 000000000000..90d561743cf1 --- /dev/null +++ b/homeassistant/components/neopool/icons.json @@ -0,0 +1,65 @@ +{ + "entity": { + "sensor": { + "filt_mode": { + "default": "mdi:water-sync", + "state": { + "auto": "mdi:water-boiler-auto", + "backwash": "mdi:water-boiler-off", + "heating": "mdi:water-boiler-alert", + "intelligent": "mdi:water-boiler-auto", + "manual": "mdi:water-boiler-alert", + "smart": "mdi:water-boiler-auto" + } + }, + "filtration_speed": { + "default": "mdi:fan" + }, + "filtvalve_remaining": { + "default": "mdi:timer-sand" + }, + "hidro_current": { + "default": "mdi:air-humidifier-off", + "range": { + "10": "mdi:air-humidifier" + } + }, + "hidro_polarity": { + "default": "mdi:plus-minus-variant" + }, + "intelligent_intervals": { + "default": "mdi:counter" + }, + "intelligent_tt_next_interval": { + "default": "mdi:timeline-clock-outline" + }, + "ion_current": { + "default": "mdi:atom" + }, + "ion_polarity": { + "default": "mdi:plus-minus-variant" + }, + "measure_cl": { + "default": "mdi:shaker-outline" + }, + "measure_rx": { + "default": "mdi:gradient-vertical" + }, + "ph_pump_status": { + "default": "mdi:pump" + }, + "ph_status_alarm": { + "default": "mdi:ph", + "state": { + "ok": "mdi:check-circle-outline", + "ph_high": "mdi:alert", + "ph_low": "mdi:alert", + "ph_over": "mdi:alert", + "ph_under": "mdi:alert", + "pump_stopped": "mdi:alert", + "tank_level": "mdi:alert" + } + } + } + } +} diff --git a/homeassistant/components/neopool/manifest.json b/homeassistant/components/neopool/manifest.json new file mode 100644 index 000000000000..9c0658e5031b --- /dev/null +++ b/homeassistant/components/neopool/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "neopool", + "name": "NeoPool", + "codeowners": ["@svasek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/neopool", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["neopool_modbus"], + "quality_scale": "silver", + "requirements": ["neopool-modbus==3.6.0"] +} diff --git a/homeassistant/components/neopool/quality_scale.yaml b/homeassistant/components/neopool/quality_scale.yaml new file mode 100644 index 000000000000..1893a01bff50 --- /dev/null +++ b/homeassistant/components/neopool/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not register any service actions. + docs-conditions: + status: exempt + comment: The integration does not provide any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: The integration does not provide any triggers. + entity-event-setup: + status: exempt + comment: | + Entities use the coordinator pattern and do not subscribe to + integration-specific events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not register any service actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Modbus TCP has no authentication mechanism. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: | + Modbus TCP gateways have no standard discovery protocol + (no zeroconf, SSDP, or DHCP signal that uniquely identifies + a NeoPool controller behind the gateway). + discovery-update-info: + status: exempt + comment: See discovery exemption above. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + One config entry maps to one physical NeoPool controller; multiple + controllers are supported via separate config entries. The single + device per entry is created during initial setup and cannot change. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + One config entry maps to one physical device; the device is not + removed during runtime, so there are no stale devices to clean up. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Integration uses Modbus TCP, not HTTP, so no aiohttp session is involved. + strict-typing: done diff --git a/homeassistant/components/neopool/sensor.py b/homeassistant/components/neopool/sensor.py new file mode 100644 index 000000000000..830628fb73e3 --- /dev/null +++ b/homeassistant/components/neopool/sensor.py @@ -0,0 +1,380 @@ +"""Sensor platform for the NeoPool integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Any, override + +from neopool_modbus.capabilities import ( + has_filtvalve, + has_heating_relay, + has_variable_speed_pump, + is_chlorine_module_present, + is_conductivity_module_present, + is_hydrolysis_present, + is_ionization_present, + is_ph_module_present, + is_redox_module_present, + is_temperature_active, +) +from neopool_modbus.decoders import ( + FILTRATION_MODE_LABELS, + FILTRATION_SPEED_STATE_LABELS, + HIDRO_POLARITY_LABELS, + ION_POLARITY_LABELS, + PH_STATUS_ALARM_LABELS, + calculate_next_interval_time, + decode_hidro_polarity, + decode_ion_polarity, + decode_ph_alarm, + decode_ph_pump_status, + is_hydrolysis_in_percent, + ph_pump_options, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + EntityCategory, + UnitOfElectricPotential, + UnitOfRatio, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NeoPoolConfigEntry +from .coordinator import NeoPoolCoordinator +from .entity import NeoPoolEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class NeoPoolSensorEntityDescription(SensorEntityDescription): + """Describes a NeoPool sensor entity.""" + + supported_fn: Callable[[dict[str, Any]], bool] | None = None + value_fn: Callable[[dict[str, Any]], Any] | None = None + options_fn: Callable[[dict[str, Any]], list[str]] | None = None + unit_fn: Callable[[dict[str, Any]], str | None] | None = None + precision_fn: Callable[[dict[str, Any]], int | None] | None = None + + +SENSOR_DESCRIPTIONS: dict[str, NeoPoolSensorEntityDescription] = { + "MBF_ION_CURRENT": NeoPoolSensorEntityDescription( + key="MBF_ION_CURRENT", + translation_key="ion_current", + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_ionization_present, + ), + "MBF_HIDRO_CURRENT": NeoPoolSensorEntityDescription( + key="MBF_HIDRO_CURRENT", + translation_key="hidro_current", + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + supported_fn=is_hydrolysis_present, + unit_fn=lambda data: ( + UnitOfRatio.PERCENTAGE if is_hydrolysis_in_percent(data) else "g/h" + ), + precision_fn=lambda data: 0 if is_hydrolysis_in_percent(data) else 1, + ), + "MBF_MEASURE_PH": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_PH", + device_class=SensorDeviceClass.PH, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_ph_module_present, + ), + "MBF_MEASURE_RX": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_RX", + translation_key="measure_rx", + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_redox_module_present, + ), + "MBF_MEASURE_CL": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_CL", + translation_key="measure_cl", + native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_chlorine_module_present, + ), + "MBF_MEASURE_CONDUCTIVITY": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_CONDUCTIVITY", + translation_key="measure_conductivity", + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + supported_fn=is_conductivity_module_present, + ), + "MBF_MEASURE_TEMPERATURE": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_TEMPERATURE", + translation_key="measure_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_temperature_active, + ), + "MBF_HIDRO_VOLTAGE": NeoPoolSensorEntityDescription( + key="MBF_HIDRO_VOLTAGE", + translation_key="hidro_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "MBF_PAR_FILT_MODE": NeoPoolSensorEntityDescription( + key="MBF_PAR_FILT_MODE", + translation_key="filt_mode", + device_class=SensorDeviceClass.ENUM, + options=list(FILTRATION_MODE_LABELS.values()), + value_fn=lambda data: data.get("filtration_mode"), + ), + "MBF_PH_STATUS_ALARM": NeoPoolSensorEntityDescription( + key="MBF_PH_STATUS_ALARM", + translation_key="ph_status_alarm", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(PH_STATUS_ALARM_LABELS.values()), + value_fn=decode_ph_alarm, + supported_fn=is_ph_module_present, + ), + "HIDRO_POLARITY": NeoPoolSensorEntityDescription( + key="HIDRO_POLARITY", + translation_key="hidro_polarity", + device_class=SensorDeviceClass.ENUM, + options=list(HIDRO_POLARITY_LABELS), + value_fn=decode_hidro_polarity, + supported_fn=is_hydrolysis_present, + ), + "ION_POLARITY": NeoPoolSensorEntityDescription( + key="ION_POLARITY", + translation_key="ion_polarity", + device_class=SensorDeviceClass.ENUM, + options=list(ION_POLARITY_LABELS), + value_fn=decode_ion_polarity, + supported_fn=is_ionization_present, + ), + "PH_PUMP_STATUS": NeoPoolSensorEntityDescription( + key="PH_PUMP_STATUS", + translation_key="ph_pump_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options_fn=ph_pump_options, + value_fn=decode_ph_pump_status, + supported_fn=is_ph_module_present, + ), + "FILTRATION_SPEED": NeoPoolSensorEntityDescription( + key="FILTRATION_SPEED", + translation_key="filtration_speed", + device_class=SensorDeviceClass.ENUM, + options=list(FILTRATION_SPEED_STATE_LABELS), + value_fn=lambda data: data.get("filtration_speed_state"), + supported_fn=has_variable_speed_pump, + ), + "MBF_PAR_INTELLIGENT_INTERVALS": NeoPoolSensorEntityDescription( + key="MBF_PAR_INTELLIGENT_INTERVALS", + translation_key="intelligent_intervals", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=lambda data: ( + has_heating_relay(data) and is_temperature_active(data) + ), + ), + "MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL": NeoPoolSensorEntityDescription( + key="MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL", + translation_key="intelligent_tt_next_interval", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: calculate_next_interval_time( + data.get("MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL") + ), + supported_fn=lambda data: ( + has_heating_relay(data) and is_temperature_active(data) + ), + ), + "MBF_PAR_FILTVALVE_REMAINING": NeoPoolSensorEntityDescription( + key="MBF_PAR_FILTVALVE_REMAINING", + translation_key="filtvalve_remaining", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + supported_fn=has_filtvalve, + ), + "CELL_RUNTIME_TOTAL": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_TOTAL", + translation_key="cell_runtime_total", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_PART": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_PART", + translation_key="cell_runtime_part", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_POLA": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_POLA", + translation_key="cell_runtime_pola", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_POLB": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_POLB", + translation_key="cell_runtime_polb", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_POL_CHANGES": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_POL_CHANGES", + translation_key="cell_runtime_pol_changes", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NeoPoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NeoPool sensors from a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + NeoPoolSensor(coordinator, key, desc) + for key, desc in SENSOR_DESCRIPTIONS.items() + if desc.supported_fn is None or desc.supported_fn(coordinator.data) + ) + + +_PRODUCTION_KEYS_REQUIRING_FILTRATION = frozenset( + { + "MBF_HIDRO_CURRENT", + "MBF_HIDRO_VOLTAGE", + "MBF_ION_CURRENT", + } +) + +_MEASURE_KEYS_REQUIRING_FILTRATION = frozenset( + { + "MBF_MEASURE_TEMPERATURE", + "MBF_MEASURE_PH", + "MBF_MEASURE_RX", + "MBF_MEASURE_CL", + "MBF_MEASURE_CONDUCTIVITY", + } +) + + +class NeoPoolSensor(NeoPoolEntity, SensorEntity): + """Representation of a NeoPool sensor.""" + + entity_description: NeoPoolSensorEntityDescription + + def __init__( + self, + coordinator: NeoPoolCoordinator, + key: str, + description: NeoPoolSensorEntityDescription, + ) -> None: + """Initialize the NeoPool sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._key = key + self._attr_unique_id = ( + f"{self.coordinator.config_entry.unique_id}_{key.lower()}" + ) + + @property + @override + def suggested_display_precision(self) -> int | None: + """Return the suggested display precision for the sensor value.""" + if (precision_fn := self.entity_description.precision_fn) is not None: + return precision_fn(self.coordinator.data) + return super().suggested_display_precision + + @property + @override + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement for the sensor value.""" + if (unit_fn := self.entity_description.unit_fn) is not None: + return unit_fn(self.coordinator.data) + return super().native_unit_of_measurement + + def _filtration_off(self) -> bool: + """Return True when the filtration pump is off.""" + return self.coordinator.data.get("Filtration Pump") is False + + def _is_measurement_suppressed(self) -> bool: + """Return True if a measurement sensor should report None.""" + if self._key not in _MEASURE_KEYS_REQUIRING_FILTRATION: + return False + return self._filtration_off() + + def _is_production_suppressed(self) -> bool: + """Return True if a production sensor should report 0.""" + if self._key not in _PRODUCTION_KEYS_REQUIRING_FILTRATION: + return False + return self._filtration_off() + + @property + @override + def native_value(self) -> float | int | str | datetime | None: + """Return the actual sensor value from coordinator data.""" + if self._is_measurement_suppressed(): + return None + if self._is_production_suppressed(): + return 0 + if (value_fn := self.entity_description.value_fn) is not None: + value: float | int | str | datetime | None = value_fn(self.coordinator.data) + return value + return self.coordinator.data.get(self._key) + + @property + @override + def options(self) -> list[str] | None: + """Return the list of options for the sensor.""" + if (options_fn := self.entity_description.options_fn) is not None: + return options_fn(self.coordinator.data) + return super().options diff --git a/homeassistant/components/neopool/strings.json b/homeassistant/components/neopool/strings.json new file mode 100644 index 000000000000..4f3bea3e33d8 --- /dev/null +++ b/homeassistant/components/neopool/strings.json @@ -0,0 +1,150 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_read_modbus": "Connected, but cannot read from the Modbus device. Check unit ID and framer settings." + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "modbus_framer": "Modbus framer", + "port": "[%key:common::config_flow::data::port%]", + "unit_id": "Unit ID" + }, + "data_description": { + "host": "Enter the IP address of the Modbus TCP gateway connected to your pool controller.", + "modbus_framer": "Wire protocol used by the gateway. Select TCP for MBAP framing (default for Ethernet-native gateways). Select RTU for RTU framing tunnelled through a TCP socket, used by passthrough serial-to-TCP bridges like ESPHome's stream_server.", + "port": "Standard Modbus TCP port (default: 502). Change only if your gateway uses a non-standard port.", + "unit_id": "Modbus device address (1-247). Identifies the specific device on a shared bus. Most direct-connected devices use 1. Gateways may route requests to different addresses." + }, + "description": "Configure the connection to your NeoPool controller.", + "title": "NeoPool Connection" + } + } + }, + "entity": { + "sensor": { + "cell_runtime_part": { + "name": "Cell runtime since reset" + }, + "cell_runtime_pol_changes": { + "name": "Cell polarity changes" + }, + "cell_runtime_pola": { + "name": "Cell runtime in polarity 1" + }, + "cell_runtime_polb": { + "name": "Cell runtime in polarity 2" + }, + "cell_runtime_total": { + "name": "Cell runtime total" + }, + "filt_mode": { + "name": "Filtration mode", + "state": { + "auto": "Automatic", + "backwash": "Backwash", + "heating": "Heating", + "intelligent": "Intelligent", + "manual": "[%key:common::state::manual%]", + "smart": "Smart" + } + }, + "filtration_speed": { + "name": "Current filtration speed", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "mid": "Medium", + "off": "[%key:common::state::off%]" + } + }, + "filtvalve_remaining": { + "name": "Backwash time remaining" + }, + "hidro_current": { + "name": "Hydrolysis intensity" + }, + "hidro_polarity": { + "name": "Hydrolysis polarity", + "state": { + "dead_time": "Dead time", + "no_flow": "No flow", + "off": "[%key:common::state::off%]", + "pol1": "Polarity 1", + "pol2": "Polarity 2" + } + }, + "hidro_voltage": { + "name": "Hydrolysis voltage" + }, + "intelligent_intervals": { + "name": "Intelligent mode intervals" + }, + "intelligent_tt_next_interval": { + "name": "Intelligent mode next interval start" + }, + "ion_current": { + "name": "Ionization level" + }, + "ion_polarity": { + "name": "Ionizer polarity", + "state": { + "dead_time": "Dead time", + "off": "[%key:common::state::off%]", + "pol1": "Polarity 1", + "pol2": "Polarity 2" + } + }, + "measure_cl": { + "name": "Salt level" + }, + "measure_conductivity": { + "name": "Conductivity level" + }, + "measure_rx": { + "name": "Redox potential" + }, + "measure_temperature": { + "name": "Water temperature" + }, + "ph_pump_status": { + "name": "pH pump status", + "state": { + "acid": "Acid pump", + "base": "Base pump", + "both": "Both pumps", + "idle": "[%key:common::state::idle%]", + "off": "[%key:common::state::off%]" + } + }, + "ph_status_alarm": { + "name": "pH alarm", + "state": { + "ok": "OK", + "ph_high": "pH too high", + "ph_low": "pH too low", + "ph_over": "pH higher than the set point", + "ph_under": "pH lower than the set point", + "pump_stopped": "Pump stopped (exceeded working time)", + "tank_level": "Tank level alarm" + } + } + } + }, + "exceptions": { + "modbus_communication_error": { + "message": "An error occurred while communicating with the NeoPool controller: {error}" + } + }, + "issues": { + "corrupted_gpio": { + "description": "The following GPIO register(s) on your pool controller contain invalid values:\n\n{details}\n\nThis typically happens when the Modbus gateway framing mode does not match the integration's framer setting. The affected function(s) will not work correctly until the register(s) are restored to valid values.\n\nSee the integration documentation for repair instructions.", + "title": "Corrupted GPIO register(s) detected" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b3be40e3696e..4d0256cc4f05 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -498,6 +498,7 @@ FLOWS = { "nasweb", "neato", "nederlandse_spoorwegen", + "neopool", "ness_alarm", "nest", "netatmo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 361b332c997b..a9be4c9cf706 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4630,6 +4630,12 @@ "integration_type": "virtual", "supported_by": "shelly" }, + "neopool": { + "name": "NeoPool", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "ness_alarm": { "name": "Ness Alarm", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 77911105e280..a8fea92f7875 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3707,6 +3707,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.neopool.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.nest.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index d47877ce7de3..3dfcf2642cfd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1639,6 +1639,9 @@ nad-receiver==0.3.0 # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 +# homeassistant.components.neopool +neopool-modbus==3.6.0 + # homeassistant.components.ness_alarm nessclient==1.3.1 diff --git a/tests/components/neopool/__init__.py b/tests/components/neopool/__init__.py new file mode 100644 index 000000000000..327810d614a2 --- /dev/null +++ b/tests/components/neopool/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the NeoPool integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the NeoPool integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/neopool/conftest.py b/tests/components/neopool/conftest.py new file mode 100644 index 000000000000..4ef31e0dfeb9 --- /dev/null +++ b/tests/components/neopool/conftest.py @@ -0,0 +1,175 @@ +"""Common fixtures for the NeoPool tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.neopool.const import ( + CURRENT_VERSION, + DEFAULT_PORT, + DEFAULT_UNIT_ID, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT + +from tests.common import MockConfigEntry + +MOCK_HOST = "192.0.2.1" +MOCK_PORT = DEFAULT_PORT +MOCK_NAME = "Pool" +MOCK_SERIAL = "1234567890" + + +MOCK_POOL_DATA: dict[str, Any] = { + "MBF_POWER_MODULE_VERSION": 0x1234, + "MBF_PAR_VERSION": 0x100, + "MBF_PAR_MODEL": 0x0003, + "MBF_PAR_SERNUM": int(MOCK_SERIAL), + "MBF_PAR_FILTRATION_CONF": 1, + "MBF_PAR_FILT_GPIO": 1, + "MBF_PAR_LIGHTING_GPIO": 2, + "MBF_PAR_HEATING_GPIO": 3, + "MBF_PAR_PH_ACID_RELAY_GPIO": 4, + "MBF_PAR_PH_BASE_RELAY_GPIO": 5, + "MBF_PAR_RX_RELAY_GPIO": 6, + "MBF_PAR_CL_RELAY_GPIO": 7, + "MBF_PAR_CD_RELAY_GPIO": 0, + "MBF_PAR_UV_RELAY_GPIO": 1, + "MBF_PAR_FILTVALVE_GPIO": 1, + "MBF_PAR_FILTVALVE_ENABLE": 1, + "MBF_PAR_TEMPERATURE_ACTIVE": 1, + "MBF_PAR_UICFG_MACHINE": 0, + "MBF_PAR_RELAY_PH": 0, + "Hydrolysis module detected": True, + "Redox measurement module detected": True, + "pH measurement module detected": True, + "Chlorine measurement module detected": True, + "Conductivity measurement module detected": True, + "Ionization module detected": True, + "MBF_PAR_FILT_MODE": 0, + "filtration_mode": "manual", + "filtration_speed_state": "off", + "MBF_MEASURE_TEMPERATURE": 250, + "MBF_MEASURE_PH": 720, + "MBF_MEASURE_RX": 650, + "MBF_MEASURE_CL": 120, + "MBF_MEASURE_CONDUCTIVITY": 45, + "MBF_HIDRO_CURRENT": 70, + "MBF_HIDRO_VOLTAGE": 24, + "MBF_ION_CURRENT": 50, + "MBF_PAR_INTELLIGENT_INTERVALS": 4, + "MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL": 7200, + "MBF_PAR_FILTVALVE_REMAINING": 0, + "HIDRO_POLARITY": 0, + "ION_POLARITY": 0, + "PH_PUMP_STATUS": "off", + "HIDRO in Pol1": False, + "HIDRO in Pol2": False, + "HIDRO in dead time": False, + "ION in Pol1": False, + "ION in Pol2": False, + "ION in dead time": False, + "pH control module": True, + "pH pump active": False, + "pH acid pump active": False, + "Filtration Pump": False, + "MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19, + "Pool Cover": 0, + "CELL_RUNTIME_TOTAL": 0x00010000, + "CELL_RUNTIME_PART": 0x00000E10, + "CELL_RUNTIME_POLA": 0x00000708, + "CELL_RUNTIME_POLB": 0x00000708, + "CELL_RUNTIME_POL_CHANGES": 0x00000007, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.neopool.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a config entry with a bare-serial unique_id.""" + 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", + }, + ) + + +@pytest.fixture +def mock_neopool_client() -> Generator[MagicMock]: + """Patch the NeoPoolModbusClient and return a configurable mock instance.""" + with ( + patch( + "homeassistant.components.neopool.NeoPoolModbusClient", + autospec=True, + ) as mock_client_cls, + patch( + "homeassistant.components.neopool.config_flow.async_probe_serial", + new=AsyncMock(return_value=MOCK_SERIAL), + ), + ): + mock_client = mock_client_cls.return_value + mock_client.async_read_all = AsyncMock(return_value=dict(MOCK_POOL_DATA)) + mock_client.close = AsyncMock() + yield mock_client + + +@pytest.fixture +def minimal_pool_data() -> dict[str, Any]: + """Pool data with all optional capability flags off. + + Used to drive the 'should-skip' branches for supported_fn gating. + """ + return { + "MBF_POWER_MODULE_VERSION": 0x1234, + "MBF_PAR_VERSION": 0x100, + "MBF_PAR_MODEL": 0, + "MBF_PAR_SERNUM": int(MOCK_SERIAL), + "MBF_PAR_FILTRATION_CONF": 0, + "MBF_PAR_FILT_GPIO": 0, + "MBF_PAR_LIGHTING_GPIO": 0, + "MBF_PAR_HEATING_GPIO": 0, + "MBF_PAR_PH_ACID_RELAY_GPIO": 0, + "MBF_PAR_PH_BASE_RELAY_GPIO": 0, + "MBF_PAR_RX_RELAY_GPIO": 0, + "MBF_PAR_CL_RELAY_GPIO": 0, + "MBF_PAR_CD_RELAY_GPIO": 0, + "MBF_PAR_UV_RELAY_GPIO": 0, + "MBF_PAR_FILTVALVE_GPIO": 0, + "MBF_PAR_FILTVALVE_ENABLE": 0, + "MBF_PAR_TEMPERATURE_ACTIVE": 0, + "Hydrolysis module detected": False, + "Redox measurement module detected": False, + "pH measurement module detected": False, + "MBF_PAR_FILT_MODE": 0, + "filtration_mode": "manual", + "filtration_speed_state": "off", + "Filtration Pump": False, + } + + +@pytest.fixture +def mock_socket_connection() -> Generator[AsyncMock]: + """Patch the lib probe in config_flow so we don't hit the network.""" + with patch( + "homeassistant.components.neopool.config_flow.async_probe_serial", + new=AsyncMock(return_value=MOCK_SERIAL), + ) as mock: + yield mock diff --git a/tests/components/neopool/snapshots/test_sensor.ambr b/tests/components/neopool/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..e830f3ba5cbe --- /dev/null +++ b/tests/components/neopool/snapshots/test_sensor.ambr @@ -0,0 +1,1379 @@ +# serializer version: 1 +# name: test_all_entities[sensor.neopool_backwash_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_backwash_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backwash time remaining', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Backwash time remaining', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtvalve_remaining', + 'unique_id': '1234567890_mbf_par_filtvalve_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_backwash_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Backwash time remaining', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_backwash_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_polarity_changes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_polarity_changes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell polarity changes', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cell polarity changes', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_pol_changes', + 'unique_id': '1234567890_cell_runtime_pol_changes', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_cell_polarity_changes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Cell polarity changes', + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_polarity_changes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime in polarity 1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime in polarity 1', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_pola', + 'unique_id': '1234567890_cell_runtime_pola', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime in polarity 1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime in polarity 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime in polarity 2', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_polb', + 'unique_id': '1234567890_cell_runtime_polb', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime in polarity 2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_since_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_since_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime since reset', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime since reset', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_part', + 'unique_id': '1234567890_cell_runtime_part', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_since_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime since reset', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_since_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime total', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_total', + 'unique_id': '1234567890_cell_runtime_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2044444444444', + }) +# --- +# name: test_all_entities[sensor.neopool_conductivity_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_conductivity_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Conductivity level', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Conductivity level', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_conductivity', + 'unique_id': '1234567890_mbf_measure_conductivity', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_conductivity_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Conductivity level', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_conductivity_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_current_filtration_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'low', + 'mid', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_current_filtration_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current filtration speed', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current filtration speed', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration_speed', + 'unique_id': '1234567890_filtration_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_current_filtration_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Current filtration speed', + : list([ + 'off', + 'low', + 'mid', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_current_filtration_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensor.neopool_filtration_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_filtration_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration mode', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration mode', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filt_mode', + 'unique_id': '1234567890_mbf_par_filt_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_filtration_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Filtration mode', + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_filtration_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_hydrolysis_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis intensity', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hydrolysis intensity', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_current', + 'unique_id': '1234567890_mbf_hidro_current', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Hydrolysis intensity', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_hydrolysis_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_polarity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'no_flow', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_hydrolysis_polarity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis polarity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrolysis polarity', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_polarity', + 'unique_id': '1234567890_hidro_polarity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_polarity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Hydrolysis polarity', + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'no_flow', + 'off', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_hydrolysis_polarity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_hydrolysis_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrolysis voltage', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_voltage', + 'unique_id': '1234567890_mbf_hidro_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'NeoPool Hydrolysis voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_hydrolysis_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_intervals-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_intelligent_mode_intervals', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Intelligent mode intervals', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Intelligent mode intervals', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intelligent_intervals', + 'unique_id': '1234567890_mbf_par_intelligent_intervals', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_intervals-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Intelligent mode intervals', + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_intelligent_mode_intervals', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_next_interval_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_intelligent_mode_next_interval_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Intelligent mode next interval start', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intelligent mode next interval start', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intelligent_tt_next_interval', + 'unique_id': '1234567890_mbf_par_intelligent_tt_next_interval', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_next_interval_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'NeoPool Intelligent mode next interval start', + }), + 'context': , + 'entity_id': 'sensor.neopool_intelligent_mode_next_interval_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-07-03T14:00:00+00:00', + }) +# --- +# name: test_all_entities[sensor.neopool_ionization_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_ionization_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ionization level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ionization level', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ion_current', + 'unique_id': '1234567890_mbf_ion_current', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_ionization_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Ionization level', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_ionization_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_ionizer_polarity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_ionizer_polarity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ionizer polarity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ionizer polarity', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ion_polarity', + 'unique_id': '1234567890_ion_polarity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ionizer_polarity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Ionizer polarity', + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'off', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_ionizer_polarity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensor.neopool_ph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_ph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_mbf_measure_ph', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ph', + : 'NeoPool pH', + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_ph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_ph_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'ok', + 'ph_high', + 'ph_low', + 'pump_stopped', + 'ph_over', + 'ph_under', + 'tank_level', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_ph_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH alarm', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_status_alarm', + 'unique_id': '1234567890_mbf_ph_status_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ph_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool pH alarm', + : list([ + 'ok', + 'ph_high', + 'ph_low', + 'pump_stopped', + 'ph_over', + 'ph_under', + 'tank_level', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_ph_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_ph_pump_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'idle', + 'acid', + 'base', + 'both', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_ph_pump_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH pump status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH pump status', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_pump_status', + 'unique_id': '1234567890_ph_pump_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ph_pump_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool pH pump status', + : list([ + 'off', + 'idle', + 'acid', + 'base', + 'both', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_ph_pump_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[sensor.neopool_redox_potential-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_redox_potential', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox potential', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Redox potential', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_rx', + 'unique_id': '1234567890_mbf_measure_rx', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_redox_potential-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'NeoPool Redox potential', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_redox_potential', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_salt_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_salt_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Salt level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt level', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_cl', + 'unique_id': '1234567890_mbf_measure_cl', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_salt_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Salt level', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_salt_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_temperature', + 'unique_id': '1234567890_mbf_measure_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'NeoPool Water temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_when_modules_absent[sensor.neopool_filtration_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_filtration_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration mode', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration mode', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filt_mode', + 'unique_id': '1234567890_mbf_par_filt_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_when_modules_absent[sensor.neopool_filtration_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Filtration mode', + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_filtration_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- diff --git a/tests/components/neopool/test_config_flow.py b/tests/components/neopool/test_config_flow.py new file mode 100644 index 000000000000..96ecb532914f --- /dev/null +++ b/tests/components/neopool/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the NeoPool config flow.""" + +from unittest.mock import AsyncMock + +from neopool_modbus.exceptions import ( + NeoPoolConnectionError, + NeoPoolModbusError, + NeoPoolTimeoutError, +) +import pytest + +from homeassistant.components.neopool.const import DEFAULT_UNIT_ID, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_HOST, MOCK_PORT, MOCK_SERIAL + +from tests.common import MockConfigEntry + +USER_INPUT = { + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + "unit_id": DEFAULT_UNIT_ID, + "modbus_framer": "tcp", +} + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test a happy-path config flow creates the entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_HOST + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["result"].unique_id == MOCK_SERIAL + assert mock_setup_entry.call_count == 1 + + +@pytest.mark.parametrize( + ("exc_cls", "error_key"), + [ + (NeoPoolConnectionError, "cannot_connect"), + (NeoPoolTimeoutError, "cannot_connect"), + (NeoPoolModbusError, "cannot_read_modbus"), + ], +) +async def test_user_flow_probe_errors_recover( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_socket_connection: AsyncMock, + exc_cls: type[Exception], + error_key: str, +) -> None: + """Probe errors surface as form errors, and the flow recovers on retry.""" + mock_socket_connection.side_effect = exc_cls("boom") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_HOST: error_key} + + mock_socket_connection.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow aborts when the same device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/neopool/test_init.py b/tests/components/neopool/test_init.py new file mode 100644 index 000000000000..8ef873df5f89 --- /dev/null +++ b/tests/components/neopool/test_init.py @@ -0,0 +1,192 @@ +"""Test the NeoPool integration setup, unload, and lifecycle.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from freezegun.api import FrozenDateTimeFactory +from neopool_modbus.registers import MAX_RELAY_GPIO +import pytest + +from homeassistant.components.neopool.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, issue_registry as ir + +from . import setup_integration +from .conftest import MOCK_POOL_DATA, MOCK_SERIAL + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_setup_and_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Set up the integration end-to-end and tear it down again.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_first_refresh_fails_marks_retry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, +) -> None: + """Setup re-tries when the first Modbus read raises.""" + mock_neopool_client.async_read_all = AsyncMock( + side_effect=ConnectionError("Modbus down") + ) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_device_registered_with_firmware( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The first successful read populates firmware on the device entry.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_SERIAL)}) + assert device is not None + assert "18.52" in (device.sw_version or "") + + +async def test_transient_modbus_failure_after_first_success_marks_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Entities transition to unavailable when polling fails after a good read.""" + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.neopool_water_temperature" + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + mock_neopool_client.async_read_all.side_effect = ConnectionError("Modbus fail") + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_corrupt_gpio_creates_repair_issue( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """A GPIO register outside 0..MAX_RELAY_GPIO opens a corrupted_gpio issue.""" + bad_data = dict(MOCK_POOL_DATA) + bad_data["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 1 + mock_neopool_client.async_read_all = AsyncMock(return_value=bad_data) + + await setup_integration(hass, mock_config_entry) + + issue = issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") + assert issue is not None + assert issue.severity is ir.IssueSeverity.ERROR + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_clean_gpio_does_not_create_issue( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """A clean read does not open a corrupted_gpio issue.""" + await setup_integration(hass, mock_config_entry) + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is None + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_corrupt_gpio_clears_stale_issue_from_previous_session( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """A stale issue from a previous session clears on the first clean poll.""" + ir.async_create_issue( + hass, + DOMAIN, + "corrupted_gpio", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="corrupted_gpio", + translation_placeholders={"details": "- stale"}, + ) + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is not None + + await setup_integration(hass, mock_config_entry) + + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is None + + +async def test_corrupt_gpio_logs_error_only_on_state_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """The ERROR log fires on entering corruption and clears on healing.""" + bad_data = dict(MOCK_POOL_DATA) + bad_data["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 1 + mock_neopool_client.async_read_all = AsyncMock(return_value=bad_data) + + await setup_integration(hass, mock_config_entry) + assert sum("Corrupted GPIO register" in r.message for r in caplog.records) == 1 + + caplog.clear() + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert not any("Corrupted GPIO register" in r.message for r in caplog.records) + + mock_neopool_client.async_read_all = AsyncMock(return_value=dict(MOCK_POOL_DATA)) + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is None + + +async def test_corrupt_gpio_updates_issue_on_value_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """The repair issue details refresh when a corrupted register value changes.""" + first = dict(MOCK_POOL_DATA) + first["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 1 + mock_neopool_client.async_read_all = AsyncMock(return_value=first) + + await setup_integration(hass, mock_config_entry) + issue = issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") + assert issue is not None + assert issue.translation_placeholders is not None + assert str(MAX_RELAY_GPIO + 1) in issue.translation_placeholders["details"] + + second = dict(MOCK_POOL_DATA) + second["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 2 + mock_neopool_client.async_read_all = AsyncMock(return_value=second) + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") + assert issue is not None + assert issue.translation_placeholders is not None + assert str(MAX_RELAY_GPIO + 2) in issue.translation_placeholders["details"] diff --git a/tests/components/neopool/test_sensor.py b/tests/components/neopool/test_sensor.py new file mode 100644 index 000000000000..4e925f15ae95 --- /dev/null +++ b/tests/components/neopool/test_sensor.py @@ -0,0 +1,242 @@ +"""Tests for the NeoPool sensor platform.""" + +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sensor import ATTR_OPTIONS +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import 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 + + +async def test_measurement_sensors_suppressed_when_filtration_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Probe sensors report unknown while filtration pump is off (stale reading).""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "Filtration Pump": False, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + for entity_id in ( + "sensor.neopool_ph", + "sensor.neopool_redox_potential", + "sensor.neopool_water_temperature", + ): + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert state.state == "unknown" + + +async def test_production_sensors_zero_when_filtration_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Production sensors report 0 while filtration pump is off (cell idle).""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "Filtration Pump": False, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + for entity_id in ( + "sensor.neopool_hydrolysis_intensity", + "sensor.neopool_ionization_level", + ): + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert state.state == "0" + + +@pytest.mark.parametrize( + ("filt_mode", "expected"), + [ + (0, "manual"), + (1, "auto"), + (2, "heating"), + (3, "smart"), + (4, "intelligent"), + (13, "backwash"), + ], +) +async def test_filt_mode_native_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + filt_mode: int, + expected: str, +) -> None: + """Filt mode native value reads the lib's decoded filtration_mode key.""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "MBF_PAR_FILT_MODE": filt_mode, + "filtration_mode": expected, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.neopool_filtration_mode") + assert state is not None + assert state.state == expected + + +@pytest.mark.parametrize( + ("relay", "expected_options"), + [ + pytest.param(1, ["off", "idle", "acid"], id="acid_only"), + pytest.param(2, ["off", "idle", "base"], id="base_only"), + pytest.param(0, ["off", "idle", "acid", "base", "both"], id="both_relays"), + ], +) +async def test_ph_pump_status_options_per_relay_config( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + relay: int, + expected_options: list[str], +) -> None: + """The pH pump status options list shrinks based on the relay configuration.""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "MBF_PAR_RELAY_PH": relay, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.neopool_ph_pump_status") + assert state is not None + assert state.attributes[ATTR_OPTIONS] == expected_options + + +async def test_hidro_current_g_per_hour_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """In g/h mode HIDRO_CURRENT swaps unit and bumps display precision.""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "MBF_PAR_UICFG_MACHINE": 1, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.neopool_hydrolysis_intensity") + assert state is not None + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "g/h" + + +_CELL_RUNTIME_ENTITY_IDS: dict[str, str] = { + "CELL_RUNTIME_TOTAL": "sensor.neopool_cell_runtime_total", + "CELL_RUNTIME_PART": "sensor.neopool_cell_runtime_since_reset", + "CELL_RUNTIME_POLA": "sensor.neopool_cell_runtime_in_polarity_1", + "CELL_RUNTIME_POLB": "sensor.neopool_cell_runtime_in_polarity_2", + "CELL_RUNTIME_POL_CHANGES": "sensor.neopool_cell_polarity_changes", +} + + +@pytest.mark.parametrize( + ("key", "expected_seconds"), + [ + ("CELL_RUNTIME_TOTAL", 65536), + ("CELL_RUNTIME_PART", 3600), + ("CELL_RUNTIME_POLA", 1800), + ("CELL_RUNTIME_POLB", 1800), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_neopool_client") +async def test_cell_runtime_duration_sensor_reads_combined_register( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + key: str, + expected_seconds: int, +) -> None: + """Each duration CELL_RUNTIME_* sensor reads the combined u32 key from coordinator data. + + Sensors have ``entity_registry_enabled_default=False``; enabling every + disabled-by-default entity via the fixture avoids the reload dance. The + sensors declare seconds but suggest hours, so ``state.state`` is expressed + in hours (converted by the frontend layer). + """ + await setup_integration(hass, mock_config_entry) + + entity_id = _CELL_RUNTIME_ENTITY_IDS[key] + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert float(state.state) == pytest.approx(expected_seconds / 3600, abs=1e-4) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_neopool_client") +async def test_cell_runtime_pol_changes_sensor_reads_combined_register( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """CELL_RUNTIME_POL_CHANGES reads the combined u32 key as a raw counter. + + Unlike the duration sensors, this one has no unit and no unit conversion, + so ``state.state`` is the raw integer from coordinator data. + """ + await setup_integration(hass, mock_config_entry) + + entity_id = _CELL_RUNTIME_ENTITY_IDS["CELL_RUNTIME_POL_CHANGES"] + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert state.state == "7" + + +@pytest.mark.freeze_time("2026-07-03T12:00:00Z") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_neopool_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Snapshot every entity registered by the sensor platform.""" + with patch("homeassistant.components.neopool.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2026-07-03T12:00:00Z") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_setup_when_modules_absent( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + minimal_pool_data: dict[str, Any], +) -> None: + """Snapshot the sensor entities registered when no modules are present.""" + mock_neopool_client.async_read_all.return_value = minimal_pool_data + with patch("homeassistant.components.neopool.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)