Add light platform and options flow to NeoPool (#176039)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Miloš Svašek
2026-08-04 11:45:21 +02:00
committed by GitHub
co-authored by Joost Lekkerkerker
parent 9341ffdd63
commit 1f1b0a30d0
11 changed files with 727 additions and 8 deletions
@@ -22,6 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NeoPoolConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: NeoPoolConfigEntry) -> bool:
"""Unload a NeoPool config entry."""
entry.runtime_data.cancel_follow_up_refresh()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
await entry.runtime_data.client.close()
@@ -11,10 +11,22 @@ from neopool_modbus.exceptions import (
from neopool_modbus.registers import DEFAULT_MODBUS_FRAMER
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import callback
from .const import CURRENT_VERSION, DEFAULT_PORT, DEFAULT_UNIT_ID, DOMAIN
from .const import (
CONF_USE_LIGHT,
CURRENT_VERSION,
DEFAULT_PORT,
DEFAULT_UNIT_ID,
DOMAIN,
)
from .coordinator import NeoPoolConfigEntry
async def _async_probe(user_input: dict[str, Any]) -> tuple[str | None, str | None]:
@@ -38,6 +50,15 @@ class NeoPoolConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = CURRENT_VERSION
@staticmethod
@callback
@override
def async_get_options_flow(
config_entry: NeoPoolConfigEntry,
) -> NeoPoolOptionsFlowHandler:
"""Return the options flow handler."""
return NeoPoolOptionsFlowHandler()
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -73,3 +94,25 @@ class NeoPoolConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=data_schema,
errors=errors,
)
class NeoPoolOptionsFlowHandler(OptionsFlowWithReload):
"""Handle options flow for NeoPool integration."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step of the options flow."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
options = self.config_entry.options
schema = vol.Schema(
{
vol.Optional(
CONF_USE_LIGHT,
default=options.get(CONF_USE_LIGHT, False),
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=schema)
+5 -1
View File
@@ -5,10 +5,14 @@ from homeassistant.const import Platform
DOMAIN = "neopool"
NAME = "NeoPool"
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SENSOR]
DEFAULT_SCAN_INTERVAL = 20 # in seconds
FOLLOW_UP_REFRESH_DELAY = 2.0 # seconds (delay before a 2nd refresh for IO entity)
DEFAULT_PORT = 502
DEFAULT_UNIT_ID = 1
# Options-flow keys.
CONF_USE_LIGHT = "use_light"
CURRENT_VERSION = 6
@@ -6,14 +6,24 @@ from typing import Any, override
from neopool_modbus import NeoPoolModbusClient
from neopool_modbus.exceptions import NeoPoolError
from neopool_modbus.registers import MAX_RELAY_GPIO, find_corrupted_gpio_registers
from neopool_modbus.registers import (
MAX_RELAY_GPIO,
find_corrupted_gpio_registers,
is_valid_relay_gpio,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
from .const import (
CONF_USE_LIGHT,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
FOLLOW_UP_REFRESH_DELAY,
)
_LOGGER = logging.getLogger(__name__)
@@ -43,6 +53,30 @@ 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
def request_refresh_with_followup(
self, delay: float = FOLLOW_UP_REFRESH_DELAY
) -> None:
"""Schedule a follow-up refresh after a delay.
The follow-up catches delayed device state changes that may not
be visible in Modbus registers immediately after a write.
"""
self.cancel_follow_up_refresh()
@callback
def _do_refresh(_now: Any) -> None:
self._follow_up_unsub = None
self.hass.async_create_task(self.async_request_refresh())
self._follow_up_unsub = async_call_later(self.hass, delay, _do_refresh)
def cancel_follow_up_refresh(self) -> None:
"""Cancel any pending follow-up refresh."""
if self._follow_up_unsub:
self._follow_up_unsub()
self._follow_up_unsub = None
def _check_gpio_registers(self, data: dict[str, Any]) -> None:
"""Validate GPIO register values and (re-)raise or clear the repair issue."""
@@ -83,11 +117,42 @@ class NeoPoolCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Clear a previously raised repair issue once the device is healthy.
ir.async_delete_issue(self.hass, DOMAIN, "corrupted_gpio")
def _get_enabled_timers(self, data: dict[str, Any]) -> list[str]:
"""Return the list of timer block names to poll each cycle.
The light timer is polled only when the light entity is enabled in
the options and the lighting GPIO is valid; the entity gates on the
same condition, so relay_light_enable would have no consumer
otherwise.
"""
enabled: list[str] = []
if self.config_entry.options.get(CONF_USE_LIGHT, False) and is_valid_relay_gpio(
data.get("MBF_PAR_LIGHTING_GPIO", 0) or 0
):
enabled.append("relay_light")
return enabled
async def _read_timers_into_data(self, data: dict[str, Any]) -> None:
"""Read every enabled timer block and merge derived fields into data.
Only the ``<timer>_enable`` field is exposed: it is the sole timer
attribute consumed by the light platform (as a manual-mode guard).
Further derived keys will be added by follow-up platform PRs that
consume them.
"""
enabled = self._get_enabled_timers(data)
if not enabled:
return
timers = await self.client.read_all_timers(enabled_timers=enabled)
for t_name, t in timers.items():
data[f"{t_name}_enable"] = t["enable"]
@override
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch the latest data from the pool controller."""
try:
data = await self.client.async_read_all()
await self._read_timers_into_data(data)
except (NeoPoolError, OSError, TimeoutError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -96,5 +161,4 @@ class NeoPoolCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) from err
self._check_gpio_registers(data)
return data
@@ -1,5 +1,13 @@
{
"entity": {
"light": {
"light": {
"default": "mdi:lightbulb-off",
"state": {
"on": "mdi:lightbulb-on"
}
}
},
"sensor": {
"filt_mode": {
"default": "mdi:water-sync",
+136
View File
@@ -0,0 +1,136 @@
"""Light platform for the NeoPool integration."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, override
from neopool_modbus import NeoPoolInvalidStateError
from neopool_modbus.exceptions import NeoPoolError
from neopool_modbus.registers import RelayKind, TimerRelayMode, is_valid_relay_gpio
from homeassistant.components.light import (
ColorMode,
LightEntity,
LightEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_USE_LIGHT, DOMAIN
from .coordinator import NeoPoolConfigEntry, NeoPoolCoordinator
from .entity import NeoPoolEntity
PARALLEL_UPDATES = 1
_LIGHT_TIMER_ENABLE_KEY = "relay_light_enable"
@dataclass(frozen=True, kw_only=True)
class NeoPoolLightEntityDescription(LightEntityDescription):
"""Describes a NeoPool light entity."""
supported_fn: Callable[[dict[str, Any]], bool] | None = None
LIGHT_DESCRIPTIONS: dict[str, NeoPoolLightEntityDescription] = {
"light": NeoPoolLightEntityDescription(
key="light",
translation_key="light",
supported_fn=lambda data: (
"MBF_PAR_LIGHTING_GPIO" in data
and is_valid_relay_gpio(data["MBF_PAR_LIGHTING_GPIO"] or 0)
),
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: NeoPoolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up NeoPool lights from a config entry."""
coordinator = entry.runtime_data
if not entry.options.get(CONF_USE_LIGHT):
return
async_add_entities(
NeoPoolLight(coordinator, key, desc)
for key, desc in LIGHT_DESCRIPTIONS.items()
if desc.supported_fn is None or desc.supported_fn(coordinator.data)
)
class NeoPoolLight(NeoPoolEntity, LightEntity):
"""Representation of a NeoPool light entity."""
entity_description: NeoPoolLightEntityDescription
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_color_mode = ColorMode.ONOFF
def __init__(
self,
coordinator: NeoPoolCoordinator,
key: str,
description: NeoPoolLightEntityDescription,
) -> None:
"""Initialize the NeoPool light entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = (
f"{self.coordinator.config_entry.unique_id}_{key.lower()}"
)
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light ON."""
await self._async_set_state(True)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light OFF."""
await self._async_set_state(False)
async def _async_set_state(self, state: bool) -> None:
"""Write the light relay state.
Refuses the write when the light timer is not in a manual mode
(checked against ``relay_light_enable`` in coordinator data as a
fast pre-check, and by re-raising ``NeoPoolInvalidStateError`` from
the library for the race where device state changed since the poll).
"""
if self.coordinator.data.get(_LIGHT_TIMER_ENABLE_KEY) not in (
TimerRelayMode.ALWAYS_ON,
TimerRelayMode.ALWAYS_OFF,
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="relay_in_auto_mode",
)
try:
overrides = await self.coordinator.client.async_set_relay_state(
RelayKind.LIGHT, state
)
except NeoPoolInvalidStateError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="relay_in_auto_mode",
) from err
except (NeoPoolError, OSError, TimeoutError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="modbus_communication_error",
translation_placeholders={"error": str(err)},
) from err
self.coordinator.async_set_updated_data({**self.coordinator.data, **overrides})
self.coordinator.request_refresh_with_followup()
@property
@override
def is_on(self) -> bool:
"""Return True if the light is ON."""
return bool(self.coordinator.data.get("Pool Light"))
@@ -27,6 +27,11 @@
}
},
"entity": {
"light": {
"light": {
"name": "Pool light"
}
},
"sensor": {
"cell_runtime_part": {
"name": "Cell runtime since reset"
@@ -139,6 +144,9 @@
"exceptions": {
"modbus_communication_error": {
"message": "An error occurred while communicating with the NeoPool controller: {error}"
},
"relay_in_auto_mode": {
"message": "This relay is currently in automatic mode. Change the mode to manual first to control it directly."
}
},
"issues": {
@@ -146,5 +154,19 @@
"description": "The following GPIO register(s) on your pool controller contain invalid values:\n\n{details}\n\nThis typically happens when the Modbus gateway framing mode does not match the integration's framer setting. The affected function(s) will not work correctly until the register(s) are restored to valid values.\n\nSee the integration documentation for repair instructions.",
"title": "Corrupted GPIO register(s) detected"
}
},
"options": {
"step": {
"init": {
"data": {
"use_light": "Enable pool light relay"
},
"data_description": {
"use_light": "Creates entities to control and monitor the pool light relay."
},
"description": "Changes take effect immediately and reload the integration.",
"title": "NeoPool settings"
}
}
}
}
+22
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.neopool.const import (
CONF_USE_LIGHT,
CURRENT_VERSION,
DEFAULT_PORT,
DEFAULT_UNIT_ID,
@@ -112,6 +113,25 @@ def mock_config_entry() -> MockConfigEntry:
)
@pytest.fixture
def mock_config_entry_light() -> MockConfigEntry:
"""Return a config entry with the pool light option enabled."""
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_LIGHT: True},
)
@pytest.fixture
def mock_neopool_client() -> Generator[MagicMock]:
"""Patch the NeoPoolModbusClient and return a configurable mock instance."""
@@ -127,6 +147,8 @@ def mock_neopool_client() -> Generator[MagicMock]:
):
mock_client = mock_client_cls.return_value
mock_client.async_read_all = AsyncMock(return_value=dict(MOCK_POOL_DATA))
mock_client.read_all_timers = AsyncMock(return_value={})
mock_client.async_set_relay_state = AsyncMock(return_value={})
mock_client.close = AsyncMock()
yield mock_client
@@ -0,0 +1,60 @@
# serializer version: 1
# name: test_all_entities[light.neopool_pool_light-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES: 'supported_color_modes'>: list([
<ColorMode.ONOFF: 'onoff'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.neopool_pool_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Pool light',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Pool light',
'platform': 'neopool',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'light',
'unique_id': '1234567890_light',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[light.neopool_pool_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<LightEntityStateAttribute.COLOR_MODE: 'color_mode'>: None,
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NeoPool Pool light',
<LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES: 'supported_color_modes'>: list([
<ColorMode.ONOFF: 'onoff'>,
]),
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <LightEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'light.neopool_pool_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+40 -1
View File
@@ -9,12 +9,17 @@ from neopool_modbus.exceptions import (
)
import pytest
from homeassistant.components.neopool.const import DEFAULT_UNIT_ID, DOMAIN
from homeassistant.components.neopool.const import (
CONF_USE_LIGHT,
DEFAULT_UNIT_ID,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import setup_integration
from .conftest import MOCK_HOST, MOCK_PORT, MOCK_SERIAL
from tests.common import MockConfigEntry
@@ -104,3 +109,37 @@ async def test_user_flow_already_configured(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_neopool_client")
async def test_options_flow_show_form(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Opening the options flow shows the init form."""
await setup_integration(hass, mock_config_entry)
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
@pytest.mark.usefixtures("mock_neopool_client")
async def test_options_flow_save_changes(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Submitting the form persists the new option on the config entry."""
await setup_integration(hass, mock_config_entry)
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{CONF_USE_LIGHT: True},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_config_entry.options[CONF_USE_LIGHT] is True
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
+320
View File
@@ -0,0 +1,320 @@
"""Tests for the NeoPool light platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from neopool_modbus import NeoPoolInvalidStateError
from neopool_modbus.exceptions import NeoPoolConnectionError
from neopool_modbus.registers import RelayKind, TimerRelayMode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.neopool.const import FOLLOW_UP_REFRESH_DELAY
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
@pytest.fixture(autouse=True)
def _seed_light_relay_manual(mock_neopool_client: MagicMock) -> None:
"""Default the light relay timer to a manual mode so writes pass the guard."""
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"relay_light_enable": TimerRelayMode.ALWAYS_OFF,
"Pool Light": False,
}
async def _turn_on(hass: HomeAssistant, entity_id: str) -> None:
await hass.services.async_call(
LIGHT_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(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{"entity_id": entity_id},
blocking=True,
)
def _light_entity_id(hass: HomeAssistant, entry: MockConfigEntry) -> str:
registry = er.async_get(hass)
entries = [
e
for e in er.async_entries_for_config_entry(registry, entry.entry_id)
if e.domain == LIGHT_DOMAIN
]
assert len(entries) == 1, "expected exactly one neopool light entity"
return entries[0].entity_id
async def test_light_turn_on_off_writes_via_relay_state(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""Light on/off delegates to the high-level async_set_relay_state API."""
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_set_relay_state = AsyncMock(
return_value={"Pool Light": True}
)
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_awaited_once_with(
RelayKind.LIGHT, True
)
assert hass.states.get(entity_id).state == STATE_ON
mock_neopool_client.async_set_relay_state = AsyncMock(
return_value={"Pool Light": False}
)
await _turn_off(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_awaited_once_with(
RelayKind.LIGHT, False
)
assert hass.states.get(entity_id).state == STATE_OFF
async def test_light_is_on_reflects_relay_state(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""is_on tracks the "Pool Light" relay state key from a fresh poll."""
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"relay_light_enable": TimerRelayMode.ALWAYS_ON,
"Pool Light": True,
}
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,
"relay_light_enable": TimerRelayMode.ALWAYS_OFF,
"Pool Light": False,
}
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
@pytest.mark.parametrize(
"relay_data",
[
pytest.param({"relay_light_enable": TimerRelayMode.ENABLED}, id="auto"),
pytest.param({}, id="missing"),
pytest.param({"relay_light_enable": 0}, id="disabled"),
pytest.param({"relay_light_enable": 2}, id="unknown-state"),
],
)
async def test_light_refuses_when_not_in_manual_mode(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
freezer: FrozenDateTimeFactory,
relay_data: dict[str, int],
) -> None:
"""Turn on/off is rejected while the relay is not in a manual mode."""
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_read_all.return_value = {**MOCK_POOL_DATA, **relay_data}
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_light_maps_lib_invalid_state_to_service_validation(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""Lib-raised NeoPoolInvalidStateError is remapped to ServiceValidationError.
Coordinator data may briefly lag the device state, so the pre-check passes
but the library refuses. The mapping surfaces a translated error to users
instead of leaking the raw library exception.
"""
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_set_relay_state = AsyncMock(
side_effect=NeoPoolInvalidStateError("relay in auto mode")
)
with pytest.raises(ServiceValidationError):
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_awaited_once_with(
RelayKind.LIGHT, True
)
@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_light_maps_communication_error_to_home_assistant_error(
hass: HomeAssistant,
mock_config_entry_light: 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_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_set_relay_state = AsyncMock(side_effect=write_error)
with pytest.raises(HomeAssistantError):
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_awaited_once_with(
RelayKind.LIGHT, True
)
@pytest.mark.usefixtures("mock_neopool_client")
async def test_light_absent_when_option_off(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""No light entity is created while the use_light option is off."""
await setup_integration(hass, mock_config_entry)
light_entries = [
e
for e in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
if e.domain == LIGHT_DOMAIN
]
assert light_entries == []
async def test_light_absent_when_gpio_unassigned(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""Light entity is not registered when the lighting GPIO is unassigned."""
mock_neopool_client.async_read_all.return_value = {
**MOCK_POOL_DATA,
"MBF_PAR_LIGHTING_GPIO": 0,
"relay_light_enable": TimerRelayMode.ALWAYS_OFF,
}
await setup_integration(hass, mock_config_entry_light)
light_entries = [
e
for e in er.async_entries_for_config_entry(
entity_registry, mock_config_entry_light.entry_id
)
if e.domain == LIGHT_DOMAIN
]
assert light_entries == []
@pytest.mark.usefixtures("mock_neopool_client")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry_light: MockConfigEntry,
) -> None:
"""Snapshot every light entity registered by the platform."""
with patch("homeassistant.components.neopool.PLATFORMS", [Platform.LIGHT]):
await setup_integration(hass, mock_config_entry_light)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry_light.entry_id
)
async def test_light_write_schedules_follow_up_refresh(
hass: HomeAssistant,
mock_config_entry_light: 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_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
mock_neopool_client.async_set_relay_state = AsyncMock(
return_value={"Pool Light": True}
)
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
async def test_light_timer_enable_gates_writes(
hass: HomeAssistant,
mock_config_entry_light: MockConfigEntry,
mock_neopool_client: MagicMock,
) -> None:
"""The relay-light timer enable read from read_all_timers gates writes.
The enable field only reaches the write-guard via the timer read, so an
auto mode here must make turn-on raise ServiceValidationError. A stale or
missing read would leave the seeded ALWAYS_OFF and let the write through.
"""
mock_neopool_client.read_all_timers = AsyncMock(
return_value={
"relay_light": {
"enable": TimerRelayMode.ENABLED,
"on": 3600,
"interval": 7200,
"period": 86400,
"countdown": 120,
"stop": 5400,
}
}
)
await setup_integration(hass, mock_config_entry_light)
entity_id = _light_entity_id(hass, mock_config_entry_light)
with pytest.raises(ServiceValidationError):
await _turn_on(hass, entity_id)
mock_neopool_client.async_set_relay_state.assert_not_called()