Add number platform to NeoPool (#180415)

This commit is contained in:
Miloš Svašek
2026-09-14 18:38:06 +02:00
committed by GitHub
parent 21e9b8c6f2
commit 61c70a2809
8 changed files with 2678 additions and 0 deletions
@@ -9,6 +9,7 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]
@@ -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
@@ -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",
+560
View File
@@ -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
@@ -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"
+19
View File
@@ -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."""
@@ -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({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 10.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.neopool_chlorine_setpoint',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <UnitOfRatio.PARTS_PER_MILLION: 'ppm'>,
})
# ---
# name: test_all_entities[number.neopool_chlorine_setpoint-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool Chlorine setpoint',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 10.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.1,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfRatio.PARTS_PER_MILLION: 'ppm'>,
}),
'context': <ANY>,
'entity_id': 'number.neopool_chlorine_setpoint',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[number.neopool_cover_reduction_when_covered-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 100.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.neopool_cover_reduction_when_covered',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool Cover reduction (when covered)',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 100.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: '%',
}),
'context': <ANY>,
'entity_id': 'number.neopool_cover_reduction_when_covered',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '25.0',
})
# ---
# name: test_all_entities[number.neopool_hydrolysis_shutdown_temp_threshold-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 40.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 1.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.neopool_hydrolysis_shutdown_temp_threshold',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Hydrolysis shutdown temp. threshold',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[number.neopool_hydrolysis_shutdown_temp_threshold-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool Hydrolysis shutdown temp. threshold',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 40.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 1.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.neopool_hydrolysis_shutdown_temp_threshold',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '12.0',
})
# ---
# name: test_all_entities[number.neopool_hydrolysis_target_production_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 100.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.neopool_hydrolysis_target_production_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool Hydrolysis target production level',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 100.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: '%',
}),
'context': <ANY>,
'entity_id': 'number.neopool_hydrolysis_target_production_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[number.neopool_ph_max_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 14.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.neopool_ph_max_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'pH max limit',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.PH: 'ph'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'ph',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool pH max limit',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 14.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.1,
}),
'context': <ANY>,
'entity_id': 'number.neopool_ph_max_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[number.neopool_ph_min_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 14.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.neopool_ph_min_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'pH min limit',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.PH: 'ph'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'ph',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool pH min limit',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 14.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.1,
}),
'context': <ANY>,
'entity_id': 'number.neopool_ph_min_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[number.neopool_redox_setpoint-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1000.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.neopool_redox_setpoint',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Redox setpoint',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.VOLTAGE: 'voltage'>,
'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': <UnitOfElectricPotential.MILLIVOLT: 'mV'>,
})
# ---
# name: test_all_entities[number.neopool_redox_setpoint-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'voltage',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool Redox setpoint',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1000.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfElectricPotential.MILLIVOLT: 'mV'>,
}),
'context': <ANY>,
'entity_id': 'number.neopool_redox_setpoint',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[number.neopool_smart_lower_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 40.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.neopool_smart_lower_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Smart lower temperature',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[number.neopool_smart_lower_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool Smart lower temperature',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 40.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.neopool_smart_lower_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[number.neopool_smart_upper_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 40.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.neopool_smart_upper_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Smart upper temperature',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[number.neopool_smart_upper_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool Smart upper temperature',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 40.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.neopool_smart_upper_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[number.neopool_temperature_setpoint-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 40.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.neopool_temperature_setpoint',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature setpoint',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[number.neopool_temperature_setpoint-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool Temperature setpoint',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 40.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.neopool_temperature_setpoint',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
File diff suppressed because it is too large Load Diff