mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Add light platform and options flow to NeoPool (#176039)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
co-authored by
Joost Lekkerkerker
parent
9341ffdd63
commit
1f1b0a30d0
@@ -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,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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user