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

661 lines
23 KiB
Python

"""Tests for the NeoPool switch platform."""
from datetime import timedelta
from typing import Any
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from neopool_modbus import InvalidStateReason, NeoPoolInvalidStateError
from neopool_modbus.decoders import encode_cell_boost
from neopool_modbus.exceptions import NeoPoolConnectionError
from neopool_modbus.registers import (
BinaryConfigFlag,
BitmaskConfigFlag,
FiltValveMode,
RelayKind,
TimerRelayMode,
)
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.neopool.const import FOLLOW_UP_REFRESH_DELAY
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import MOCK_POOL_DATA
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def _turn_on(hass: HomeAssistant, entity_id: str) -> None:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": entity_id},
blocking=True,
)
async def _turn_off(hass: HomeAssistant, entity_id: str) -> None:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{"entity_id": entity_id},
blocking=True,
)
def _entity_id_by_suffix(
hass: HomeAssistant, entry: MockConfigEntry, suffix: str
) -> str:
"""Resolve a switch entity_id by its unique_id suffix."""
registry = er.async_get(hass)
entries = [
e
for e in er.async_entries_for_config_entry(registry, entry.entry_id)
if e.domain == SWITCH_DOMAIN and e.unique_id.endswith(suffix)
]
assert entries, f"no switch entity with unique_id ending in {suffix}"
return entries[0].entity_id
async def test_manual_filtration_turn_on_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""Manual filtration dispatches to async_set_manual_filtration(state)."""
mock_neopool_client.async_set_manual_filtration.side_effect = lambda state: {
"Filtration Pump": state,
"MBF_PAR_FILT_MANUAL_STATE": int(state),
}
await setup_integration(hass, mock_config_entry)
await _turn_on(hass, "switch.neopool_filtration")
mock_neopool_client.async_set_manual_filtration.assert_called_with(True)
mock_neopool_client.async_set_manual_filtration.reset_mock()
await _turn_off(hass, "switch.neopool_filtration")
mock_neopool_client.async_set_manual_filtration.assert_called_with(False)
async def test_manual_filtration_raises_when_not_manual_mode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""turn_on/off raises ServiceValidationError when filtration mode is not manual."""
await setup_integration(hass, mock_config_entry)
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILT_MODE": 1,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_neopool_client.async_set_manual_filtration.reset_mock()
with pytest.raises(ServiceValidationError) as exc:
await _turn_on(hass, "switch.neopool_filtration")
assert exc.value.translation_key == "filtration_not_manual_mode"
with pytest.raises(ServiceValidationError):
await _turn_off(hass, "switch.neopool_filtration")
mock_neopool_client.async_set_manual_filtration.assert_not_called()
async def test_manual_filtration_raises_when_boost_active(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Manual mode but active boost: the pre-check must block the write."""
await setup_integration(hass, mock_config_entry)
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILT_MODE": 0,
"MBF_CELL_BOOST": encode_cell_boost("active"),
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_neopool_client.async_set_manual_filtration.reset_mock()
with pytest.raises(ServiceValidationError) as exc:
await _turn_on(hass, "switch.neopool_filtration")
assert exc.value.translation_key == "filtration_boost_active"
mock_neopool_client.async_set_manual_filtration.assert_not_called()
async def test_manual_filtration_is_on_reflects_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""is_on tracks the "Filtration Pump" relay state, regardless of mode."""
await setup_integration(hass, mock_config_entry)
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILT_MODE": 1,
"Filtration Pump": True,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("switch.neopool_filtration").state == STATE_ON
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILT_MODE": 1,
"Filtration Pump": False,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("switch.neopool_filtration").state == STATE_OFF
async def test_filtration_maps_filtration_reason_to_dedicated_key(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""A FILTRATION_NOT_IN_MANUAL_MODE reason routes to the filtration key.
The cache says manual mode, so the pre-check passes, but the library
rejects the write for a race where the device left manual since the poll.
"""
await setup_integration(hass, mock_config_entry)
mock_neopool_client.async_set_manual_filtration.side_effect = (
NeoPoolInvalidStateError(
"not in manual filtration mode",
reason=InvalidStateReason.FILTRATION_NOT_IN_MANUAL_MODE,
)
)
with pytest.raises(ServiceValidationError) as exc:
await _turn_on(hass, "switch.neopool_filtration")
assert exc.value.translation_key == "filtration_not_manual_mode"
@pytest.mark.parametrize(
("suffix", "flag"),
[
("_mbf_par_clima_onoff", BinaryConfigFlag.CLIMA_ONOFF),
("_mbf_par_smart_anti_freeze", BinaryConfigFlag.SMART_ANTI_FREEZE),
("_mbf_par_uv_mode", BinaryConfigFlag.UV_MODE),
],
ids=lambda v: v.name if isinstance(v, BinaryConfigFlag) else v,
)
async def test_binary_flag_switch_writes_flag(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
suffix: str,
flag: BinaryConfigFlag,
) -> None:
"""The grouped switches dispatch to async_set_binary_flag with their flag."""
await setup_integration(hass, mock_config_entry)
entity_id = _entity_id_by_suffix(hass, mock_config_entry, suffix)
mock_neopool_client.async_set_binary_flag.reset_mock()
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_binary_flag.assert_called_with(flag, True)
mock_neopool_client.async_set_binary_flag.reset_mock()
await _turn_off(hass, entity_id)
mock_neopool_client.async_set_binary_flag.assert_called_with(flag, False)
async def test_hidro_cover_enable_bitmask_writes(
hass: HomeAssistant,
mock_config_entry_switch: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""The hydrolysis cover-enable bitmask switch dispatches to the bitmask API."""
await setup_integration(hass, mock_config_entry_switch)
entity_id = _entity_id_by_suffix(
hass, mock_config_entry_switch, "_mbf_par_hidro_cover_enable"
)
mock_neopool_client.async_set_bitmask_flag.reset_mock()
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_bitmask_flag.assert_called_with(
BitmaskConfigFlag.HIDRO_COVER_ENABLE, True
)
mock_neopool_client.async_set_bitmask_flag.reset_mock()
await _turn_off(hass, entity_id)
mock_neopool_client.async_set_bitmask_flag.assert_called_with(
BitmaskConfigFlag.HIDRO_COVER_ENABLE, False
)
async def test_hidro_bitmask_switches_reflect_options_bitfield(
hass: HomeAssistant,
mock_config_entry_switch: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Both hidro switches read is_on from the shared MBF_PAR_HIDRO_COVER_ENABLE bitfield."""
await setup_integration(hass, mock_config_entry_switch)
cover_id = _entity_id_by_suffix(
hass, mock_config_entry_switch, "_mbf_par_hidro_cover_enable"
)
shutdown_id = _entity_id_by_suffix(
hass, mock_config_entry_switch, "_mbf_par_hidro_temp_shutdown"
)
assert hass.states.get(cover_id).state == STATE_OFF
assert hass.states.get(shutdown_id).state == STATE_OFF
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_HIDRO_COVER_ENABLE": 0x0003,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(cover_id).state == STATE_ON
assert hass.states.get(shutdown_id).state == STATE_ON
async def test_aux_relay_turn_on_off_writes_relay_state(
hass: HomeAssistant,
mock_config_entry_switch: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""aux1 turn_on/off dispatches to async_set_relay_state(RelayKind.AUX1, state)."""
mock_neopool_client.async_set_relay_state.side_effect = lambda relay, state: {
"AUX1": state
}
await setup_integration(hass, mock_config_entry_switch)
entity_id = _entity_id_by_suffix(hass, mock_config_entry_switch, "_aux1")
mock_neopool_client.async_set_relay_state.reset_mock()
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_called_with(RelayKind.AUX1, True)
mock_neopool_client.async_set_relay_state.reset_mock()
await _turn_off(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_called_with(RelayKind.AUX1, False)
@pytest.mark.parametrize(
("aux_suffix", "block"),
[
("_aux1", "relay_aux1"),
("_aux2", "relay_aux2"),
("_aux3", "relay_aux3"),
("_aux4", "relay_aux4"),
],
)
@pytest.mark.parametrize(
"enable_value",
[
pytest.param(TimerRelayMode.ENABLED, id="auto"),
pytest.param(None, id="missing"),
pytest.param(0, id="disabled"),
pytest.param(2, id="unknown-state"),
],
)
async def test_aux_relay_refuses_when_not_in_manual_mode(
hass: HomeAssistant,
mock_config_entry_switch: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
aux_suffix: str,
block: str,
enable_value: int | None,
) -> None:
"""Aux relay refuses to fire unless the relay is in a manual mode."""
def _timers(
enabled_timers: list[str] | None = None, **_kwargs: Any
) -> dict[str, dict[str, Any]]:
if enable_value is None:
return {}
return {
block: {
"enable": enable_value,
"on": 0,
"interval": 0,
"period": 0,
"countdown": 0,
"stop": None,
}
}
mock_neopool_client.read_all_timers.side_effect = _timers
await setup_integration(hass, mock_config_entry_switch)
entity_id = _entity_id_by_suffix(hass, mock_config_entry_switch, aux_suffix)
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_neopool_client.async_set_relay_state.reset_mock()
with pytest.raises(ServiceValidationError):
await _turn_on(hass, entity_id)
with pytest.raises(ServiceValidationError):
await _turn_off(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_not_called()
async def test_aux_relay_maps_lib_invalid_state_to_service_validation(
hass: HomeAssistant,
mock_config_entry_switch: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""Race window: cache guard passes but the library refuses on write."""
await setup_integration(hass, mock_config_entry_switch)
entity_id = _entity_id_by_suffix(hass, mock_config_entry_switch, "_aux1")
mock_neopool_client.async_set_relay_state.side_effect = NeoPoolInvalidStateError(
"relay in auto mode",
reason=InvalidStateReason.RELAY_IN_AUTO_MODE,
)
with pytest.raises(ServiceValidationError) as exc:
await _turn_on(hass, entity_id)
assert exc.value.translation_key == "relay_in_auto_mode"
@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_aux_relay_maps_communication_error_to_home_assistant_error(
hass: HomeAssistant,
mock_config_entry_switch: MockConfigEntry,
mock_neopool_client: MagicMock,
write_error: Exception,
) -> None:
"""Communication errors on write are surfaced as translated HomeAssistantError."""
await setup_integration(hass, mock_config_entry_switch)
entity_id = _entity_id_by_suffix(hass, mock_config_entry_switch, "_aux1")
mock_neopool_client.async_set_relay_state.side_effect = write_error
with pytest.raises(HomeAssistantError):
await _turn_on(hass, entity_id)
async def test_backwash_turn_on_starts_and_reports_on(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""turn_on starts the backwash and reports ON once the device confirms."""
await setup_integration(hass, mock_config_entry)
entity_id = _entity_id_by_suffix(hass, mock_config_entry, "_backwash")
mock_neopool_client.async_start_backwash.reset_mock()
await _turn_on(hass, entity_id)
mock_neopool_client.async_start_backwash.assert_awaited_once_with()
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILTVALVE_REMAINING": 150,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ON
async def test_backwash_turn_off_stops_and_reports_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""turn_off stops the backwash and reports OFF once the device confirms."""
await setup_integration(hass, mock_config_entry)
entity_id = _entity_id_by_suffix(hass, mock_config_entry, "_backwash")
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILTVALVE_REMAINING": 120,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ON
mock_neopool_client.async_stop_backwash.reset_mock()
await _turn_off(hass, entity_id)
mock_neopool_client.async_stop_backwash.assert_awaited_once_with()
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILTVALVE_REMAINING": 0,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
async def test_backwash_turn_on_raises_when_interval_unset(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""turn_on with no configured duration raises before touching the client."""
await setup_integration(hass, mock_config_entry)
entity_id = _entity_id_by_suffix(hass, mock_config_entry, "_backwash")
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILTVALVE_INTERVAL": 0,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_neopool_client.async_start_backwash.reset_mock()
with pytest.raises(ServiceValidationError) as exc:
await _turn_on(hass, entity_id)
assert exc.value.translation_key == "filtvalve_interval_not_set"
mock_neopool_client.async_start_backwash.assert_not_called()
async def test_backwash_is_on_tracks_remaining_countdown(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""is_on follows MBF_PAR_FILTVALVE_REMAINING, so it clears when the cycle ends."""
await setup_integration(hass, mock_config_entry)
entity_id = _entity_id_by_suffix(hass, mock_config_entry, "_backwash")
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILTVALVE_REMAINING": 90,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ON
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILTVALVE_REMAINING": 0,
}
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
async def test_backwash_maps_lib_invalid_state_to_service_validation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""Fail-safe: interval present in cache but the library rejects with a reason."""
await setup_integration(hass, mock_config_entry)
entity_id = _entity_id_by_suffix(hass, mock_config_entry, "_backwash")
mock_neopool_client.async_start_backwash.side_effect = NeoPoolInvalidStateError(
"no interval",
reason=InvalidStateReason.FILTVALVE_INTERVAL_NOT_SET,
)
with pytest.raises(ServiceValidationError) as exc:
await _turn_on(hass, entity_id)
assert exc.value.translation_key == "filtvalve_interval_not_set"
@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF])
async def test_backwash_raises_when_valve_in_auto(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
service: str,
) -> None:
"""Toggling with the valve in AUTO mode raises before touching the client."""
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILTVALVE_MODE": FiltValveMode.AUTO.value,
}
await setup_integration(hass, mock_config_entry)
entity_id = _entity_id_by_suffix(hass, mock_config_entry, "_backwash")
mock_neopool_client.async_start_backwash.reset_mock()
mock_neopool_client.async_stop_backwash.reset_mock()
with pytest.raises(ServiceValidationError) as exc:
await hass.services.async_call(
SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True
)
assert exc.value.translation_key == "filtvalve_in_auto_mode"
mock_neopool_client.async_start_backwash.assert_not_called()
mock_neopool_client.async_stop_backwash.assert_not_called()
async def test_backwash_absent_without_filtvalve(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""No backwash switch is registered when the filter valve is absent."""
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILTVALVE_GPIO": 0,
"MBF_PAR_FILTVALVE_ENABLE": 0,
}
await setup_integration(hass, mock_config_entry)
matches = [
e
for e in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
if e.domain == SWITCH_DOMAIN and e.unique_id.endswith("_backwash")
]
assert matches == []
async def test_manual_filtration_absent_without_filt_gpio(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""No manual filtration switch is registered when no filtration relay exists."""
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_FILT_GPIO": 0,
}
await setup_integration(hass, mock_config_entry)
matches = [
e
for e in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
if e.domain == SWITCH_DOMAIN
and e.unique_id.endswith("_mbf_par_filt_manual_state")
]
assert matches == []
@pytest.mark.usefixtures("mock_neopool_client")
async def test_aux_and_cover_absent_when_options_off(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""No aux or cover switches are created while their options are off."""
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 == SWITCH_DOMAIN
and (
e.unique_id.endswith(("_aux1", "_aux2", "_aux3", "_aux4"))
or e.unique_id.endswith("_mbf_par_hidro_cover_enable")
)
]
assert gated == []
async def test_switch_write_schedules_follow_up_refresh(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""A successful write triggers a second refresh after the follow-up delay."""
await setup_integration(hass, mock_config_entry)
entity_id = _entity_id_by_suffix(hass, mock_config_entry, "_mbf_par_clima_onoff")
reads_before = mock_neopool_client.async_read_all.await_count
await _turn_on(hass, entity_id)
freezer.tick(timedelta(seconds=FOLLOW_UP_REFRESH_DELAY + 0.5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_neopool_client.async_read_all.await_count > reads_before
@pytest.mark.usefixtures("mock_neopool_client")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry_switch: MockConfigEntry,
) -> None:
"""Snapshot every switch entity registered by the platform."""
with patch("homeassistant.components.neopool.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry_switch)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry_switch.entry_id
)