Files
core/tests/components/neopool/test_number.py
T

1435 lines
51 KiB
Python

"""Tests for the NeoPool number platform."""
import asyncio
from datetime import timedelta
import gc
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from neopool_modbus.exceptions import NeoPoolConnectionError
from neopool_modbus.registers import MaskedFlag, SetpointKind
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform, entity_registry as er
from . import setup_integration
from .conftest import MOCK_POOL_DATA
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
# Longer than the entity's settle delay so a single tick flushes the write.
FLUSH = timedelta(seconds=5)
# Shorter than the settle delay: advancing by this must NOT flush a pending write.
PARTIAL = timedelta(seconds=2)
def _number_entity_id(
hass: HomeAssistant, entry: MockConfigEntry, key_lower_suffix: str
) -> str:
registry = er.async_get(hass)
entries = [
e
for e in er.async_entries_for_config_entry(registry, entry.entry_id)
if e.domain == NUMBER_DOMAIN and e.unique_id.endswith(f"_{key_lower_suffix}")
]
assert entries, (
f"no number entity ending in _{key_lower_suffix}, found: "
+ ", ".join(
e.unique_id
for e in er.async_entries_for_config_entry(registry, entry.entry_id)
if e.domain == NUMBER_DOMAIN
)
)
return entries[0].entity_id
async def _set_value(hass: HomeAssistant, entity_id: str, value: float) -> None:
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{"entity_id": entity_id, ATTR_VALUE: value},
blocking=True,
)
def _set_value_nowait(
hass: HomeAssistant, entity_id: str, value: float
) -> asyncio.Task[None]:
"""Start a blocking set_value as a task so the freezer can drive the flush.
A blocking call now awaits the debounced write, which only runs on a
freezer tick. Awaiting it inline would deadlock, so callers schedule it,
flush, then await the task to observe the write's outcome.
"""
return hass.async_create_task(_set_value(hass, entity_id, value))
async def _let_park(hass: HomeAssistant) -> None:
"""Yield enough for a scheduled set_value task to reach its await point.
``async_block_till_done`` waits on hass-tracked tasks, so it would block on
a set_value task intentionally parked before the flush; plain event-loop
yields let the task register its optimistic state without that wait.
"""
for _ in range(3):
await asyncio.sleep(0)
async def _write(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
entity_id: str,
value: float,
) -> None:
"""Set a value, flush the debounce, and await the write's outcome.
Raises whatever the debounced write raises (a device error surfaces to the
blocking caller), matching how a ``blocking: true`` service call behaves.
"""
task = _set_value_nowait(hass, entity_id, value)
await _flush(hass, freezer)
await task
async def _flush(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None:
"""Advance past the debounce cooldown and let the pending write run."""
freezer.tick(FLUSH)
async_fire_time_changed(hass)
await hass.async_block_till_done()
async def _advance(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None:
"""Advance by less than the cooldown; a pending write must not fire yet.
Uses event-loop yields rather than ``async_block_till_done`` so a set_value
task parked on the coalesce future does not stall the advance.
"""
freezer.tick(PARTIAL)
async_fire_time_changed(hass)
await _let_park(hass)
async def _poll(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_client: MagicMock,
data: dict[str, Any],
) -> None:
"""Push a coordinator poll returning ``data``."""
mock_client.async_read_all.return_value = data
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
async def test_simple_number_writes_register_after_debounce(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Setting a numeric value dispatches to the correct high-level API."""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
await _write(hass, freezer, ph1_entity_id, 7.5)
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(
SetpointKind.PH_MAX, 750
)
smart_entity_id = _number_entity_id(
hass, mock_config_entry_number, "mbf_par_smart_temp_high"
)
mock_neopool_client.async_set_setpoint.reset_mock()
await _write(hass, freezer, smart_entity_id, 30.0)
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(
SetpointKind.SMART_TEMP_HIGH, 30
)
async def test_scaled_setpoint_optimistic_value_is_ui_scaled(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Optimistic state after a scaled setpoint write is UI-scaled.
The lib override carries the raw register value (7.5 pH -> 750), but the
entity state must read back the decoded value. Regression guard: the merged
optimistic value must surface as 7.5, not 750.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
await _write(hass, freezer, ph1_entity_id, 7.5)
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 7.5
async def test_pending_value_shown_optimistically_before_write(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""State surfaces the requested value while the debounce is in flight.
Until the debounced write merges the override, the coordinator still holds
the old register. The entity must report the requested value optimistically
so the UI does not snap back to the stale reading before the cooldown fires.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
task = _set_value_nowait(hass, ph1_entity_id, 7.5)
# Advance less than the cooldown: the optimistic state is set, no write yet.
await _advance(hass, freezer)
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 7.5
mock_neopool_client.async_set_setpoint.assert_not_awaited()
# Let the queued write settle so the awaiting task finishes cleanly.
await _flush(hass, freezer)
await task
async def test_scaled_write_rounds_to_nearest_register_int(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""The scaled register value is rounded, not truncated.
Regression guard: ``4.1 * 100`` evaluates to ``409.999...`` in float, so a
plain ``int()`` would write 409. The write must round to 410.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 410}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
await _write(hass, freezer, ph1_entity_id, 4.1)
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(
SetpointKind.PH_MAX, 410
)
async def test_heating_setpoint_writes_via_high_level_api(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Writing the heating setpoint delegates to async_set_setpoint(HEATING)."""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_HEATING_TEMP": 28}
)
await setup_integration(hass, mock_config_entry_number)
entity_id = _number_entity_id(
hass, mock_config_entry_number, "mbf_par_heating_temp"
)
mock_neopool_client.async_set_setpoint.reset_mock()
await _write(hass, freezer, entity_id, 28.0)
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(
SetpointKind.HEATING, 28
)
@pytest.mark.parametrize(
("suffix", "data_key", "value", "kind", "expected_raw"),
[
pytest.param("mbf_par_ph1", "MBF_PAR_PH1", 7.2, SetpointKind.PH_MAX, 720),
pytest.param("mbf_par_ph2", "MBF_PAR_PH2", 7.0, SetpointKind.PH_MIN, 700),
pytest.param("mbf_par_rx1", "MBF_PAR_RX1", 700, SetpointKind.REDOX, 700),
pytest.param("mbf_par_cl1", "MBF_PAR_CL1", 1.5, SetpointKind.CHLORINE, 150),
pytest.param("mbf_par_hidro", "MBF_PAR_HIDRO", 50, SetpointKind.HIDRO, 500),
pytest.param(
"mbf_par_smart_temp_high",
"MBF_PAR_SMART_TEMP_HIGH",
18,
SetpointKind.SMART_TEMP_HIGH,
18,
),
pytest.param(
"mbf_par_smart_temp_low",
"MBF_PAR_SMART_TEMP_LOW",
12,
SetpointKind.SMART_TEMP_LOW,
12,
),
],
)
async def test_setpoint_write_targets_map_to_kind_and_scale(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
suffix: str,
data_key: str,
value: float,
kind: SetpointKind,
expected_raw: int,
) -> None:
"""Each setpoint entity writes to its own SetpointKind with the right scale.
A wrong enum or scale in a single description would pass the snapshots and
the generic debounce tests, so exercise every write target explicitly.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={data_key: expected_raw}
)
await setup_integration(hass, mock_config_entry_number)
entity_id = _number_entity_id(hass, mock_config_entry_number, suffix)
mock_neopool_client.async_set_setpoint.reset_mock()
await _write(hass, freezer, entity_id, value)
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(kind, expected_raw)
async def test_number_state_returns_raw_register_value(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""The entity state reflects the register value the coordinator holds."""
await setup_integration(hass, mock_config_entry_number)
await _poll(
hass, freezer, mock_neopool_client, {**MOCK_POOL_DATA, "MBF_PAR_PH1": 7.55}
)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 7.55
@pytest.mark.parametrize(
("visual_style", "expected_unit", "expected_step", "expected_max"),
[
pytest.param(0x4000, PERCENTAGE, "1.0", 100, id="percent"),
pytest.param(0x2000, "g/h", "0.1", 85, id="grh"),
],
)
async def test_hidro_units_follow_visual_style(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
visual_style: int,
expected_unit: str,
expected_step: str,
expected_max: int,
) -> None:
"""MBF_PAR_HIDRO unit, step and max follow the reported percent/g-h mode.
MBF_PAR_UICFG_MACH_VISUAL_STYLE forces percentage (0x4000) or g/h (0x2000).
Percent mode caps at 100; g/h mode uses the reported nominal
(MBF_PAR_HIDRO_NOM). The nominal is a non-100 value so percent fails if the
maximum falls back to reading it.
"""
await setup_integration(hass, mock_config_entry_number)
await _poll(
hass,
freezer,
mock_neopool_client,
{
**MOCK_POOL_DATA,
"MBF_PAR_HIDRO_NOM": 85,
"MBF_PAR_MODEL": 0x0002,
"MBF_PAR_UICFG_MACH_VISUAL_STYLE": visual_style,
},
)
entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_hidro")
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == expected_unit
assert state.attributes["step"] == float(expected_step)
assert state.attributes["max"] == expected_max
async def test_masked_number_state_decodes_field(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Masked compound numbers decode their field from the shared register.
HIDRO_COVER_REDUCTION / SHUTDOWN_TEMPERATURE share register 0x042D: the
lower byte holds cover reduction %, the upper byte the shutdown temperature.
Each entity's state must isolate its own field.
"""
await setup_integration(hass, mock_config_entry_number)
await _poll(
hass,
freezer,
mock_neopool_client,
{**MOCK_POOL_DATA, "MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19},
)
cover_id = _number_entity_id(
hass, mock_config_entry_number, "mbf_par_hidro_cover_reduction"
)
shutdown_id = _number_entity_id(
hass, mock_config_entry_number, "mbf_par_hidro_shutdown_temperature"
)
cover = hass.states.get(cover_id)
shutdown = hass.states.get(shutdown_id)
assert cover is not None and float(cover.state) == 25
assert shutdown is not None and float(shutdown.state) == 12
@pytest.mark.parametrize(
("key_suffix", "flag", "value", "register_result"),
[
(
"mbf_par_hidro_cover_reduction",
MaskedFlag.HIDRO_COVER_REDUCTION_PERCENT,
50,
0x0C32, # shutdown temp 12 (0x0C) in the high byte, cover 50 (0x32)
),
(
"mbf_par_hidro_shutdown_temperature",
MaskedFlag.HIDRO_SHUTDOWN_TEMPERATURE,
15,
0x0F19, # shutdown temp 15 (0x0F) in the high byte, cover 25 (0x19)
),
],
)
async def test_masked_number_write_passes_field_value(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
key_suffix: str,
flag: MaskedFlag,
value: float,
register_result: int,
) -> None:
"""Writing one masked number dispatches to async_set_masked_register.
The read-modify-write that keeps the sibling byte intact is a lib concern
(``async_set_masked_register`` performs it internally). Each entity passes
its own *field value*, not the packed 16-bit register, tagged with its own
masked flag: the two share register 0x042D but map to distinct fields.
"""
mock_neopool_client.async_set_masked_register = AsyncMock(
return_value={"MBF_PAR_HIDRO_COVER_REDUCTION": register_result}
)
await setup_integration(hass, mock_config_entry_number)
await _poll(
hass,
freezer,
mock_neopool_client,
{**MOCK_POOL_DATA, "MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19},
)
entity_id = _number_entity_id(hass, mock_config_entry_number, key_suffix)
mock_neopool_client.async_set_masked_register.reset_mock()
await _write(hass, freezer, entity_id, value)
mock_neopool_client.async_set_masked_register.assert_awaited_once_with(flag, value)
state = hass.states.get(entity_id)
assert state is not None
assert float(state.state) == value
async def test_write_works_after_entity_id_change(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A write still reaches the device after the entity is renamed.
Changing the entity ID removes and re-adds the same object. If removal
leaves the removing flag set, every later flush aborts and the write never
reaches the device, so the write must run against the new entity ID.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
new_entity_id = f"{ph1_entity_id}_renamed"
entity_registry.async_update_entity(ph1_entity_id, new_entity_id=new_entity_id)
await hass.async_block_till_done()
assert hass.states.get(new_entity_id) is not None
mock_neopool_client.async_set_setpoint.reset_mock()
await _write(hass, freezer, new_entity_id, 7.5)
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(
SetpointKind.PH_MAX, 750
)
state = hass.states.get(new_entity_id)
assert state is not None
assert float(state.state) == 7.5
async def test_masked_writes_are_serialized(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Concurrent masked writes serialize through the coordinator lock.
Cover reduction and shutdown temperature share one packed register, so
their read-modify-write must not overlap; otherwise the later write would
restore the sibling byte to a stale value.
"""
overlap = False
active = 0
async def _slow_masked(flag: MaskedFlag, value: int) -> dict[str, Any]:
nonlocal overlap, active
active += 1
if active > 1:
overlap = True
await asyncio.sleep(0)
active -= 1
return {"MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19}
mock_neopool_client.async_set_masked_register = AsyncMock(side_effect=_slow_masked)
await setup_integration(hass, mock_config_entry_number)
await _poll(
hass,
freezer,
mock_neopool_client,
{**MOCK_POOL_DATA, "MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19},
)
cover_id = _number_entity_id(
hass, mock_config_entry_number, "mbf_par_hidro_cover_reduction"
)
shutdown_id = _number_entity_id(
hass, mock_config_entry_number, "mbf_par_hidro_shutdown_temperature"
)
# Queue both writes so their debounced flushes race the shared register.
cover_task = _set_value_nowait(hass, cover_id, 50)
shutdown_task = _set_value_nowait(hass, shutdown_id, 20)
await _flush(hass, freezer)
await asyncio.gather(cover_task, shutdown_task)
assert not overlap
assert mock_neopool_client.async_set_masked_register.await_count == 2
@pytest.mark.parametrize(
"write_error",
[
pytest.param(NeoPoolConnectionError("boom"), id="lib-connection-error"),
pytest.param(TimeoutError("boom"), id="timeout"),
pytest.param(OSError("boom"), id="os-error"),
],
)
async def test_number_write_raises_communication_error(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
write_error: Exception,
) -> None:
"""A device error in the debounced write surfaces to the blocking caller.
The write is coalesced behind a shared future the blocking service call
awaits, so a communication failure raises HomeAssistantError (matching the
switch platform) instead of being swallowed.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=write_error)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
task = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _flush(hass, freezer)
with pytest.raises(HomeAssistantError) as err:
await task
assert err.value.translation_key == "modbus_communication_error"
mock_neopool_client.async_set_setpoint.assert_awaited_once()
async def test_optimistic_state_rolls_back_on_write_failure(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A failed write rolls the optimistic state back to the device reading.
The requested value shows optimistically while the write is queued; when the
device rejects it, the state must snap back to the last known register value
rather than linger on the value that never took.
"""
await setup_integration(hass, mock_config_entry_number)
await _poll(
hass, freezer, mock_neopool_client, {**MOCK_POOL_DATA, "MBF_PAR_PH1": 7.0}
)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint = AsyncMock(
side_effect=NeoPoolConnectionError("boom")
)
task = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _let_park(hass)
# Optimistic value is shown while the write is in flight.
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 7.5
await _flush(hass, freezer)
with pytest.raises(HomeAssistantError):
await task
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 7.0
async def test_coalesced_callers_all_succeed_together(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Three quick set_value calls collapse to one write and all callers succeed.
Every caller awaits the same coalesce future, so the single write of the
last value resolves them together.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
tasks = [_set_value_nowait(hass, ph1_entity_id, value) for value in (7.0, 7.2, 7.5)]
await _flush(hass, freezer)
results = await asyncio.gather(*tasks)
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(
SetpointKind.PH_MAX, 750
)
assert results == [None, None, None]
async def test_coalesced_callers_all_raise_together(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A failed coalesced write raises for every caller in the debounce window.
All three blocking callers await the shared future, so the single write's
device error surfaces to each of them.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
side_effect=NeoPoolConnectionError("boom")
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
tasks = [_set_value_nowait(hass, ph1_entity_id, value) for value in (7.0, 7.2, 7.5)]
await _flush(hass, freezer)
results = await asyncio.gather(*tasks, return_exceptions=True)
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(
SetpointKind.PH_MAX, 750
)
assert all(isinstance(r, HomeAssistantError) for r in results)
async def test_failed_write_survives_a_cancelled_coalesced_caller(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A failed coalesced write raises for the survivor and logs no warning.
Two callers share one coalesce future; one is cancelled before the delayed
write fails. Cancelling a caller makes asyncio.shield attach its own logger
to the shared future, so failing it via set_exception would be reported as
an unretrieved error at teardown. The write instead carries its outcome as
the future's result, so the survivor still re-raises the device error and
no "exception in shielded future" warning is logged.
"""
in_write = asyncio.Event()
release = asyncio.Event()
async def _blocking_boom(kind: SetpointKind, value: int) -> dict[str, Any]:
in_write.set()
await release.wait()
raise NeoPoolConnectionError("boom")
mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_blocking_boom)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
# Two callers coalesce onto one batch; the write enters the library call and
# blocks there, so both are parked on the shared shielded future.
victim = _set_value_nowait(hass, ph1_entity_id, 7.5)
survivor = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _let_park(hass)
freezer.tick(FLUSH)
async_fire_time_changed(hass)
await in_write.wait()
# Cancel one caller; the shield spares the batch, so the write still runs.
victim.cancel()
with pytest.raises(asyncio.CancelledError):
await victim
# Let the shielded write finish and fail; the survivor observes the error.
release.set()
with pytest.raises(HomeAssistantError):
await survivor
await hass.async_block_till_done()
# Force a GC pass so any unretrieved shielded future would surface a warning.
gc.collect()
await asyncio.sleep(0)
assert "exception in shielded future" not in caplog.text
assert "exception was never retrieved" not in caplog.text
async def test_write_queued_during_flush_gets_its_own_outcome(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A set_value arriving mid-flush writes on its own, not the in-flight batch.
While the first flush is awaiting device I/O, a new set_value must not reuse
the in-flight future: it would return the first write's outcome and its own
value would never reach the device. Each batch gets its own future and its
own serialized write.
"""
in_write = asyncio.Event()
release = asyncio.Event()
seen: list[int] = []
async def _gated_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]:
seen.append(value)
if len(seen) == 1:
in_write.set()
await release.wait()
return {"MBF_PAR_PH1": value}
mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_gated_setpoint)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
seen.clear()
# First write enters the library call and blocks there.
first = _set_value_nowait(hass, ph1_entity_id, 7.0)
freezer.tick(FLUSH)
async_fire_time_changed(hass)
await in_write.wait()
# A second value arrives while the first write is still in flight.
second = _set_value_nowait(hass, ph1_entity_id, 8.0)
await _let_park(hass)
# Release the first write, then flush the second batch.
release.set()
await first
await _flush(hass, freezer)
await second
# Two distinct writes ran, each with its own value.
assert seen == [700, 800]
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 8.0
async def test_same_value_queued_during_flush_still_resolves(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A second set_value of the same value mid-flush still gets an outcome.
The two batches carry the same float value, so telling them apart by value
alone fails: the first flush would clear the second batch's pending value,
its own flush would then find nothing queued, and the newer caller would be
left cancelled. Each batch is tagged, so the newer caller resolves.
"""
in_write = asyncio.Event()
release = asyncio.Event()
seen: list[int] = []
async def _gated_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]:
seen.append(value)
if len(seen) == 1:
in_write.set()
await release.wait()
return {"MBF_PAR_PH1": value}
mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_gated_setpoint)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
seen.clear()
# First write enters the library call and blocks there.
first = _set_value_nowait(hass, ph1_entity_id, 7.0)
freezer.tick(FLUSH)
async_fire_time_changed(hass)
await in_write.wait()
# The same value arrives again while the first write is still in flight.
second = _set_value_nowait(hass, ph1_entity_id, 7.0)
await _let_park(hass)
# Release the first write, then flush the second batch. Both callers must
# observe an outcome; neither is left cancelled.
release.set()
await first
await _flush(hass, freezer)
await second
# Both same-valued batches reached the client as distinct writes.
assert seen == [700, 700]
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 7.0
async def test_unexpected_write_error_reaches_caller(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""An unexpected write error still fails the blocking caller.
A raise outside the device-error set must not resolve the coalesce future as
a success: the caller would otherwise see a successful service call while the
value never reached the device. It surfaces unchanged, not relabeled as a
communication error.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=ValueError("boom"))
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
task = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _flush(hass, freezer)
with pytest.raises(ValueError, match="boom"):
await task
async def test_post_write_merge_failure_reaches_caller(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A raise after a successful device write still fails the caller.
The device write succeeds, but merging the result into the coordinator
raises. The flush must not fall through to a success result: the failure
surfaces unchanged, not relabeled as a communication error, since the write
itself succeeded.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
task = _set_value_nowait(hass, ph1_entity_id, 7.5)
with patch.object(
mock_config_entry_number.runtime_data,
"async_set_updated_data",
side_effect=RuntimeError("merge boom"),
):
await _flush(hass, freezer)
with pytest.raises(RuntimeError, match="merge boom"):
await task
async def test_optimistic_value_survives_overlapping_flush(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A newer optimistic value is not clobbered by an in-flight older write.
The first flush holds the write lock while its device call blocks. A second
debounce elapses and queues a newer value. When the first write commits, it
must keep showing the newer optimistic value, not republish its own older
value for the duration of the second write.
"""
release = asyncio.Event()
in_write = asyncio.Event()
seen: list[int] = []
async def _gated_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]:
seen.append(value)
if len(seen) == 1:
in_write.set()
await release.wait()
return {"MBF_PAR_PH1": value}
mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_gated_setpoint)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
seen.clear()
# First write enters the library call and blocks there.
first = _set_value_nowait(hass, ph1_entity_id, 7.0)
freezer.tick(FLUSH)
async_fire_time_changed(hass)
await in_write.wait()
# A newer value arrives while the first write is still in flight.
second = _set_value_nowait(hass, ph1_entity_id, 8.0)
await _let_park(hass)
# Release the first write; it commits its older value to the coordinator but
# must keep showing the newer optimistic value rather than republishing 7.0.
release.set()
await first
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 8.0
# The second write then runs and confirms the newer value on the device.
await _flush(hass, freezer)
await second
assert seen == [700, 800]
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 8.0
async def test_repeated_set_value_writes_only_latest(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Two quick set_value calls debounce to a single write of the last value."""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
task1 = _set_value_nowait(hass, ph1_entity_id, 7.0)
task2 = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _flush(hass, freezer)
await asyncio.gather(task1, task2)
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(
SetpointKind.PH_MAX, 750
)
async def test_stepper_settle_restarts_timer(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Each click restarts the timer; the write fires after the last click.
Two clicks 2s apart (each under the 3s settle delay) span 4s in total. A
fixed-window debounce anchored to the first click would have written the
intermediate value at 3s; the restart-on-click behavior must instead write
only the final value once the stepper settles.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
task1 = _set_value_nowait(hass, ph1_entity_id, 7.0)
await _advance(hass, freezer)
task2 = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _advance(hass, freezer)
# 4s elapsed since the first click, but only 2s since the last: no write yet.
mock_neopool_client.async_set_setpoint.assert_not_awaited()
await _flush(hass, freezer)
await asyncio.gather(task1, task2)
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(
SetpointKind.PH_MAX, 750
)
async def test_no_write_when_settled_value_unchanged(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A sweep that lands back on the current value writes nothing.
Stepping away and back to the device's value must not burn an EEPROM cycle:
the settled raw value equals the coordinator's, so the write is skipped.
An off-step request that rounds to the same register must also drop the
optimistic state back to the device reading instead of lingering.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
await _poll(
hass, freezer, mock_neopool_client, {**MOCK_POOL_DATA, "MBF_PAR_PH1": 7.5}
)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
task1 = _set_value_nowait(hass, ph1_entity_id, 8.0)
task2 = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _flush(hass, freezer)
await asyncio.gather(task1, task2)
mock_neopool_client.async_set_setpoint.assert_not_awaited()
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 7.5
# Off-step request rounding to the same register: no write, and the UI must
# snap back from the optimistic 7.504 to the device value.
await _write(hass, freezer, ph1_entity_id, 7.504)
mock_neopool_client.async_set_setpoint.assert_not_awaited()
state = hass.states.get(ph1_entity_id)
assert state is not None
assert float(state.state) == 7.5
async def test_pending_write_dropped_on_remove(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Unloading cancels an un-elapsed write and releases the awaiting caller.
The queued write must never fire, and the blocking caller awaiting the
coalesce future exits cleanly (cancellation, not an error) when the entity
is removed mid-wait.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
task = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _let_park(hass)
# Unload before the cooldown elapses: the queued write must never fire.
await hass.config_entries.async_unload(mock_config_entry_number.entry_id)
await _flush(hass, freezer)
# The awaiting caller returns without raising once the future is cancelled.
await task
mock_neopool_client.async_set_setpoint.assert_not_awaited()
async def test_external_cancel_propagates_but_spares_coalesced_caller(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Cancelling one caller re-raises for it but spares the coalesced batch.
Two blocking callers land in the same debounce window and share one
coalesce future. Cancelling one caller's service task must propagate the
cancellation to that caller alone: the shield keeps the shared future
alive, so the write still fires and the surviving caller observes its
outcome without a spurious cancellation.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
mock_neopool_client.async_set_setpoint.reset_mock()
victim = _set_value_nowait(hass, ph1_entity_id, 7.5)
survivor = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _let_park(hass)
# Cancel only the victim's task; its cancellation must propagate to it.
victim.cancel()
with pytest.raises(asyncio.CancelledError):
await victim
# The batch is untouched: the queued write fires and the survivor, still
# awaiting the shielded future, returns cleanly with the write's outcome.
await _flush(hass, freezer)
await survivor
mock_neopool_client.async_set_setpoint.assert_awaited_once_with(
SetpointKind.PH_MAX, 750
)
async def test_inflight_write_skips_coordinator_on_remove(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A write already in flight when the entity is removed skips the coordinator.
The scheduled call can only be cancelled before it fires. Once the write is
inside a library call and the entity is removed, it must not merge into or
refresh the coordinator whose client is being torn down.
"""
in_write = asyncio.Event()
release = asyncio.Event()
async def _blocking_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]:
in_write.set()
await release.wait()
return {"MBF_PAR_PH1": value}
mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_blocking_setpoint)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
task = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _let_park(hass)
# Let the timer fire and the write enter the library call, then block there.
freezer.tick(FLUSH)
async_fire_time_changed(hass)
await in_write.wait()
with patch.object(
mock_config_entry_number.runtime_data, "async_set_updated_data"
) as mock_update:
await hass.config_entries.async_unload(mock_config_entry_number.entry_id)
release.set()
await hass.async_block_till_done(wait_background_tasks=True)
await task
mock_neopool_client.async_set_setpoint.assert_awaited_once()
mock_update.assert_not_called()
async def test_client_close_waits_for_inflight_flush(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""The client close waits for an in-flight flush, so no write outlives it.
Removal cancels the flush task and awaits it before ``async_unload_entry``
closes the client, so the device call has unwound by the time the client
goes away. A close reaching a live connection while the write is still in
the library call would race the teardown.
"""
in_write = asyncio.Event()
write_active = False
close_saw_write_active: bool | None = None
async def _blocking_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]:
nonlocal write_active
write_active = True
in_write.set()
try:
# Never released: removal must cancel this to let the unload finish.
await asyncio.Event().wait()
return {"MBF_PAR_PH1": value}
finally:
write_active = False
async def _record_close() -> None:
nonlocal close_saw_write_active
close_saw_write_active = write_active
mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_blocking_setpoint)
mock_neopool_client.close = AsyncMock(side_effect=_record_close)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
task = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _let_park(hass)
# Let the timer fire and the write enter the library call, then block there.
freezer.tick(FLUSH)
async_fire_time_changed(hass)
await in_write.wait()
# Unload while the write is in flight. Removal cancels and awaits the flush
# task, unwinding the setpoint call, so the unload completes without a hang.
assert await hass.config_entries.async_unload(mock_config_entry_number.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
await task
mock_neopool_client.close.assert_awaited_once()
# The in-flight device call had unwound before the client was closed.
assert close_saw_write_active is False
async def test_client_close_waits_for_all_overlapping_flushes(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Removal cancels every overlapping flush, not just the most recent one.
Two set_value calls spaced beyond the settle delay start two flush tasks.
The first is inside the library call (holding the flush lock) when the
second fires and blocks on that lock. Tracking only the latest task would
leave the first device call live past removal, racing the client close.
"""
in_write = asyncio.Event()
write_active = False
close_saw_write_active: bool | None = None
async def _blocking_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]:
nonlocal write_active
write_active = True
in_write.set()
try:
# Never released: removal must cancel this to let the unload finish.
await asyncio.Event().wait()
return {"MBF_PAR_PH1": value}
finally:
write_active = False
async def _record_close() -> None:
nonlocal close_saw_write_active
close_saw_write_active = write_active
mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_blocking_setpoint)
mock_neopool_client.close = AsyncMock(side_effect=_record_close)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
# First write enters the library call and blocks, holding the flush lock.
first = _set_value_nowait(hass, ph1_entity_id, 7.0)
await _let_park(hass)
freezer.tick(FLUSH)
async_fire_time_changed(hass)
await in_write.wait()
# A second value's flush fires while the first is in flight; it blocks on
# the flush lock as a separate task, so two flush tasks are now active.
second = _set_value_nowait(hass, ph1_entity_id, 8.0)
await _let_park(hass)
freezer.tick(FLUSH)
async_fire_time_changed(hass)
await _let_park(hass)
# Unload must cancel and await both tasks, including the first still in the
# library call, before the client closes.
assert await hass.config_entries.async_unload(mock_config_entry_number.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
await first
await second
mock_neopool_client.close.assert_awaited_once()
assert close_saw_write_active is False
def _get_number_entity(hass: HomeAssistant, entity_id: str) -> Any:
"""Return the live NeoPoolNumber object for entity_id."""
for platform in entity_platform.async_get_platforms(hass, "neopool"):
if entity_id in platform.entities:
return platform.entities[entity_id]
raise AssertionError(f"no number entity {entity_id}")
async def test_flush_aborts_when_removed_while_holding_lock(
hass: HomeAssistant,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A flush that finds the entity removed after winning the lock aborts.
Removal cancels every tracked flush task, so a queued flush waiting on the
lock is normally canceled before it can write; the in-lock removal guard
only fires if a flush wins the lock in the same tick removal flips the flag.
Drive that directly: hold the flush lock from the test, flip removing, then
release the lock so the parked flush hits the guard before it touches the
device.
"""
mock_neopool_client.async_set_setpoint = AsyncMock(
return_value={"MBF_PAR_PH1": 750}
)
await setup_integration(hass, mock_config_entry_number)
ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1")
entity = _get_number_entity(hass, ph1_entity_id)
await entity._flush_lock.acquire()
task = _set_value_nowait(hass, ph1_entity_id, 7.5)
await _let_park(hass)
# Fire the debounce timer without async_block_till_done: the flush task
# parks on the lock we hold, so waiting on it here would deadlock.
freezer.tick(FLUSH)
async_fire_time_changed(hass)
await _let_park(hass)
# The flush is parked on the lock the test holds. Flip removing, then hand
# the lock over so the flush enters and aborts at the in-lock guard.
entity._removing = True
entity._flush_lock.release()
await task
# The guard aborted before any device write.
mock_neopool_client.async_set_setpoint.assert_not_awaited()
entity._removing = False
@pytest.mark.usefixtures("mock_neopool_client")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry_number: MockConfigEntry,
) -> None:
"""Snapshot every entity registered by the number platform."""
with patch("homeassistant.components.neopool.PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, mock_config_entry_number)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry_number.entry_id
)
async def test_setup_when_modules_absent(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry_number: MockConfigEntry,
mock_neopool_client: MagicMock,
minimal_pool_data: dict[str, Any],
) -> None:
"""No number entities register when no modules are present.
Every number is gated on a module or relay, so a controller reporting
none registers nothing. In particular the cover-reduction number must
not appear without a hydrolysis module.
"""
mock_neopool_client.async_read_all.return_value = minimal_pool_data
with patch("homeassistant.components.neopool.PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, mock_config_entry_number)
number_entries = [
e
for e in er.async_entries_for_config_entry(
entity_registry, mock_config_entry_number.entry_id
)
if e.domain == NUMBER_DOMAIN
]
assert number_entries == []
@pytest.mark.usefixtures("mock_neopool_client")
async def test_cover_gated_numbers_absent_when_option_off(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""The cover-gated numbers are not created while their option is off.
Both numbers gate on CONF_USE_COVER_SENSOR, so a config entry without it
must register neither, even though the hydrolysis module is present.
"""
await setup_integration(hass, mock_config_entry)
gated = [
e
for e in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
if e.domain == NUMBER_DOMAIN
and e.unique_id.endswith(
("_mbf_par_hidro_cover_reduction", "_mbf_par_hidro_shutdown_temperature")
)
]
assert gated == []