mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Add number platform to NeoPool (#180415)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user