From 48bbb21a88b5fd5cbea6ec0ba30a00ec2b9541cf Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 24 Dec 2022 17:53:18 +0000 Subject: [PATCH 01/18] Rainbird config flow Convert rainbird to a config flow. Still need to handle irrigation numbers. --- homeassistant/components/rainbird/__init__.py | 133 +++++++++++++----- .../components/rainbird/binary_sensor.py | 48 ++++--- .../components/rainbird/config_flow.py | 108 ++++++++++++++ homeassistant/components/rainbird/const.py | 10 +- .../components/rainbird/coordinator.py | 6 +- .../components/rainbird/manifest.json | 1 + homeassistant/components/rainbird/sensor.py | 44 ++++-- .../components/rainbird/strings.json | 24 ++++ homeassistant/components/rainbird/switch.py | 117 ++++++++------- .../components/rainbird/translations/en.json | 24 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/rainbird/conftest.py | 42 +++++- .../components/rainbird/test_binary_sensor.py | 8 ++ tests/components/rainbird/test_config_flow.py | 103 ++++++++++++++ tests/components/rainbird/test_init.py | 75 ++++++++-- tests/components/rainbird/test_sensor.py | 8 ++ tests/components/rainbird/test_switch.py | 52 ++++--- 18 files changed, 645 insertions(+), 161 deletions(-) create mode 100644 homeassistant/components/rainbird/config_flow.py create mode 100644 homeassistant/components/rainbird/strings.json create mode 100644 homeassistant/components/rainbird/translations/en.json create mode 100644 tests/components/rainbird/test_config_flow.py diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 1e80cfb1cbcff2..9437f835d0b10e 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -4,6 +4,7 @@ import asyncio import logging +import async_timeout from pyrainbird.async_client import ( AsyncRainbirdClient, AsyncRainbirdController, @@ -11,6 +12,7 @@ ) import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_HOST, @@ -18,17 +20,24 @@ CONF_TRIGGER_TIME, Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import ( + ATTR_DURATION, CONF_ZONES, + DEVICE_INFO, + MANUFACTURER, RAINBIRD_CONTROLLER, SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR, + SERIAL_NUMBER, + TIMEOUT_SECONDS, ) from .coordinator import RainbirdUpdateCoordinator @@ -61,47 +70,101 @@ extra=vol.ALLOW_EXTRA, ) +SERVICE_SET_RAIN_DELAY = "set_rain_delay" +SERVICE_SCHEMA_RAIN_DELAY = vol.Schema( + { + vol.Required(ATTR_DURATION): cv.positive_float, + } +) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Rain Bird component.""" - return all( - await asyncio.gather( - *[ - _setup_controller(hass, controller_config, config) - for controller_config in config[DOMAIN] - ] + if DOMAIN not in config: + return True + + for controller_config in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=controller_config, + ) ) + + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.3.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", ) + return True -async def _setup_controller(hass, controller_config, config): - """Set up a controller.""" - server = controller_config[CONF_HOST] - password = controller_config[CONF_PASSWORD] - client = AsyncRainbirdClient(async_get_clientsession(hass), server, password) - controller = AsyncRainbirdController(client) - try: - await controller.get_serial_number() - except RainbirdApiException as exc: - _LOGGER.error("Unable to setup controller: %s", exc) - return False - rain_coordinator = RainbirdUpdateCoordinator(hass, controller.get_rain_sensor_state) - delay_coordinator = RainbirdUpdateCoordinator(hass, controller.get_rain_delay) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the config entry for Rain Bird.""" + hass.data.setdefault(DOMAIN, {}) - for platform in PLATFORMS: - hass.async_create_task( - discovery.async_load_platform( - hass, - platform, - DOMAIN, - { - RAINBIRD_CONTROLLER: controller, - SENSOR_TYPE_RAINSENSOR: rain_coordinator, - SENSOR_TYPE_RAINDELAY: delay_coordinator, - **controller_config, - }, - config, - ) + controller = AsyncRainbirdController( + AsyncRainbirdClient( + async_get_clientsession(hass), + entry.data[CONF_HOST], + entry.data[CONF_PASSWORD], ) + ) + + try: + async with async_timeout.timeout(TIMEOUT_SECONDS): + serial_number = await controller.get_serial_number() + except (RainbirdApiException, asyncio.TimeoutError) as err: + raise ConfigEntryNotReady(f"Error talking to controller: {str(err)}") from err + + device_info = DeviceInfo( + default_name=MANUFACTURER, + identifiers={(DOMAIN, serial_number)}, + manufacturer=MANUFACTURER, + ) + rain_coordinator = RainbirdUpdateCoordinator( + hass, "Rain", controller.get_rain_sensor_state + ) + delay_coordinator = RainbirdUpdateCoordinator( + hass, "Rain delay", controller.get_rain_delay + ) + + hass.data[DOMAIN][entry.entry_id] = { + SERIAL_NUMBER: serial_number, + DEVICE_INFO: device_info, + RAINBIRD_CONTROLLER: controller, + SENSOR_TYPE_RAINSENSOR: rain_coordinator, + SENSOR_TYPE_RAINDELAY: delay_coordinator, + } + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def set_rain_delay(service: ServiceCall) -> None: + await controller.set_rain_delay(service.data[ATTR_DURATION]) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_RAIN_DELAY, + set_rain_delay, + schema=SERVICE_SCHEMA_RAIN_DELAY, + ) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + if unload_ok and not hass.data[DOMAIN]: + hass.services.async_remove(DOMAIN, SERVICE_SET_RAIN_DELAY) + + return unload_ok diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 02ea8b21bb14ef..1b7f68e2e709f2 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -1,6 +1,7 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations +import asyncio import logging from typing import Union @@ -8,18 +9,27 @@ BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator -from .const import SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR +from .const import ( + DEVICE_INFO, + DOMAIN, + SENSOR_TYPE_RAINDELAY, + SENSOR_TYPE_RAINSENSOR, + SERIAL_NUMBER, +) from .coordinator import RainbirdUpdateCoordinator + _LOGGER = logging.getLogger(__name__) -BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( +SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key=SENSOR_TYPE_RAINSENSOR, name="Rainsensor", @@ -33,22 +43,24 @@ ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, ) -> None: - """Set up a Rain Bird sensor.""" - if discovery_info is None: - return - - async_add_entities( - [ - RainBirdSensor(discovery_info[description.key], description) - for description in BINARY_SENSOR_TYPES + """Set up entry for a Rain Bird binary_sensor.""" + data = hass.data[DOMAIN][config_entry.entry_id] + await asyncio.gather( + *[ + data[description.key].async_config_entry_first_refresh() + for description in SENSOR_TYPES ], - True, + ) + async_add_devices( + RainBirdSensor( + data[description.key], description, data[SERIAL_NUMBER], data[DEVICE_INFO] + ) + for description in SENSOR_TYPES ) @@ -61,10 +73,14 @@ def __init__( self, coordinator: RainbirdUpdateCoordinator[int | bool], description: BinarySensorEntityDescription, + serial_number: str, + device_info: DeviceInfo, ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description + self._attr_unique_id = f"{serial_number}-{description.key}" + self._attr_device_info = device_info @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py new file mode 100644 index 00000000000000..54d72f1346cfc6 --- /dev/null +++ b/homeassistant/components/rainbird/config_flow.py @@ -0,0 +1,108 @@ +"""Config flow for Rain Bird.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import async_timeout +from pyrainbird.async_client import ( + AsyncRainbirdClient, + AsyncRainbirdController, + RainbirdApiException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, TIMEOUT_SECONDS + +_LOGGER = logging.getLogger(__name__) + + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): selector.TextSelector(), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + } +) + + +class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rain Bird.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Configure the Rain Bird device.""" + error_code: str | None = None + _LOGGER.debug("async_step_user=%s", user_input) + if user_input: + try: + serial_number = await self._test_connection( + user_input[CONF_HOST], user_input[CONF_PASSWORD] + ) + except asyncio.TimeoutError as exc: + _LOGGER.error("Timeout connecting to Rain Bird controller: %s", exc) + error_code = "timeout_connect" + except RainbirdApiException as exc: + _LOGGER.error("Error connecting to Rain Bird controller: %s", exc) + error_code = "cannot_connect" + else: + return await self.async_finish(serial_number, user_input) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors={"base": error_code} if error_code else None, + ) + + async def _test_connection(self, host: str, password: str) -> str: + """Test the connection and return the device serial number. + + Raises a TimeoutError or RainbirdApiException on failure. + """ + controller = AsyncRainbirdController( + AsyncRainbirdClient( + async_get_clientsession(self.hass), + host, + password, + ) + ) + async with async_timeout.timeout(TIMEOUT_SECONDS): + return await controller.get_serial_number() + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == entry.data[CONF_HOST]: + return self.async_abort(reason="already_configured") + try: + serial_number = await self._test_connection( + config[CONF_HOST], config[CONF_PASSWORD] + ) + except asyncio.TimeoutError as exc: + _LOGGER.error("Timeout connecting to Rain Bird controller: %s", exc) + return self.async_abort(reason="timeout_connect") + except RainbirdApiException as exc: + _LOGGER.error("Error connecting to Rain Bird controller: %s", exc) + return self.async_abort(reason="cannot_connect") + return await self.async_finish(serial_number, config) + + async def async_finish( + self, serial_number: str, data: dict[str, Any] + ) -> FlowResult: + """Create the config entry.""" + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=data[CONF_HOST], + data=data, + ) diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index be06fdb8224917..b3106608530a95 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -2,9 +2,17 @@ DOMAIN = "rainbird" +DEFAULT_TRIGGER_TIME = 360 +DEVICE_INFO = "device_info" SENSOR_TYPE_RAINDELAY = "raindelay" SENSOR_TYPE_RAINSENSOR = "rainsensor" - +SERIAL_NUMBER = "serial_number" RAINBIRD_CONTROLLER = "controller" CONF_ZONES = "zones" + +ATTR_DURATION = "duration" + +TIMEOUT_SECONDS = 20 + +MANUFACTURER = "Rain Bird" diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index ee6857fe93c455..449448114c9204 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -13,7 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -TIMEOUT_SECONDS = 20 +from .const import TIMEOUT_SECONDS + UPDATE_INTERVAL = datetime.timedelta(minutes=1) _LOGGER = logging.getLogger(__name__) @@ -27,13 +28,14 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[_T]): def __init__( self, hass: HomeAssistant, + name: str, update_method: Callable[[], Awaitable[_T]], ) -> None: """Initialize ZoneStateUpdateCoordinator.""" super().__init__( hass, _LOGGER, - name="Rainbird Zones", + name=name, update_method=update_method, update_interval=UPDATE_INTERVAL, ) diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index a7366fac4b5e5d..3c248649b5a2f5 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -1,6 +1,7 @@ { "domain": "rainbird", "name": "Rain Bird", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainbird", "requirements": ["pyrainbird==0.7.1"], "codeowners": ["@konikvranik", "@allenporter"], diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index e1dd56d1fb39e7..ab20d90734d4ed 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -1,18 +1,29 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations +import asyncio import logging from typing import Union from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator from .const import SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR from .coordinator import RainbirdUpdateCoordinator +from .const import ( + DEVICE_INFO, + DOMAIN, + SENSOR_TYPE_RAINDELAY, + SENSOR_TYPE_RAINSENSOR, + SERIAL_NUMBER, +) + _LOGGER = logging.getLogger(__name__) @@ -30,23 +41,24 @@ ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, ) -> None: - """Set up a Rain Bird sensor.""" - - if discovery_info is None: - return - - async_add_entities( - [ - RainBirdSensor(discovery_info[description.key], description) + """Set up entry for a Rain Bird sensor.""" + data = hass.data[DOMAIN][config_entry.entry_id] + await asyncio.gather( + *[ + data[description.key].async_config_entry_first_refresh() for description in SENSOR_TYPES ], - True, + ) + async_add_devices( + RainBirdSensor( + data[description.key], description, data[SERIAL_NUMBER], data[DEVICE_INFO] + ) + for description in SENSOR_TYPES ) @@ -59,10 +71,14 @@ def __init__( self, coordinator: RainbirdUpdateCoordinator[int | bool], description: SensorEntityDescription, + serial_number: str, + device_info: DeviceInfo, ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description + self._attr_unique_id = f"{serial_number}-{description.key}" + self._attr_device_info = device_info @property def native_value(self) -> StateType: diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json new file mode 100644 index 00000000000000..be790064dd360c --- /dev/null +++ b/homeassistant/components/rainbird/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure Rain Bird", + "description": "Please enter the LNK WiFi module information for your Rain Bird device.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Rain Bird YAML configuration is being removed", + "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.3.\n\nYour configuration has been imported into the UI automatically. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 5a9edee2753935..42c878672ac599 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from pyrainbird import AvailableStations from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException @@ -9,23 +10,29 @@ import voluptuous as vol from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_TRIGGER_TIME -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_ZONES, DOMAIN, RAINBIRD_CONTROLLER +from .const import ( + ATTR_DURATION, + CONF_ZONES, + DEFAULT_TRIGGER_TIME, + DEVICE_INFO, + DOMAIN, + RAINBIRD_CONTROLLER, + SERIAL_NUMBER, +) from .coordinator import RainbirdUpdateCoordinator _LOGGER = logging.getLogger(__name__) -ATTR_DURATION = "duration" - SERVICE_START_IRRIGATION = "start_irrigation" -SERVICE_SET_RAIN_DELAY = "set_rain_delay" SERVICE_SCHEMA_IRRIGATION = vol.Schema( { @@ -41,18 +48,14 @@ ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, ) -> None: - """Set up Rain Bird switches over a Rain Bird controller.""" - - if discovery_info is None: - return - - controller: AsyncRainbirdController = discovery_info[RAINBIRD_CONTROLLER] + """Set up entry for a Rain Bird irrigation switches.""" + data = hass.data[DOMAIN][config_entry.entry_id] + controller: AsyncRainbirdController = data[RAINBIRD_CONTROLLER] try: available_stations: AvailableStations = ( await controller.get_available_stations() @@ -61,55 +64,43 @@ async def async_setup_platform( raise PlatformNotReady(f"Failed to get stations: {str(err)}") from err if not (available_stations and available_stations.stations): return - coordinator = RainbirdUpdateCoordinator(hass, controller.get_zone_states) - devices = [] - for zone in range(1, available_stations.stations.count + 1): - if available_stations.stations.active(zone): - zone_config = discovery_info.get(CONF_ZONES, {}).get(zone, {}) - time = zone_config.get(CONF_TRIGGER_TIME, discovery_info[CONF_TRIGGER_TIME]) - name = zone_config.get(CONF_FRIENDLY_NAME) - devices.append( - RainBirdSwitch( - coordinator, - controller, - zone, - time, - name if name else f"Sprinkler {zone}", - ) - ) - try: - await coordinator.async_config_entry_first_refresh() - except ConfigEntryNotReady as err: - raise PlatformNotReady(f"Failed to load zone state: {str(err)}") from err + coordinator = RainbirdUpdateCoordinator( + hass, "Zone States", controller.get_zone_states + ) + await coordinator.async_config_entry_first_refresh() - async_add_entities(devices) + config: dict[int | str, Any] = { + **config_entry.data, # type: ignore[list-item] + } - async def start_irrigation(service: ServiceCall) -> None: - entity_id = service.data[ATTR_ENTITY_ID] - duration = service.data[ATTR_DURATION] + devices = [] + for zone in range(1, available_stations.stations.count + 1): + if not available_stations.stations.active(zone): + continue + zone_config = config.get(CONF_ZONES, {}).get(zone, {}) + devices.append( + RainBirdSwitch( + coordinator, + controller, + zone, + zone_config.get( + CONF_TRIGGER_TIME, + config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), + ), + zone_config.get(CONF_FRIENDLY_NAME, f"Sprinkler {zone}"), + data[SERIAL_NUMBER], + data[DEVICE_INFO], + ) + ) - for device in devices: - if device.entity_id == entity_id: - await device.async_turn_on(duration=duration) + async_add_devices(devices) - hass.services.async_register( - DOMAIN, + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( SERVICE_START_IRRIGATION, - start_irrigation, - schema=SERVICE_SCHEMA_IRRIGATION, - ) - - async def set_rain_delay(service: ServiceCall) -> None: - duration = service.data[ATTR_DURATION] - - await controller.set_rain_delay(duration) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_RAIN_DELAY, - set_rain_delay, - schema=SERVICE_SCHEMA_RAIN_DELAY, + SERVICE_SCHEMA_IRRIGATION, + "async_turn_on", ) @@ -125,6 +116,8 @@ def __init__( zone: int, time: int, name: str, + serial_number: str, + device_info: DeviceInfo, ) -> None: """Initialize a Rain Bird Switch Device.""" super().__init__(coordinator) @@ -134,6 +127,8 @@ def __init__( self._state = None self._duration = time self._attributes = {ATTR_DURATION: self._duration, "zone": self._zone} + self._attr_unique_id = f"{serial_number}-{zone}" + self._attr_device_info = device_info @property def extra_state_attributes(self): diff --git a/homeassistant/components/rainbird/translations/en.json b/homeassistant/components/rainbird/translations/en.json new file mode 100644 index 00000000000000..abbdef4b24d76c --- /dev/null +++ b/homeassistant/components/rainbird/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "timeout_connect": "Timeout establishing connection" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password" + }, + "description": "Please enter the LNK WiFi module information for your Rain Bird device.", + "title": "Configure Rain Bird" + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.3.\n\nYour configuration has been imported into the UI automatically. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "title": "The Rain Bird YAML configuration is being removed" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 04aaab06fb9669..b01c8da41c0dea 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -332,6 +332,7 @@ "radarr", "radio_browser", "radiotherm", + "rainbird", "rainforest_eagle", "rainmachine", "rdw", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 056852f589fe74..90d07c77eb70f1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4325,7 +4325,7 @@ "rainbird": { "name": "Rain Bird", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "rainforest_eagle": { diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 660307f1c60208..3d9443740d8440 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Generator +from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -14,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse ComponentSetup = Callable[[], Awaitable[bool]] @@ -53,6 +55,16 @@ } } +CONFIG_ENTRY_DATA = { + "host": HOST, + "password": PASSWORD, +} + + +UNAVAILABLE_RESPONSE = AiohttpClientMockResponse( + "POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE +) + @pytest.fixture def platforms() -> list[Platform]: @@ -63,7 +75,35 @@ def platforms() -> list[Platform]: @pytest.fixture def yaml_config() -> dict[str, Any]: """Fixture for configuration.yaml.""" - return CONFIG + return {} + + +@pytest.fixture +async def config_entry_data() -> dict[str, Any]: + """Fixture for MockConfigEntry data.""" + return CONFIG_ENTRY_DATA + + +@pytest.fixture +async def config_entry( + config_entry_data: dict[str, Any] | None +) -> MockConfigEntry | None: + """Fixture for MockConfigEntry.""" + if config_entry_data is None: + return None + return MockConfigEntry( + domain=DOMAIN, + data=config_entry_data, + ) + + +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry | None +) -> None: + """Fixture to set up the config entry.""" + if config_entry: + config_entry.add_to_hass(hass) @pytest.fixture diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index 7ed6f2d1a29a52..35490a0bb9f225 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -49,6 +49,10 @@ async def test_rainsensor( rainsensor = hass.states.get("binary_sensor.rainsensor") assert rainsensor is not None assert rainsensor.state == expected_state + assert rainsensor.attributes == { + "friendly_name": "Rainsensor", + "icon": "mdi:water", + } @pytest.mark.parametrize( @@ -76,3 +80,7 @@ async def test_raindelay( raindelay = hass.states.get("binary_sensor.raindelay") assert raindelay is not None assert raindelay.state == expected_state + assert raindelay.attributes == { + "friendly_name": "Raindelay", + "icon": "mdi:water-off", + } diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py new file mode 100644 index 00000000000000..466f6a8fbc551f --- /dev/null +++ b/tests/components/rainbird/test_config_flow.py @@ -0,0 +1,103 @@ +"""Tests for the Rain Bird config flow.""" + +import asyncio +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import Mock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.rainbird import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .conftest import CONFIG_ENTRY_DATA, HOST, PASSWORD, URL + +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse + + +@pytest.fixture(autouse=True) +async def setup_config_entry() -> None: + """Fixture to disable config entry setup for exercising config flow.""" + return None + + +@pytest.fixture(autouse=True) +async def mock_setup() -> Generator[Mock, None, None]: + """Fixture for patching out integration setup.""" + + with patch( + "homeassistant.components.rainbird.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup + + +async def complete_flow(hass: HomeAssistant) -> FlowResult: + """Start the config flow and enter the host and password.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "user" + assert not result.get("errors") + assert "flow_id" in result + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: HOST, CONF_PASSWORD: PASSWORD}, + ) + + +async def test_controller_flow(hass: HomeAssistant, mock_setup: Mock) -> None: + """Test the controller is setup correctly.""" + + result = await complete_flow(hass) + assert result.get("type") == "create_entry" + assert result.get("title") == HOST + assert "result" in result + assert result["result"].data == CONFIG_ENTRY_DATA + + assert len(mock_setup.mock_calls) == 1 + + +async def test_controller_cannot_connect( + hass: HomeAssistant, + mock_setup: Mock, + responses: list[AiohttpClientMockResponse], + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test an error talking to the controller.""" + + # Controller response with a failure + responses.clear() + responses.append( + AiohttpClientMockResponse("POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE) + ) + + result = await complete_flow(hass) + assert result.get("type") == "form" + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + assert not mock_setup.mock_calls + + +async def test_controller_timeout( + hass: HomeAssistant, + mock_setup: Mock, +) -> None: + """Test an error talking to the controller.""" + + with patch( + "homeassistant.components.rainbird.config_flow.async_timeout.timeout", + side_effect=asyncio.TimeoutError, + ): + result = await complete_flow(hass) + assert result.get("type") == "form" + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "timeout_connect"} + + assert not mock_setup.mock_calls diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index acf6a92d4a53d4..76821610bdb6ce 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -1,15 +1,38 @@ """Tests for rainbird initialization.""" -from http import HTTPStatus -from homeassistant.core import HomeAssistant +import pytest -from .conftest import URL, ComponentSetup +from homeassistant.components.rainbird import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant -from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse +from .conftest import ( + CONFIG, + CONFIG_ENTRY_DATA, + SERIAL_RESPONSE, + UNAVAILABLE_RESPONSE, + ComponentSetup, + mock_response, +) -async def test_setup_success( +@pytest.mark.parametrize( + "yaml_config,config_entry_data,responses", + [ + ({}, CONFIG_ENTRY_DATA, [mock_response(SERIAL_RESPONSE)]), + ( + CONFIG, + None, + [ + mock_response(SERIAL_RESPONSE), # Issued during import + mock_response(SERIAL_RESPONSE), + ], + ), + ], + ids=["config_entry", "yaml"], +) +async def test_init_success( hass: HomeAssistant, setup_integration: ComponentSetup, ) -> None: @@ -17,18 +40,44 @@ async def test_setup_success( assert await setup_integration() + assert [entry.state for entry in hass.config_entries.async_entries(DOMAIN)] == [ + ConfigEntryState.LOADED + ] -async def test_setup_communication_failure( + +@pytest.mark.parametrize( + "yaml_config,config_entry_data,responses,config_entry_states", + [ + ({}, CONFIG_ENTRY_DATA, [UNAVAILABLE_RESPONSE], [ConfigEntryState.SETUP_RETRY]), + ( + CONFIG, + None, + [ + UNAVAILABLE_RESPONSE, # Failure when importing yaml + ], + [], + ), + ( + CONFIG, + None, + [ + mock_response(SERIAL_RESPONSE), # Import succeeds + UNAVAILABLE_RESPONSE, # Failure on integration setup + ], + [ConfigEntryState.SETUP_RETRY], + ), + ], + ids=["config_entry_failure", "yaml_import_failure", "yaml_init_failure"], +) +async def test_communication_failure( hass: HomeAssistant, setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], - aioclient_mock: AiohttpClientMocker, + config_entry_states: list[ConfigEntryState], ) -> None: """Test unable to talk to server on startup, which permanently fails setup.""" - responses.clear() - responses.append( - AiohttpClientMockResponse("POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE) - ) + assert await setup_integration() - assert not await setup_integration() + assert [ + entry.state for entry in hass.config_entries.async_entries(DOMAIN) + ] == config_entry_states diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index b80e014b236818..9cbbb2d66036a0 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -43,7 +43,15 @@ async def test_sensors( rainsensor = hass.states.get("sensor.rainsensor") assert rainsensor is not None assert rainsensor.state == expected_state + assert rainsensor.attributes == { + "friendly_name": "Rainsensor", + "icon": "mdi:water", + } raindelay = hass.states.get("sensor.raindelay") assert raindelay is not None assert raindelay.state == "16" + assert raindelay.attributes == { + "friendly_name": "Raindelay", + "icon": "mdi:water-off", + } diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index d6e89c585271c4..0d3481fa3c1ca9 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -16,6 +16,7 @@ EMPTY_STATIONS_RESPONSE, HOST, PASSWORD, + SERIAL_RESPONSE, URL, ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, @@ -64,10 +65,20 @@ async def test_zones( zone = hass.states.get("switch.sprinkler_1") assert zone is not None assert zone.state == "off" + assert zone.attributes == { + "friendly_name": "Sprinkler 1", + "duration": 360, + "zone": 1, + } zone = hass.states.get("switch.sprinkler_2") assert zone is not None assert zone.state == "off" + assert zone.attributes == { + "friendly_name": "Sprinkler 2", + "duration": 360, + "zone": 2, + } zone = hass.states.get("switch.sprinkler_3") assert zone is not None @@ -258,27 +269,30 @@ async def test_coordinator_unavailable( with caplog.at_level(logging.WARNING): assert await setup_integration() - assert "Failed to load zone state" in caplog.text + assert "Error while setting up rainbird platform for switch" in caplog.text @pytest.mark.parametrize( - "yaml_config", + "yaml_config,config_entry_data", [ - { - DOMAIN: { - "host": HOST, - "password": PASSWORD, - "trigger_time": 360, - "zones": { - 1: { - "friendly_name": "Garden Sprinkler", + ( + { + DOMAIN: { + "host": HOST, + "password": PASSWORD, + "trigger_time": 360, + "zones": { + 1: { + "friendly_name": "Garden Sprinkler", + }, + 2: { + "friendly_name": "Back Yard", + }, }, - 2: { - "friendly_name": "Back Yard", - }, - }, - } - }, + } + }, + None, + ) ], ) async def test_yaml_config( @@ -289,7 +303,11 @@ async def test_yaml_config( """Test switch platform with fake data that creates 7 zones with one enabled.""" responses.extend( - [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_5_ON_RESPONSE)] + [ + mock_response(SERIAL_RESPONSE), # Issued during import + mock_response(AVAILABLE_STATIONS_RESPONSE), + mock_response(ZONE_5_ON_RESPONSE), + ], ) assert await setup_integration() From ffd2b5f51cf3538fe299497fd5b7a8240d150902 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 5 Jan 2023 16:38:25 -0800 Subject: [PATCH 02/18] Add options for irrigation time and deprecate yaml --- .../components/rainbird/config_flow.py | 49 +++++++++++++++-- homeassistant/components/rainbird/const.py | 2 +- .../components/rainbird/strings.json | 12 ++++- homeassistant/components/rainbird/switch.py | 28 +++------- .../components/rainbird/translations/en.json | 12 ++++- tests/components/rainbird/conftest.py | 5 +- tests/components/rainbird/test_config_flow.py | 43 +++++++++++++-- tests/components/rainbird/test_switch.py | 52 ------------------- 8 files changed, 119 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 54d72f1346cfc6..bfbe3ab6479229 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -15,12 +15,14 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import selector +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, TIMEOUT_SECONDS +from .const import ATTR_DURATION, DOMAIN, TIMEOUT_SECONDS _LOGGER = logging.getLogger(__name__) @@ -38,6 +40,14 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Rain Bird.""" + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> RainBirdOptionsFlowHandler: + """Define the config flow to handle options.""" + return RainBirdOptionsFlowHandler(config_entry) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -97,7 +107,10 @@ async def async_step_import(self, config: dict[str, Any]) -> FlowResult: return await self.async_finish(serial_number, config) async def async_finish( - self, serial_number: str, data: dict[str, Any] + self, + serial_number: str, + data: dict[str, Any], + options: dict[str, Any] | None = None, ) -> FlowResult: """Create the config entry.""" await self.async_set_unique_id(serial_number) @@ -105,4 +118,34 @@ async def async_finish( return self.async_create_entry( title=data[CONF_HOST], data=data, + options=options, + ) + + +class RainBirdOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a RainBird options flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize RainBirdOptionsFlowHandler.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + ATTR_DURATION, + default=self.config_entry.options.get( + ATTR_DURATION, + ), + ): cv.positive_int, + } + ), ) diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index b3106608530a95..a2067e5eaafb6e 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -2,7 +2,7 @@ DOMAIN = "rainbird" -DEFAULT_TRIGGER_TIME = 360 +DEFAULT_TRIGGER_TIME_MINUTES = 6 DEVICE_INFO = "device_info" SENSOR_TYPE_RAINDELAY = "raindelay" SENSOR_TYPE_RAINSENSOR = "rainsensor" diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index be790064dd360c..d52d9f81478beb 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -15,10 +15,20 @@ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" } }, + "options": { + "step": { + "init": { + "title": "Configure Rain Bird", + "data": { + "duration": "Default irrigation time in minutes" + } + } + } + }, "issues": { "deprecated_yaml": { "title": "The Rain Bird YAML configuration is being removed", - "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.3.\n\nYour configuration has been imported into the UI automatically. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.3.\n\nYour configuration has been imported into the UI automatically, however default per-zone irrigation times are no longer supported. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 42c878672ac599..2b5c012e8c6233 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -from typing import Any from pyrainbird import AvailableStations from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException @@ -11,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_TRIGGER_TIME +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform @@ -21,8 +20,7 @@ from .const import ( ATTR_DURATION, - CONF_ZONES, - DEFAULT_TRIGGER_TIME, + DEFAULT_TRIGGER_TIME_MINUTES, DEVICE_INFO, DOMAIN, RAINBIRD_CONTROLLER, @@ -70,25 +68,16 @@ async def async_setup_entry( ) await coordinator.async_config_entry_first_refresh() - config: dict[int | str, Any] = { - **config_entry.data, # type: ignore[list-item] - } - devices = [] for zone in range(1, available_stations.stations.count + 1): if not available_stations.stations.active(zone): continue - zone_config = config.get(CONF_ZONES, {}).get(zone, {}) devices.append( RainBirdSwitch( coordinator, controller, zone, - zone_config.get( - CONF_TRIGGER_TIME, - config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), - ), - zone_config.get(CONF_FRIENDLY_NAME, f"Sprinkler {zone}"), + config_entry.options.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), data[SERIAL_NUMBER], data[DEVICE_INFO], ) @@ -114,8 +103,7 @@ def __init__( coordinator: RainbirdUpdateCoordinator[States], rainbird: AsyncRainbirdController, zone: int, - time: int, - name: str, + duration_minutes: int, serial_number: str, device_info: DeviceInfo, ) -> None: @@ -123,10 +111,10 @@ def __init__( super().__init__(coordinator) self._rainbird = rainbird self._zone = zone - self._name = name + self._name = f"Sprinkler {zone}" self._state = None - self._duration = time - self._attributes = {ATTR_DURATION: self._duration, "zone": self._zone} + self._duration_minutes = duration_minutes + self._attributes = {"zone": self._zone} self._attr_unique_id = f"{serial_number}-{zone}" self._attr_device_info = device_info @@ -144,7 +132,7 @@ async def async_turn_on(self, **kwargs): """Turn the switch on.""" await self._rainbird.irrigate_zone( int(self._zone), - int(kwargs[ATTR_DURATION] if ATTR_DURATION in kwargs else self._duration), + int(kwargs.get(ATTR_DURATION, self._duration_minutes)), ) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/rainbird/translations/en.json b/homeassistant/components/rainbird/translations/en.json index abbdef4b24d76c..f9b7c25733b20e 100644 --- a/homeassistant/components/rainbird/translations/en.json +++ b/homeassistant/components/rainbird/translations/en.json @@ -17,8 +17,18 @@ }, "issues": { "deprecated_yaml": { - "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.3.\n\nYour configuration has been imported into the UI automatically. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.3.\n\nYour configuration has been imported into the UI automatically, however default per-zone irrigation times are no longer supported. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", "title": "The Rain Bird YAML configuration is being removed" } + }, + "options": { + "step": { + "init": { + "data": { + "duration": "Default irrigation time" + }, + "title": "Configure Rain Bird" + } + } } } \ No newline at end of file diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 3d9443740d8440..35f1b0dd62d89b 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -51,7 +51,9 @@ DOMAIN: { "host": HOST, "password": PASSWORD, - "trigger_time": 360, + "trigger_time": { + "minutes": 6, + }, } } @@ -102,6 +104,7 @@ async def setup_config_entry( hass: HomeAssistant, config_entry: MockConfigEntry | None ) -> None: """Fixture to set up the config entry.""" + print("setup_config_entry") if config_entry: config_entry.add_to_hass(hass) diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index 466f6a8fbc551f..fd99b8889e5d1f 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -9,9 +9,11 @@ from homeassistant import config_entries from homeassistant.components.rainbird import DOMAIN +from homeassistant.components.rainbird.const import ATTR_DURATION +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import FlowResult, FlowResultType from .conftest import CONFIG_ENTRY_DATA, HOST, PASSWORD, URL @@ -19,7 +21,7 @@ @pytest.fixture(autouse=True) -async def setup_config_entry() -> None: +async def config_entry_data() -> None: """Fixture to disable config entry setup for exercising config flow.""" return None @@ -40,7 +42,7 @@ async def complete_flow(hass: HomeAssistant) -> FlowResult: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result.get("type") == "form" + assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" assert not result.get("errors") assert "flow_id" in result @@ -59,6 +61,7 @@ async def test_controller_flow(hass: HomeAssistant, mock_setup: Mock) -> None: assert result.get("title") == HOST assert "result" in result assert result["result"].data == CONFIG_ENTRY_DATA + assert result["result"].options == {} assert len(mock_setup.mock_calls) == 1 @@ -78,7 +81,7 @@ async def test_controller_cannot_connect( ) result = await complete_flow(hass) - assert result.get("type") == "form" + assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" assert result.get("errors") == {"base": "cannot_connect"} @@ -96,8 +99,38 @@ async def test_controller_timeout( side_effect=asyncio.TimeoutError, ): result = await complete_flow(hass) - assert result.get("type") == "form" + assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" assert result.get("errors") == {"base": "timeout_connect"} assert not mock_setup.mock_calls + + +async def test_options_flow(hass: HomeAssistant, mock_setup: Mock) -> None: + """Test config flow options.""" + + # Setup config flow + result = await complete_flow(hass) + assert result.get("type") == "create_entry" + assert result.get("title") == HOST + assert "result" in result + assert result["result"].data == CONFIG_ENTRY_DATA + assert result["result"].options == {} + + # Assert single config entry is loaded + config_entry = next(iter(hass.config_entries.async_entries(DOMAIN))) + assert config_entry.state == ConfigEntryState.LOADED + + # Initiate the options flow + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "init" + + # Change the default duration + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={ATTR_DURATION: 300} + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert config_entry.options == { + ATTR_DURATION: 300, + } diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 0d3481fa3c1ca9..5fdc21c43a3a17 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -14,9 +14,6 @@ ACK_ECHO, AVAILABLE_STATIONS_RESPONSE, EMPTY_STATIONS_RESPONSE, - HOST, - PASSWORD, - SERIAL_RESPONSE, URL, ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, @@ -67,7 +64,6 @@ async def test_zones( assert zone.state == "off" assert zone.attributes == { "friendly_name": "Sprinkler 1", - "duration": 360, "zone": 1, } @@ -76,7 +72,6 @@ async def test_zones( assert zone.state == "off" assert zone.attributes == { "friendly_name": "Sprinkler 2", - "duration": 360, "zone": 2, } @@ -270,50 +265,3 @@ async def test_coordinator_unavailable( assert await setup_integration() assert "Error while setting up rainbird platform for switch" in caplog.text - - -@pytest.mark.parametrize( - "yaml_config,config_entry_data", - [ - ( - { - DOMAIN: { - "host": HOST, - "password": PASSWORD, - "trigger_time": 360, - "zones": { - 1: { - "friendly_name": "Garden Sprinkler", - }, - 2: { - "friendly_name": "Back Yard", - }, - }, - } - }, - None, - ) - ], -) -async def test_yaml_config( - hass: HomeAssistant, - setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], -) -> None: - """Test switch platform with fake data that creates 7 zones with one enabled.""" - - responses.extend( - [ - mock_response(SERIAL_RESPONSE), # Issued during import - mock_response(AVAILABLE_STATIONS_RESPONSE), - mock_response(ZONE_5_ON_RESPONSE), - ], - ) - - assert await setup_integration() - - assert hass.states.get("switch.garden_sprinkler") - assert not hass.states.get("switch.sprinkler_1") - assert hass.states.get("switch.back_yard") - assert not hass.states.get("switch.sprinkler_2") - assert hass.states.get("switch.sprinkler_3") From e16b0c6270d5c32912d498ecfb4fc08345a226db Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 5 Jan 2023 16:48:09 -0800 Subject: [PATCH 03/18] Combine exception handling paths to get 100% test coverage --- .../components/rainbird/config_flow.py | 45 ++++++++++++------- tests/components/rainbird/test_init.py | 7 ++- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index bfbe3ab6479229..3613814f473379 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -37,6 +37,15 @@ ) +class ConfigFlowError(Exception): + """Error raised during a config flow.""" + + def __init__(self, message: str, error_code: str) -> None: + """Initialize ConfigFlowError.""" + super().__init__(message) + self.error_code = error_code + + class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Rain Bird.""" @@ -53,18 +62,14 @@ async def async_step_user( ) -> FlowResult: """Configure the Rain Bird device.""" error_code: str | None = None - _LOGGER.debug("async_step_user=%s", user_input) if user_input: try: serial_number = await self._test_connection( user_input[CONF_HOST], user_input[CONF_PASSWORD] ) - except asyncio.TimeoutError as exc: - _LOGGER.error("Timeout connecting to Rain Bird controller: %s", exc) - error_code = "timeout_connect" - except RainbirdApiException as exc: - _LOGGER.error("Error connecting to Rain Bird controller: %s", exc) - error_code = "cannot_connect" + except ConfigFlowError as err: + _LOGGER.error("Error during config flow: %s", err) + error_code = err.error_code else: return await self.async_finish(serial_number, user_input) @@ -77,7 +82,7 @@ async def async_step_user( async def _test_connection(self, host: str, password: str) -> str: """Test the connection and return the device serial number. - Raises a TimeoutError or RainbirdApiException on failure. + Raises a ConfigFlowError on failure. """ controller = AsyncRainbirdController( AsyncRainbirdClient( @@ -86,8 +91,19 @@ async def _test_connection(self, host: str, password: str) -> str: password, ) ) - async with async_timeout.timeout(TIMEOUT_SECONDS): - return await controller.get_serial_number() + try: + async with async_timeout.timeout(TIMEOUT_SECONDS): + return await controller.get_serial_number() + except asyncio.TimeoutError as err: + raise ConfigFlowError( + f"Timeout connecting to Rain Bird controller: {str(err)}", + "timeout_connect", + ) from err + except RainbirdApiException as err: + raise ConfigFlowError( + f"Error connecting to Rain Bird controller: {str(err)}", + "cannot_connect", + ) from err async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Import a config entry from configuration.yaml.""" @@ -98,12 +114,9 @@ async def async_step_import(self, config: dict[str, Any]) -> FlowResult: serial_number = await self._test_connection( config[CONF_HOST], config[CONF_PASSWORD] ) - except asyncio.TimeoutError as exc: - _LOGGER.error("Timeout connecting to Rain Bird controller: %s", exc) - return self.async_abort(reason="timeout_connect") - except RainbirdApiException as exc: - _LOGGER.error("Error connecting to Rain Bird controller: %s", exc) - return self.async_abort(reason="cannot_connect") + except ConfigFlowError as err: + _LOGGER.error("Error during config import: %s", err) + return self.async_abort(reason=err.error_code) return await self.async_finish(serial_number, config) async def async_finish( diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 76821610bdb6ce..b7fd70f26d02cb 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -29,8 +29,13 @@ mock_response(SERIAL_RESPONSE), ], ), + ( + CONFIG, + CONFIG_ENTRY_DATA, + [mock_response(SERIAL_RESPONSE), mock_response(SERIAL_RESPONSE)], + ), ], - ids=["config_entry", "yaml"], + ids=["config_entry", "yaml", "already_exists"], ) async def test_init_success( hass: HomeAssistant, From 49dcb7c71f030830794b6a8d14b7122ebed0fdbf Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 5 Jan 2023 16:49:38 -0800 Subject: [PATCH 04/18] Bump the rainird config deprecation release --- homeassistant/components/rainbird/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 9437f835d0b10e..1b2476d11670b0 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -96,7 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.3.0", + breaks_in_ha_version="2023.4.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", From 797cf9e2dd8eff21ccdb1434b30ba47bb301ce7b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 5 Jan 2023 20:10:30 -0800 Subject: [PATCH 05/18] Apply suggestions from code review Co-authored-by: Martin Hjelmare --- homeassistant/components/rainbird/__init__.py | 2 +- homeassistant/components/rainbird/binary_sensor.py | 2 +- homeassistant/components/rainbird/sensor.py | 2 +- homeassistant/components/rainbird/strings.json | 2 +- homeassistant/components/rainbird/switch.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 9437f835d0b10e..1b2476d11670b0 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -96,7 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.3.0", + breaks_in_ha_version="2023.4.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 1b7f68e2e709f2..2fef6935934dbe 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -46,7 +46,7 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird binary_sensor.""" data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index ab20d90734d4ed..84f05557c59129 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -44,7 +44,7 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird sensor.""" data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index d52d9f81478beb..74bd43f2c0bf85 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -28,7 +28,7 @@ "issues": { "deprecated_yaml": { "title": "The Rain Bird YAML configuration is being removed", - "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.3.\n\nYour configuration has been imported into the UI automatically, however default per-zone irrigation times are no longer supported. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.4.\n\nYour configuration has been imported into the UI automatically, however default per-zone irrigation times are no longer supported. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 2b5c012e8c6233..fa0c44c626f32a 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -49,7 +49,7 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation switches.""" data = hass.data[DOMAIN][config_entry.entry_id] From cf019cc2566e03ef4bb8bbc37308b2ca3bf17f03 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 5 Jan 2023 22:10:26 -0800 Subject: [PATCH 06/18] Remove unnecessary sensor/binary sensor and address some PR feedback --- homeassistant/components/rainbird/__init__.py | 74 +++++++++---------- .../components/rainbird/binary_sensor.py | 50 +++++-------- homeassistant/components/rainbird/const.py | 9 +-- .../components/rainbird/coordinator.py | 24 +++++- homeassistant/components/rainbird/sensor.py | 52 +++++-------- .../components/rainbird/services.yaml | 3 + homeassistant/components/rainbird/switch.py | 22 ++---- tests/components/rainbird/conftest.py | 2 +- .../components/rainbird/test_binary_sensor.py | 47 +----------- tests/components/rainbird/test_init.py | 74 ++++++++++++++++++- tests/components/rainbird/test_sensor.py | 24 +----- tests/components/rainbird/test_switch.py | 27 ------- 12 files changed, 182 insertions(+), 226 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 1b2476d11670b0..62f96e64bcd929 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -12,8 +12,9 @@ ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState from homeassistant.const import ( + ATTR_DEVICE_ID, CONF_FRIENDLY_NAME, CONF_HOST, CONF_PASSWORD, @@ -21,25 +22,15 @@ Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.service import async_extract_config_entry_ids from homeassistant.helpers.typing import ConfigType -from .const import ( - ATTR_DURATION, - CONF_ZONES, - DEVICE_INFO, - MANUFACTURER, - RAINBIRD_CONTROLLER, - SENSOR_TYPE_RAINDELAY, - SENSOR_TYPE_RAINSENSOR, - SERIAL_NUMBER, - TIMEOUT_SECONDS, -) -from .coordinator import RainbirdUpdateCoordinator +from .const import ATTR_DURATION, CONF_ZONES, TIMEOUT_SECONDS +from .coordinator import ConfigData PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR] @@ -71,10 +62,14 @@ ) SERVICE_SET_RAIN_DELAY = "set_rain_delay" -SERVICE_SCHEMA_RAIN_DELAY = vol.Schema( - { - vol.Required(ATTR_DURATION): cv.positive_float, - } +SERVICE_SCHEMA_RAIN_DELAY = vol.All( + vol.Schema( + { + **cv.TARGET_SERVICE_FIELDS, + vol.Required(ATTR_DURATION): cv.positive_float, + } + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID), ) @@ -123,30 +118,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (RainbirdApiException, asyncio.TimeoutError) as err: raise ConfigEntryNotReady(f"Error talking to controller: {str(err)}") from err - device_info = DeviceInfo( - default_name=MANUFACTURER, - identifiers={(DOMAIN, serial_number)}, - manufacturer=MANUFACTURER, - ) - rain_coordinator = RainbirdUpdateCoordinator( - hass, "Rain", controller.get_rain_sensor_state - ) - delay_coordinator = RainbirdUpdateCoordinator( - hass, "Rain delay", controller.get_rain_delay - ) - - hass.data[DOMAIN][entry.entry_id] = { - SERIAL_NUMBER: serial_number, - DEVICE_INFO: device_info, - RAINBIRD_CONTROLLER: controller, - SENSOR_TYPE_RAINSENSOR: rain_coordinator, - SENSOR_TYPE_RAINDELAY: delay_coordinator, - } + hass.data[DOMAIN][entry.entry_id] = ConfigData(serial_number, controller) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def set_rain_delay(service: ServiceCall) -> None: - await controller.set_rain_delay(service.data[ATTR_DURATION]) + async def set_rain_delay(call: ServiceCall) -> None: + """Service call to delay automatic irrigigation.""" + duration = call.data[ATTR_DURATION] + entry_ids = await async_extract_config_entry_ids(hass, call) + if not entry_ids: + raise HomeAssistantError("Device id did not match any devices") + await asyncio.gather( + *( + hass.data[DOMAIN][entry_id].controller.set_rain_delay(duration) + for entry_id in entry_ids + ) + ) hass.services.async_register( DOMAIN, @@ -164,7 +151,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - if unload_ok and not hass.data[DOMAIN]: + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: hass.services.async_remove(DOMAIN, SERVICE_SET_RAIN_DELAY) return unload_ok diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 2fef6935934dbe..9562da4c13dbbe 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -1,7 +1,6 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations -import asyncio import logging from typing import Union @@ -13,33 +12,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - DEVICE_INFO, - DOMAIN, - SENSOR_TYPE_RAINDELAY, - SENSOR_TYPE_RAINSENSOR, - SERIAL_NUMBER, -) +from .const import DOMAIN, SENSOR_TYPE_RAINSENSOR from .coordinator import RainbirdUpdateCoordinator - _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( - key=SENSOR_TYPE_RAINSENSOR, - name="Rainsensor", - icon="mdi:water", - ), - BinarySensorEntityDescription( - key=SENSOR_TYPE_RAINDELAY, - name="Raindelay", - icon="mdi:water-off", - ), +RAIN_SENSOR_ENTITY_DESCRIPTION = BinarySensorEntityDescription( + key=SENSOR_TYPE_RAINSENSOR, + name="Rainsensor", + icon="mdi:water", ) @@ -50,17 +34,19 @@ async def async_setup_entry( ) -> None: """Set up entry for a Rain Bird binary_sensor.""" data = hass.data[DOMAIN][config_entry.entry_id] - await asyncio.gather( - *[ - data[description.key].async_config_entry_first_refresh() - for description in SENSOR_TYPES - ], + coordinator = RainbirdUpdateCoordinator( + hass, "Rain", data.controller.get_rain_sensor_state ) - async_add_devices( - RainBirdSensor( - data[description.key], description, data[SERIAL_NUMBER], data[DEVICE_INFO] - ) - for description in SENSOR_TYPES + await coordinator.async_config_entry_first_refresh() + async_add_entities( + [ + RainBirdSensor( + coordinator, + RAIN_SENSOR_ENTITY_DESCRIPTION, + data.serial_number, + data.device_info, + ) + ] ) diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index a2067e5eaafb6e..a906414c1b9ad2 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -1,18 +1,13 @@ """Constants for rainbird.""" DOMAIN = "rainbird" - -DEFAULT_TRIGGER_TIME_MINUTES = 6 -DEVICE_INFO = "device_info" SENSOR_TYPE_RAINDELAY = "raindelay" SENSOR_TYPE_RAINSENSOR = "rainsensor" -SERIAL_NUMBER = "serial_number" -RAINBIRD_CONTROLLER = "controller" + +DEFAULT_TRIGGER_TIME_MINUTES = 6 CONF_ZONES = "zones" ATTR_DURATION = "duration" TIMEOUT_SECONDS = 20 - -MANUFACTURER = "Rain Bird" diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 449448114c9204..fa485e8aae1c49 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -3,25 +3,45 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from dataclasses import dataclass import datetime import logging from typing import TypeVar import async_timeout -from pyrainbird.async_client import RainbirdApiException +from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import TIMEOUT_SECONDS +from .const import DOMAIN, TIMEOUT_SECONDS UPDATE_INTERVAL = datetime.timedelta(minutes=1) +MANUFACTURER = "Rain Bird" _LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") +@dataclass +class ConfigData: + """Global data used by a config entry.""" + + serial_number: str + controller: AsyncRainbirdController + + @property + def device_info(self) -> DeviceInfo: + """Information about the device for this config.""" + return DeviceInfo( + default_name=MANUFACTURER, + identifiers={(DOMAIN, self.serial_number)}, + manufacturer=MANUFACTURER, + ) + + class RainbirdUpdateCoordinator(DataUpdateCoordinator[_T]): """Coordinator for rainbird API calls.""" diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 84f05557c59129..a6acf04d85f891 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -1,7 +1,6 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations -import asyncio import logging from typing import Union @@ -10,34 +9,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR +from .const import DOMAIN, SENSOR_TYPE_RAINDELAY from .coordinator import RainbirdUpdateCoordinator -from .const import ( - DEVICE_INFO, - DOMAIN, - SENSOR_TYPE_RAINDELAY, - SENSOR_TYPE_RAINSENSOR, - SERIAL_NUMBER, -) - _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key=SENSOR_TYPE_RAINSENSOR, - name="Rainsensor", - icon="mdi:water", - ), - SensorEntityDescription( - key=SENSOR_TYPE_RAINDELAY, - name="Raindelay", - icon="mdi:water-off", - ), +RAIN_DELAY_ENTITY_DESCRIPTION = SensorEntityDescription( + key=SENSOR_TYPE_RAINDELAY, + name="Raindelay", + icon="mdi:water-off", ) @@ -48,17 +32,19 @@ async def async_setup_entry( ) -> None: """Set up entry for a Rain Bird sensor.""" data = hass.data[DOMAIN][config_entry.entry_id] - await asyncio.gather( - *[ - data[description.key].async_config_entry_first_refresh() - for description in SENSOR_TYPES - ], + coordinator = RainbirdUpdateCoordinator( + hass, "Rain delay", data.controller.get_rain_delay ) - async_add_devices( - RainBirdSensor( - data[description.key], description, data[SERIAL_NUMBER], data[DEVICE_INFO] - ) - for description in SENSOR_TYPES + await coordinator.async_config_entry_first_refresh() + async_add_entities( + [ + RainBirdSensor( + coordinator, + RAIN_DELAY_ENTITY_DESCRIPTION, + data.serial_number, + data.device_info, + ) + ] ) diff --git a/homeassistant/components/rainbird/services.yaml b/homeassistant/components/rainbird/services.yaml index 3d5f55dba142d7..39a487b54cf047 100644 --- a/homeassistant/components/rainbird/services.yaml +++ b/homeassistant/components/rainbird/services.yaml @@ -22,6 +22,9 @@ start_irrigation: set_rain_delay: name: Set rain delay description: Set how long automatic irrigation is turned off. + target: + device: + integration: rainbird fields: duration: name: Duration diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index fa0c44c626f32a..0226b0ee86f2a4 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -18,14 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_DURATION, - DEFAULT_TRIGGER_TIME_MINUTES, - DEVICE_INFO, - DOMAIN, - RAINBIRD_CONTROLLER, - SERIAL_NUMBER, -) +from .const import ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES, DOMAIN from .coordinator import RainbirdUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -53,10 +46,9 @@ async def async_setup_entry( ) -> None: """Set up entry for a Rain Bird irrigation switches.""" data = hass.data[DOMAIN][config_entry.entry_id] - controller: AsyncRainbirdController = data[RAINBIRD_CONTROLLER] try: available_stations: AvailableStations = ( - await controller.get_available_stations() + await data.controller.get_available_stations() ) except RainbirdApiException as err: raise PlatformNotReady(f"Failed to get stations: {str(err)}") from err @@ -64,7 +56,7 @@ async def async_setup_entry( return coordinator = RainbirdUpdateCoordinator( - hass, "Zone States", controller.get_zone_states + hass, "Zone States", data.controller.get_zone_states ) await coordinator.async_config_entry_first_refresh() @@ -75,15 +67,15 @@ async def async_setup_entry( devices.append( RainBirdSwitch( coordinator, - controller, + data.controller, zone, config_entry.options.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), - data[SERIAL_NUMBER], - data[DEVICE_INFO], + data.serial_number, + data.device_info, ) ) - async_add_devices(devices) + async_add_entities(devices) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 35f1b0dd62d89b..791ef0ffdb7129 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -23,6 +23,7 @@ HOST = "example.com" URL = "http://example.com/stick" PASSWORD = "password" +SERIAL_NUMBER = 0x12635436566 # # Response payloads below come from pyrainbird test cases. @@ -104,7 +105,6 @@ async def setup_config_entry( hass: HomeAssistant, config_entry: MockConfigEntry | None ) -> None: """Fixture to set up the config entry.""" - print("setup_config_entry") if config_entry: config_entry.add_to_hass(hass) diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index 35490a0bb9f225..482ecc6d0c6e73 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -6,14 +6,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .conftest import ( - RAIN_DELAY, - RAIN_DELAY_OFF, - RAIN_SENSOR_OFF, - RAIN_SENSOR_ON, - ComponentSetup, - mock_response, -) +from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, ComponentSetup, mock_response from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -37,12 +30,7 @@ async def test_rainsensor( ) -> None: """Test rainsensor binary sensor.""" - responses.extend( - [ - mock_response(sensor_payload), - mock_response(RAIN_DELAY), - ] - ) + responses.append(mock_response(sensor_payload)) assert await setup_integration() @@ -53,34 +41,3 @@ async def test_rainsensor( "friendly_name": "Rainsensor", "icon": "mdi:water", } - - -@pytest.mark.parametrize( - "sensor_payload,expected_state", - [(RAIN_DELAY_OFF, "off"), (RAIN_DELAY, "on")], -) -async def test_raindelay( - hass: HomeAssistant, - setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], - sensor_payload: str, - expected_state: bool, -) -> None: - """Test raindelay binary sensor.""" - - responses.extend( - [ - mock_response(RAIN_SENSOR_OFF), - mock_response(sensor_payload), - ] - ) - - assert await setup_integration() - - raindelay = hass.states.get("binary_sensor.raindelay") - assert raindelay is not None - assert raindelay.state == expected_state - assert raindelay.attributes == { - "friendly_name": "Raindelay", - "icon": "mdi:water-off", - } diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index b7fd70f26d02cb..7f8757979db530 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -4,18 +4,26 @@ import pytest from homeassistant.components.rainbird import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from .conftest import ( + ACK_ECHO, CONFIG, CONFIG_ENTRY_DATA, + RAIN_DELAY, + SERIAL_NUMBER, SERIAL_RESPONSE, UNAVAILABLE_RESPONSE, ComponentSetup, mock_response, ) +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse + @pytest.mark.parametrize( "yaml_config,config_entry_data,responses", @@ -86,3 +94,67 @@ async def test_communication_failure( assert [ entry.state for entry in hass.config_entries.async_entries(DOMAIN) ] == config_entry_states + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_rain_delay_service( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + config_entry: ConfigEntry, +) -> None: + """Test calling the rain delay service.""" + + responses.append(mock_response(RAIN_DELAY)) + assert await setup_integration() + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, SERIAL_NUMBER)}) + assert device + assert device.name == "Rain Bird" + + aioclient_mock.mock_calls.clear() + responses.extend( + [ + mock_response(ACK_ECHO), + ] + ) + + await hass.services.async_call( + DOMAIN, + "set_rain_delay", + {ATTR_DEVICE_ID: device.id, "duration": 30}, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rain_delay_invalid_device( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + config_entry: ConfigEntry, +) -> None: + """Test calling the rain delay service.""" + + assert await setup_integration() + + aioclient_mock.mock_calls.clear() + responses.extend( + [ + mock_response(ACK_ECHO), + ] + ) + + with pytest.raises(HomeAssistantError, match="Device id did not match"): + await hass.services.async_call( + DOMAIN, + "set_rain_delay", + {ATTR_DEVICE_ID: "invalid-device-id", "duration": 30}, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 0 diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index 9cbbb2d66036a0..32fd57ee51f55b 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -6,13 +6,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .conftest import ( - RAIN_DELAY, - RAIN_SENSOR_OFF, - RAIN_SENSOR_ON, - ComponentSetup, - mock_response, -) +from .conftest import RAIN_DELAY, ComponentSetup, mock_response from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -23,31 +17,17 @@ def platforms() -> list[str]: return [Platform.SENSOR] -@pytest.mark.parametrize( - "sensor_payload,expected_state", - [(RAIN_SENSOR_OFF, "False"), (RAIN_SENSOR_ON, "True")], -) async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], - sensor_payload: str, - expected_state: bool, ) -> None: """Test sensor platform.""" - responses.extend([mock_response(sensor_payload), mock_response(RAIN_DELAY)]) + responses.append(mock_response(RAIN_DELAY)) assert await setup_integration() - rainsensor = hass.states.get("sensor.rainsensor") - assert rainsensor is not None - assert rainsensor.state == expected_state - assert rainsensor.attributes == { - "friendly_name": "Rainsensor", - "icon": "mdi:water", - } - raindelay = hass.states.get("sensor.raindelay") assert raindelay is not None assert raindelay.state == "16" diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 5fdc21c43a3a17..e35cf039ad8357 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -199,33 +199,6 @@ async def test_irrigation_service( assert len(aioclient_mock.mock_calls) == 2 -async def test_rain_delay_service( - hass: HomeAssistant, - setup_integration: ComponentSetup, - aioclient_mock: AiohttpClientMocker, - responses: list[AiohttpClientMockResponse], -) -> None: - """Test calling the rain delay service.""" - - responses.extend( - [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_3_ON_RESPONSE)] - ) - assert await setup_integration() - - aioclient_mock.mock_calls.clear() - responses.extend( - [ - mock_response(ACK_ECHO), - ] - ) - - await hass.services.async_call( - DOMAIN, "set_rain_delay", {"duration": 30}, blocking=True - ) - - assert len(aioclient_mock.mock_calls) == 1 - - async def test_platform_unavailable( hass: HomeAssistant, setup_integration: ComponentSetup, From 868a2de6e7b0e275c4add2b2d3bb5dec38b63829 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 08:03:33 -0800 Subject: [PATCH 07/18] Simplify configuration flow and options based on PR feedback --- .../components/rainbird/config_flow.py | 25 +++++++++++++------ tests/components/rainbird/test_config_flow.py | 8 +++--- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 3613814f473379..b3fe15f3d91827 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ATTR_DURATION, DOMAIN, TIMEOUT_SECONDS +from .const import ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES, DOMAIN, TIMEOUT_SECONDS _LOGGER = logging.getLogger(__name__) @@ -71,7 +71,11 @@ async def async_step_user( _LOGGER.error("Error during config flow: %s", err) error_code = err.error_code else: - return await self.async_finish(serial_number, user_input) + return await self.async_finish( + serial_number, + user_input, + {ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, + ) return self.async_show_form( step_id="user", @@ -107,9 +111,7 @@ async def _test_connection(self, host: str, password: str) -> str: async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Import a config entry from configuration.yaml.""" - for entry in self._async_current_entries(): - if entry.data[CONF_HOST] == entry.data[CONF_HOST]: - return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) try: serial_number = await self._test_connection( config[CONF_HOST], config[CONF_PASSWORD] @@ -117,13 +119,22 @@ async def async_step_import(self, config: dict[str, Any]) -> FlowResult: except ConfigFlowError as err: _LOGGER.error("Error during config import: %s", err) return self.async_abort(reason=err.error_code) - return await self.async_finish(serial_number, config) + return await self.async_finish( + serial_number, + data={ + CONF_HOST: config[CONF_HOST], + CONF_PASSWORD: config[CONF_PASSWORD], + }, + options={ + ATTR_DURATION: config.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), + }, + ) async def async_finish( self, serial_number: str, data: dict[str, Any], - options: dict[str, Any] | None = None, + options: dict[str, Any], ) -> FlowResult: """Create the config entry.""" await self.async_set_unique_id(serial_number) diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index fd99b8889e5d1f..8e5c89d76d335a 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -61,7 +61,7 @@ async def test_controller_flow(hass: HomeAssistant, mock_setup: Mock) -> None: assert result.get("title") == HOST assert "result" in result assert result["result"].data == CONFIG_ENTRY_DATA - assert result["result"].options == {} + assert result["result"].options == {ATTR_DURATION: 6} assert len(mock_setup.mock_calls) == 1 @@ -115,7 +115,7 @@ async def test_options_flow(hass: HomeAssistant, mock_setup: Mock) -> None: assert result.get("title") == HOST assert "result" in result assert result["result"].data == CONFIG_ENTRY_DATA - assert result["result"].options == {} + assert result["result"].options == {ATTR_DURATION: 6} # Assert single config entry is loaded config_entry = next(iter(hass.config_entries.async_entries(DOMAIN))) @@ -128,9 +128,9 @@ async def test_options_flow(hass: HomeAssistant, mock_setup: Mock) -> None: # Change the default duration result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={ATTR_DURATION: 300} + result["flow_id"], user_input={ATTR_DURATION: 5} ) assert result.get("type") == FlowResultType.CREATE_ENTRY assert config_entry.options == { - ATTR_DURATION: 300, + ATTR_DURATION: 5, } From 8c1aa0740d2e95002169a2e0166c077c9111efe3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 10:42:05 -0800 Subject: [PATCH 08/18] Consolidate data update coordinators to simplify overall integration --- homeassistant/components/rainbird/__init__.py | 29 +++-- .../components/rainbird/binary_sensor.py | 37 ++---- .../components/rainbird/config_flow.py | 17 ++- homeassistant/components/rainbird/const.py | 3 +- .../components/rainbird/coordinator.py | 85 ++++++++++---- homeassistant/components/rainbird/sensor.py | 29 ++--- homeassistant/components/rainbird/switch.py | 71 +++--------- tests/components/rainbird/conftest.py | 49 +++++++- .../components/rainbird/test_binary_sensor.py | 7 +- tests/components/rainbird/test_config_flow.py | 15 ++- tests/components/rainbird/test_init.py | 41 +++---- tests/components/rainbird/test_sensor.py | 14 +-- tests/components/rainbird/test_switch.py | 109 ++++++------------ 13 files changed, 248 insertions(+), 258 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 62f96e64bcd929..6dbad32c08355f 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -4,12 +4,7 @@ import asyncio import logging -import async_timeout -from pyrainbird.async_client import ( - AsyncRainbirdClient, - AsyncRainbirdController, - RainbirdApiException, -) +from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState @@ -22,15 +17,15 @@ Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import async_extract_config_entry_ids from homeassistant.helpers.typing import ConfigType -from .const import ATTR_DURATION, CONF_ZONES, TIMEOUT_SECONDS -from .coordinator import ConfigData +from .const import ATTR_DURATION, CONF_SERIAL_NUMBER, CONF_ZONES +from .coordinator import RainbirdUpdateCoordinator PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR] @@ -102,6 +97,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the config entry for Rain Bird.""" + hass.data.setdefault(DOMAIN, {}) controller = AsyncRainbirdController( @@ -111,14 +107,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], ) ) + coordinator = RainbirdUpdateCoordinator( + hass, + name=entry.title, + controller=controller, + serial_number=entry.data[CONF_SERIAL_NUMBER], + ) + await coordinator.async_config_entry_first_refresh() - try: - async with async_timeout.timeout(TIMEOUT_SECONDS): - serial_number = await controller.get_serial_number() - except (RainbirdApiException, asyncio.TimeoutError) as err: - raise ConfigEntryNotReady(f"Error talking to controller: {str(err)}") from err - - hass.data[DOMAIN][entry.entry_id] = ConfigData(serial_number, controller) + hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 9562da4c13dbbe..ee5be0e4617b92 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -from typing import Union from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -10,18 +9,17 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SENSOR_TYPE_RAINSENSOR +from .const import DOMAIN from .coordinator import RainbirdUpdateCoordinator _LOGGER = logging.getLogger(__name__) RAIN_SENSOR_ENTITY_DESCRIPTION = BinarySensorEntityDescription( - key=SENSOR_TYPE_RAINSENSOR, + key="rainsensor", name="Rainsensor", icon="mdi:water", ) @@ -33,42 +31,25 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird binary_sensor.""" - data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = RainbirdUpdateCoordinator( - hass, "Rain", data.controller.get_rain_sensor_state - ) - await coordinator.async_config_entry_first_refresh() - async_add_entities( - [ - RainBirdSensor( - coordinator, - RAIN_SENSOR_ENTITY_DESCRIPTION, - data.serial_number, - data.device_info, - ) - ] - ) + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([RainBirdSensor(coordinator, RAIN_SENSOR_ENTITY_DESCRIPTION)]) -class RainBirdSensor( - CoordinatorEntity[RainbirdUpdateCoordinator[Union[int, bool]]], BinarySensorEntity -): +class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], BinarySensorEntity): """A sensor implementation for Rain Bird device.""" def __init__( self, - coordinator: RainbirdUpdateCoordinator[int | bool], + coordinator: RainbirdUpdateCoordinator, description: BinarySensorEntityDescription, - serial_number: str, - device_info: DeviceInfo, ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{serial_number}-{description.key}" - self._attr_device_info = device_info + self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + self._attr_device_info = coordinator.device_info @property def is_on(self) -> bool | None: """Return True if entity is on.""" - return None if self.coordinator.data is None else bool(self.coordinator.data) + return self.coordinator.data.rain diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index b3fe15f3d91827..360b50d411d2b0 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -22,7 +22,13 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES, DOMAIN, TIMEOUT_SECONDS +from .const import ( + ATTR_DURATION, + CONF_SERIAL_NUMBER, + DEFAULT_TRIGGER_TIME_MINUTES, + DOMAIN, + TIMEOUT_SECONDS, +) _LOGGER = logging.getLogger(__name__) @@ -73,8 +79,12 @@ async def async_step_user( else: return await self.async_finish( serial_number, - user_input, - {ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_SERIAL_NUMBER: serial_number, + }, + options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, ) return self.async_show_form( @@ -124,6 +134,7 @@ async def async_step_import(self, config: dict[str, Any]) -> FlowResult: data={ CONF_HOST: config[CONF_HOST], CONF_PASSWORD: config[CONF_PASSWORD], + CONF_SERIAL_NUMBER: serial_number, }, options={ ATTR_DURATION: config.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index a906414c1b9ad2..584f9c543da340 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -1,12 +1,11 @@ """Constants for rainbird.""" DOMAIN = "rainbird" -SENSOR_TYPE_RAINDELAY = "raindelay" -SENSOR_TYPE_RAINSENSOR = "rainsensor" DEFAULT_TRIGGER_TIME_MINUTES = 6 CONF_ZONES = "zones" +CONF_SERIAL_NUMBER = "serial_number" ATTR_DURATION = "duration" diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index fa485e8aae1c49..8173f7eed22fe5 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable from dataclasses import dataclass import datetime import logging @@ -26,44 +25,88 @@ @dataclass -class ConfigData: - """Global data used by a config entry.""" +class RainbirdDeviceState: + """Data retrieved from a Rain Bird device.""" - serial_number: str - controller: AsyncRainbirdController - - @property - def device_info(self) -> DeviceInfo: - """Information about the device for this config.""" - return DeviceInfo( - default_name=MANUFACTURER, - identifiers={(DOMAIN, self.serial_number)}, - manufacturer=MANUFACTURER, - ) + zones: set[int] + active_zones: set[int] + rain: bool + rain_delay: int -class RainbirdUpdateCoordinator(DataUpdateCoordinator[_T]): +class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): """Coordinator for rainbird API calls.""" def __init__( self, hass: HomeAssistant, name: str, - update_method: Callable[[], Awaitable[_T]], + controller: AsyncRainbirdController, + serial_number: str, ) -> None: """Initialize ZoneStateUpdateCoordinator.""" super().__init__( hass, _LOGGER, name=name, - update_method=update_method, + update_method=self._async_update_data, update_interval=UPDATE_INTERVAL, ) + self._controller = controller + self._serial_number = serial_number + self._zones: set[int] | None = None + + @property + def controller(self) -> AsyncRainbirdController: + """Return the API client for the device.""" + return self._controller + + @property + def serial_number(self) -> str: + """Return the device serial number.""" + return self._serial_number + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return DeviceInfo( + default_name=MANUFACTURER, + identifiers={(DOMAIN, self._serial_number)}, + manufacturer=MANUFACTURER, + ) - async def _async_update_data(self) -> _T: - """Fetch data from API endpoint.""" + async def _async_update_data(self) -> RainbirdDeviceState: + """Fetch data from Rain Bird device.""" try: async with async_timeout.timeout(TIMEOUT_SECONDS): - return await self.update_method() # type: ignore[misc] + return await self._fetch_data() except RainbirdApiException as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err + raise UpdateFailed(f"Error communicating with Device: {err}") from err + + async def _fetch_data(self) -> RainbirdDeviceState: + """Fetch data from the Rain Bird device. + + The data is fetched serially to avoid overwheling the device. + TODO: Do additional testing with this in parallel to see how it holds up. + """ + zones = await self._fetch_zones() + states = await self._controller.get_zone_states() + rain = await self._controller.get_rain_sensor_state() + rain_delay = await self._controller.get_rain_delay() + return RainbirdDeviceState( + zones=set(zones), + active_zones={zone for zone in zones if states.active(zone)}, + rain=rain, + rain_delay=rain_delay, + ) + + async def _fetch_zones(self) -> set[int]: + """Fetch the zones from the device, caching the results.""" + if self._zones is None: + available_stations = await self._controller.get_available_stations() + self._zones = { + zone + for zone in range(1, available_stations.stations.count + 1) + if available_stations.stations.active(zone) + } + return self._zones diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index a6acf04d85f891..de74943baf9c4c 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -2,24 +2,22 @@ from __future__ import annotations import logging -from typing import Union from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SENSOR_TYPE_RAINDELAY +from .const import DOMAIN from .coordinator import RainbirdUpdateCoordinator _LOGGER = logging.getLogger(__name__) RAIN_DELAY_ENTITY_DESCRIPTION = SensorEntityDescription( - key=SENSOR_TYPE_RAINDELAY, + key="raindelay", name="Raindelay", icon="mdi:water-off", ) @@ -31,42 +29,31 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird sensor.""" - data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = RainbirdUpdateCoordinator( - hass, "Rain delay", data.controller.get_rain_delay - ) - await coordinator.async_config_entry_first_refresh() async_add_entities( [ RainBirdSensor( - coordinator, + hass.data[DOMAIN][config_entry.entry_id], RAIN_DELAY_ENTITY_DESCRIPTION, - data.serial_number, - data.device_info, ) ] ) -class RainBirdSensor( - CoordinatorEntity[RainbirdUpdateCoordinator[Union[int, bool]]], SensorEntity -): +class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], SensorEntity): """A sensor implementation for Rain Bird device.""" def __init__( self, - coordinator: RainbirdUpdateCoordinator[int | bool], + coordinator: RainbirdUpdateCoordinator, description: SensorEntityDescription, - serial_number: str, - device_info: DeviceInfo, ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{serial_number}-{description.key}" - self._attr_device_info = device_info + self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + self._attr_device_info = coordinator.device_info @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.coordinator.data + return self.coordinator.data.rain_delay diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 0226b0ee86f2a4..73a3c18fe58ca7 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -3,18 +3,13 @@ import logging -from pyrainbird import AvailableStations -from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException -from pyrainbird.data import States import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -45,37 +40,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation switches.""" - data = hass.data[DOMAIN][config_entry.entry_id] - try: - available_stations: AvailableStations = ( - await data.controller.get_available_stations() + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + RainBirdSwitch( + coordinator, + zone, + config_entry.options.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), ) - except RainbirdApiException as err: - raise PlatformNotReady(f"Failed to get stations: {str(err)}") from err - if not (available_stations and available_stations.stations): - return - - coordinator = RainbirdUpdateCoordinator( - hass, "Zone States", data.controller.get_zone_states + for zone in coordinator.data.zones ) - await coordinator.async_config_entry_first_refresh() - - devices = [] - for zone in range(1, available_stations.stations.count + 1): - if not available_stations.stations.active(zone): - continue - devices.append( - RainBirdSwitch( - coordinator, - data.controller, - zone, - config_entry.options.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), - data.serial_number, - data.device_info, - ) - ) - - async_add_entities(devices) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -85,44 +58,32 @@ async def async_setup_entry( ) -class RainBirdSwitch( - CoordinatorEntity[RainbirdUpdateCoordinator[States]], SwitchEntity -): +class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity): """Representation of a Rain Bird switch.""" def __init__( self, - coordinator: RainbirdUpdateCoordinator[States], - rainbird: AsyncRainbirdController, + coordinator: RainbirdUpdateCoordinator, zone: int, duration_minutes: int, - serial_number: str, - device_info: DeviceInfo, ) -> None: """Initialize a Rain Bird Switch Device.""" super().__init__(coordinator) - self._rainbird = rainbird self._zone = zone - self._name = f"Sprinkler {zone}" + self._attr_name = f"Sprinkler {zone}" self._state = None self._duration_minutes = duration_minutes - self._attributes = {"zone": self._zone} - self._attr_unique_id = f"{serial_number}-{zone}" - self._attr_device_info = device_info + self._attr_unique_id = f"{coordinator.serial_number}-{zone}" + self._attr_device_info = coordinator.device_info @property def extra_state_attributes(self): """Return state attributes.""" - return self._attributes - - @property - def name(self): - """Get the name of the switch.""" - return self._name + return {"zone": self._zone} async def async_turn_on(self, **kwargs): """Turn the switch on.""" - await self._rainbird.irrigate_zone( + await self.coordinator.controller.irrigate_zone( int(self._zone), int(kwargs.get(ATTR_DURATION, self._duration_minutes)), ) @@ -130,10 +91,10 @@ async def async_turn_on(self, **kwargs): async def async_turn_off(self, **kwargs): """Turn the switch off.""" - await self._rainbird.stop_irrigation() + await self.coordinator.controller.stop_irrigation() await self.coordinator.async_request_refresh() @property def is_on(self): """Return true if switch is on.""" - return self.coordinator.data.active(self._zone) + return self._zone in self.coordinator.data.active_zones diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 791ef0ffdb7129..426f7c3dcdb63a 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -48,6 +48,7 @@ # ACK command 0x10, Echo 0x06 ACK_ECHO = "0106" + CONFIG = { DOMAIN: { "host": HOST, @@ -61,6 +62,7 @@ CONFIG_ENTRY_DATA = { "host": HOST, "password": PASSWORD, + "serial_number": SERIAL_NUMBER, } @@ -95,16 +97,17 @@ async def config_entry( if config_entry_data is None: return None return MockConfigEntry( + unique_id=SERIAL_NUMBER, domain=DOMAIN, data=config_entry_data, ) @pytest.fixture(autouse=True) -async def setup_config_entry( +async def add_config_entry( hass: HomeAssistant, config_entry: MockConfigEntry | None ) -> None: - """Fixture to set up the config entry.""" + """Fixture to add the config entry.""" if config_entry: config_entry.add_to_hass(hass) @@ -140,10 +143,48 @@ def mock_response(data: str) -> AiohttpClientMockResponse: return AiohttpClientMockResponse("POST", URL, response=rainbird_response(data)) +@pytest.fixture(name="stations_response") +def mock_station_response() -> str: + """Mock response to return available stations.""" + return AVAILABLE_STATIONS_RESPONSE + + +@pytest.fixture(name="zone_state_response") +def mock_zone_state_response() -> str: + """Mock response to return zone states.""" + return ZONE_STATE_OFF_RESPONSE + + +@pytest.fixture(name="rain_response") +def mock_rain_response() -> str: + """Mock response to return rain sensor state.""" + return RAIN_SENSOR_OFF + + +@pytest.fixture(name="rain_delay_response") +def mock_rain_delay_response() -> str: + """Mock response to return rain delay state.""" + return RAIN_DELAY_OFF + + +@pytest.fixture(name="api_responses") +def mock_api_responses( + stations_response: str, + zone_state_response: str, + rain_response: str, + rain_delay_response: str, +) -> list[str]: + """Fixture to set up a list of fake API responsees for tests to extend. + + These are returned in the order they are requested by the update coordinator. + """ + return [stations_response, zone_state_response, rain_response, rain_delay_response] + + @pytest.fixture(name="responses") -def mock_responses() -> list[AiohttpClientMockResponse]: +def mock_responses(api_responses: list[str]) -> list[AiohttpClientMockResponse]: """Fixture to set up a list of fake API responsees for tests to extend.""" - return [mock_response(SERIAL_RESPONSE)] + return [mock_response(api_response) for api_response in api_responses] @pytest.fixture(autouse=True) diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index 482ecc6d0c6e73..2cb49de49e147a 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, ComponentSetup, mock_response +from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, ComponentSetup from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -18,20 +18,17 @@ def platforms() -> list[Platform]: @pytest.mark.parametrize( - "sensor_payload,expected_state", + "rain_response,expected_state", [(RAIN_SENSOR_OFF, "off"), (RAIN_SENSOR_ON, "on")], ) async def test_rainsensor( hass: HomeAssistant, setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], - sensor_payload: str, expected_state: bool, ) -> None: """Test rainsensor binary sensor.""" - responses.append(mock_response(sensor_payload)) - assert await setup_integration() rainsensor = hass.states.get("binary_sensor.rainsensor") diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index 8e5c89d76d335a..31650a0828a157 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -15,11 +15,24 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType -from .conftest import CONFIG_ENTRY_DATA, HOST, PASSWORD, URL +from .conftest import ( + CONFIG_ENTRY_DATA, + HOST, + PASSWORD, + SERIAL_RESPONSE, + URL, + mock_response, +) from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse +@pytest.fixture(name="responses") +def mock_responses() -> list[AiohttpClientMockResponse]: + """Set up fake serial number response when testing the connection.""" + return [mock_response(SERIAL_RESPONSE)] + + @pytest.fixture(autouse=True) async def config_entry_data() -> None: """Fixture to disable config entry setup for exercising config flow.""" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 7f8757979db530..b748eebfbe370e 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -14,7 +14,6 @@ ACK_ECHO, CONFIG, CONFIG_ENTRY_DATA, - RAIN_DELAY, SERIAL_NUMBER, SERIAL_RESPONSE, UNAVAILABLE_RESPONSE, @@ -26,21 +25,18 @@ @pytest.mark.parametrize( - "yaml_config,config_entry_data,responses", + "yaml_config,config_entry_data,initial_response", [ - ({}, CONFIG_ENTRY_DATA, [mock_response(SERIAL_RESPONSE)]), + ({}, CONFIG_ENTRY_DATA, None), ( CONFIG, None, - [ - mock_response(SERIAL_RESPONSE), # Issued during import - mock_response(SERIAL_RESPONSE), - ], + mock_response(SERIAL_RESPONSE), # Extra import request ), ( CONFIG, CONFIG_ENTRY_DATA, - [mock_response(SERIAL_RESPONSE), mock_response(SERIAL_RESPONSE)], + None, ), ], ids=["config_entry", "yaml", "already_exists"], @@ -48,14 +44,22 @@ async def test_init_success( hass: HomeAssistant, setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], + initial_response: AiohttpClientMockResponse | None, ) -> None: """Test successful setup and unload.""" + if initial_response: + responses.insert(0, initial_response) assert await setup_integration() - assert [entry.state for entry in hass.config_entries.async_entries(DOMAIN)] == [ - ConfigEntryState.LOADED - ] + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + assert entries[0].state is ConfigEntryState.NOT_LOADED @pytest.mark.parametrize( @@ -101,12 +105,11 @@ async def test_rain_delay_service( hass: HomeAssistant, setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, - responses: list[AiohttpClientMockResponse], + responses: list[str], config_entry: ConfigEntry, ) -> None: """Test calling the rain delay service.""" - responses.append(mock_response(RAIN_DELAY)) assert await setup_integration() device_registry = dr.async_get(hass) @@ -115,11 +118,7 @@ async def test_rain_delay_service( assert device.name == "Rain Bird" aioclient_mock.mock_calls.clear() - responses.extend( - [ - mock_response(ACK_ECHO), - ] - ) + responses.append(mock_response(ACK_ECHO)) await hass.services.async_call( DOMAIN, @@ -135,7 +134,6 @@ async def test_rain_delay_invalid_device( hass: HomeAssistant, setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, - responses: list[AiohttpClientMockResponse], config_entry: ConfigEntry, ) -> None: """Test calling the rain delay service.""" @@ -143,11 +141,6 @@ async def test_rain_delay_invalid_device( assert await setup_integration() aioclient_mock.mock_calls.clear() - responses.extend( - [ - mock_response(ACK_ECHO), - ] - ) with pytest.raises(HomeAssistantError, match="Device id did not match"): await hass.services.async_call( diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index 32fd57ee51f55b..694c7245b380ac 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -6,9 +6,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .conftest import RAIN_DELAY, ComponentSetup, mock_response - -from tests.test_util.aiohttp import AiohttpClientMockResponse +from .conftest import RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup @pytest.fixture @@ -17,20 +15,22 @@ def platforms() -> list[str]: return [Platform.SENSOR] +@pytest.mark.parametrize( + "rain_delay_response,expected_state", + [(RAIN_DELAY, "16"), (RAIN_DELAY_OFF, "0")], +) async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], + expected_state: str, ) -> None: """Test sensor platform.""" - responses.append(mock_response(RAIN_DELAY)) - assert await setup_integration() raindelay = hass.states.get("sensor.raindelay") assert raindelay is not None - assert raindelay.state == "16" + assert raindelay.state == expected_state assert raindelay.attributes == { "friendly_name": "Raindelay", "icon": "mdi:water-off", diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index e35cf039ad8357..5f5ca99a04bfbc 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -1,9 +1,6 @@ """Tests for rainbird sensor platform.""" -from http import HTTPStatus -import logging - import pytest from homeassistant.components.rainbird import DOMAIN @@ -12,9 +9,9 @@ from .conftest import ( ACK_ECHO, - AVAILABLE_STATIONS_RESPONSE, EMPTY_STATIONS_RESPONSE, - URL, + RAIN_DELAY_OFF, + RAIN_SENSOR_OFF, ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, ZONE_OFF_RESPONSE, @@ -32,20 +29,26 @@ def platforms() -> list[str]: return [Platform.SWITCH] +@pytest.mark.parametrize( + "stations_response", + [EMPTY_STATIONS_RESPONSE], +) async def test_no_zones( hass: HomeAssistant, setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], ) -> None: """Test case where listing stations returns no stations.""" - responses.append(mock_response(EMPTY_STATIONS_RESPONSE)) assert await setup_integration() zone = hass.states.get("switch.sprinkler_1") assert zone is None +@pytest.mark.parametrize( + "zone_state_response", + [ZONE_5_ON_RESPONSE], +) async def test_zones( hass: HomeAssistant, setup_integration: ComponentSetup, @@ -53,10 +56,6 @@ async def test_zones( ) -> None: """Test switch platform with fake data that creates 7 zones with one enabled.""" - responses.extend( - [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_5_ON_RESPONSE)] - ) - assert await setup_integration() zone = hass.states.get("switch.sprinkler_1") @@ -106,9 +105,6 @@ async def test_switch_on( ) -> None: """Test turning on irrigation switch.""" - responses.extend( - [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_OFF_RESPONSE)] - ) assert await setup_integration() # Initially all zones are off. Pick zone3 as an arbitrary to assert @@ -121,13 +117,14 @@ async def test_switch_on( responses.extend( [ mock_response(ACK_ECHO), # Switch on response - mock_response(ZONE_3_ON_RESPONSE), # Updated zone state + # API responses when state is refreshed + mock_response(ZONE_3_ON_RESPONSE), + mock_response(RAIN_SENSOR_OFF), + mock_response(RAIN_DELAY_OFF), ] ) await switch_common.async_turn_on(hass, "switch.sprinkler_3") await hass.async_block_till_done() - assert len(aioclient_mock.mock_calls) == 2 - aioclient_mock.mock_calls.clear() # Verify switch state is updated zone = hass.states.get("switch.sprinkler_3") @@ -135,6 +132,10 @@ async def test_switch_on( assert zone.state == "on" +@pytest.mark.parametrize( + "zone_state_response", + [ZONE_3_ON_RESPONSE], +) async def test_switch_off( hass: HomeAssistant, setup_integration: ComponentSetup, @@ -143,9 +144,6 @@ async def test_switch_off( ) -> None: """Test turning off irrigation switch.""" - responses.extend( - [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_3_ON_RESPONSE)] - ) assert await setup_integration() # Initially the test zone is on @@ -158,14 +156,13 @@ async def test_switch_off( [ mock_response(ACK_ECHO), # Switch off response mock_response(ZONE_OFF_RESPONSE), # Updated zone state + mock_response(RAIN_SENSOR_OFF), + mock_response(RAIN_DELAY_OFF), ] ) await switch_common.async_turn_off(hass, "switch.sprinkler_3") await hass.async_block_till_done() - # One call to change the service and one to refresh state - assert len(aioclient_mock.mock_calls) == 2 - # Verify switch state is updated zone = hass.states.get("switch.sprinkler_3") assert zone is not None @@ -177,64 +174,34 @@ async def test_irrigation_service( setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], + api_responses: list[str], ) -> None: """Test calling the irrigation service.""" - responses.extend( - [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_3_ON_RESPONSE)] - ) assert await setup_integration() + zone = hass.states.get("switch.sprinkler_3") + assert zone is not None + assert zone.state == "off" + aioclient_mock.mock_calls.clear() - responses.extend([mock_response(ACK_ECHO), mock_response(ZONE_OFF_RESPONSE)]) + responses.extend( + [ + mock_response(ACK_ECHO), + # API responses when state is refreshed + mock_response(ZONE_3_ON_RESPONSE), + mock_response(RAIN_SENSOR_OFF), + mock_response(RAIN_DELAY_OFF), + ] + ) await hass.services.async_call( DOMAIN, "start_irrigation", - {ATTR_ENTITY_ID: "switch.sprinkler_5", "duration": 30}, + {ATTR_ENTITY_ID: "switch.sprinkler_3", "duration": 30}, blocking=True, ) - # One call to change the service and one to refresh state - assert len(aioclient_mock.mock_calls) == 2 - - -async def test_platform_unavailable( - hass: HomeAssistant, - setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], - caplog: pytest.LogCaptureFixture, -) -> None: - """Test failure while listing the stations when setting up the platform.""" - - responses.append( - AiohttpClientMockResponse("POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE) - ) - - with caplog.at_level(logging.WARNING): - assert await setup_integration() - - assert "Failed to get stations" in caplog.text - - -async def test_coordinator_unavailable( - hass: HomeAssistant, - setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], - caplog: pytest.LogCaptureFixture, -) -> None: - """Test failure to refresh the update coordinator.""" - - responses.extend( - [ - mock_response(AVAILABLE_STATIONS_RESPONSE), - AiohttpClientMockResponse( - "POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE - ), - ], - ) - - with caplog.at_level(logging.WARNING): - assert await setup_integration() - - assert "Error while setting up rainbird platform for switch" in caplog.text + zone = hass.states.get("switch.sprinkler_3") + assert zone is not None + assert zone.state == "on" From 1e019a6e8182f75c5159b018f4f11cbe330e7ed6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 10:54:24 -0800 Subject: [PATCH 09/18] Fix type error on python3.9 --- tests/components/rainbird/test_init.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index b748eebfbe370e..8cb5aff95ec3d5 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -1,5 +1,6 @@ """Tests for rainbird initialization.""" +from __future__ import annotations import pytest From f82a5331f9b372257f4dd752197c9ab21e0a78df Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 11:13:44 -0800 Subject: [PATCH 10/18] Handle yaml name import --- .../components/rainbird/config_flow.py | 22 +++++++--- homeassistant/components/rainbird/const.py | 1 + homeassistant/components/rainbird/switch.py | 14 ++++++- tests/components/rainbird/test_switch.py | 42 +++++++++++++++++++ 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 360b50d411d2b0..87f878658fa8ff 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -16,7 +16,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_FRIENDLY_NAME, CONF_HOST, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv, selector @@ -24,7 +24,9 @@ from .const import ( ATTR_DURATION, + CONF_IMPORTED_NAMES, CONF_SERIAL_NUMBER, + CONF_ZONES, DEFAULT_TRIGGER_TIME_MINUTES, DOMAIN, TIMEOUT_SECONDS, @@ -129,13 +131,21 @@ async def async_step_import(self, config: dict[str, Any]) -> FlowResult: except ConfigFlowError as err: _LOGGER.error("Error during config import: %s", err) return self.async_abort(reason=err.error_code) + + data = { + CONF_HOST: config[CONF_HOST], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_SERIAL_NUMBER: serial_number, + } + names: dict[int, str] = {} + for (zone, zone_config) in config.get(CONF_ZONES, {}).items(): + if name := zone_config.get(CONF_FRIENDLY_NAME): + names[int(zone)] = name + if names: + data[CONF_IMPORTED_NAMES] = names return await self.async_finish( serial_number, - data={ - CONF_HOST: config[CONF_HOST], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_SERIAL_NUMBER: serial_number, - }, + data=data, options={ ATTR_DURATION: config.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), }, diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index 584f9c543da340..e105a99c528db7 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -6,6 +6,7 @@ CONF_ZONES = "zones" CONF_SERIAL_NUMBER = "serial_number" +CONF_IMPORTED_NAMES = "imported_names" ATTR_DURATION = "duration" diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 73a3c18fe58ca7..10eb71c0d11b89 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -13,7 +13,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES, DOMAIN +from .const import ( + ATTR_DURATION, + CONF_IMPORTED_NAMES, + DEFAULT_TRIGGER_TIME_MINUTES, + DOMAIN, +) from .coordinator import RainbirdUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -46,6 +51,7 @@ async def async_setup_entry( coordinator, zone, config_entry.options.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), + config_entry.data.get(CONF_IMPORTED_NAMES, {}).get(zone), ) for zone in coordinator.data.zones ) @@ -66,11 +72,15 @@ def __init__( coordinator: RainbirdUpdateCoordinator, zone: int, duration_minutes: int, + imported_name: str | None, ) -> None: """Initialize a Rain Bird Switch Device.""" super().__init__(coordinator) self._zone = zone - self._attr_name = f"Sprinkler {zone}" + if imported_name: + self._attr_name = imported_name + else: + self._attr_name = f"Sprinkler {zone}" self._state = None self._duration_minutes = duration_minutes self._attr_unique_id = f"{coordinator.serial_number}-{zone}" diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 5f5ca99a04bfbc..25ace74431ea05 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -10,8 +10,11 @@ from .conftest import ( ACK_ECHO, EMPTY_STATIONS_RESPONSE, + HOST, + PASSWORD, RAIN_DELAY_OFF, RAIN_SENSOR_OFF, + SERIAL_RESPONSE, ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, ZONE_OFF_RESPONSE, @@ -205,3 +208,42 @@ async def test_irrigation_service( zone = hass.states.get("switch.sprinkler_3") assert zone is not None assert zone.state == "on" + + +@pytest.mark.parametrize( + "yaml_config,config_entry_data", + [ + ( + { + DOMAIN: { + "host": HOST, + "password": PASSWORD, + "trigger_time": 360, + "zones": { + 1: { + "friendly_name": "Garden Sprinkler", + }, + 2: { + "friendly_name": "Back Yard", + }, + }, + } + }, + None, + ) + ], +) +async def test_yaml_config( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], +) -> None: + """Test switch platform with fake data that creates 7 zones with one enabled.""" + responses.insert(0, mock_response(SERIAL_RESPONSE)) # Extra import request + assert await setup_integration() + + assert hass.states.get("switch.garden_sprinkler") + assert not hass.states.get("switch.sprinkler_1") + assert hass.states.get("switch.back_yard") + assert not hass.states.get("switch.sprinkler_2") + assert hass.states.get("switch.sprinkler_3") From 9b12dee7098523d87456ded9f42b8ebedfd3f910 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 11:35:06 -0800 Subject: [PATCH 11/18] Fix naming import post serialization --- homeassistant/components/rainbird/config_flow.py | 4 ++-- homeassistant/components/rainbird/switch.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 87f878658fa8ff..4a757e48b90f54 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -137,10 +137,10 @@ async def async_step_import(self, config: dict[str, Any]) -> FlowResult: CONF_PASSWORD: config[CONF_PASSWORD], CONF_SERIAL_NUMBER: serial_number, } - names: dict[int, str] = {} + names: dict[str, str] = {} for (zone, zone_config) in config.get(CONF_ZONES, {}).items(): if name := zone_config.get(CONF_FRIENDLY_NAME): - names[int(zone)] = name + names[str(zone)] = name if names: data[CONF_IMPORTED_NAMES] = names return await self.async_finish( diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 10eb71c0d11b89..19953a0b8817a8 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -51,7 +51,7 @@ async def async_setup_entry( coordinator, zone, config_entry.options.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), - config_entry.data.get(CONF_IMPORTED_NAMES, {}).get(zone), + config_entry.data.get(CONF_IMPORTED_NAMES, {}).get(str(zone)), ) for zone in coordinator.data.zones ) From 0cc4ec95e0010aa12e1593b5564fd3ee33b551b4 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 11:48:08 -0800 Subject: [PATCH 12/18] Parallelize requests to the device --- .../components/rainbird/coordinator.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 8173f7eed22fe5..123f5a0d5dccc6 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass import datetime import logging @@ -84,15 +85,13 @@ async def _async_update_data(self) -> RainbirdDeviceState: raise UpdateFailed(f"Error communicating with Device: {err}") from err async def _fetch_data(self) -> RainbirdDeviceState: - """Fetch data from the Rain Bird device. - - The data is fetched serially to avoid overwheling the device. - TODO: Do additional testing with this in parallel to see how it holds up. - """ - zones = await self._fetch_zones() - states = await self._controller.get_zone_states() - rain = await self._controller.get_rain_sensor_state() - rain_delay = await self._controller.get_rain_delay() + """Fetch data from the Rain Bird device.""" + (zones, states, rain, rain_delay) = await asyncio.gather( + self._fetch_zones(), + self._controller.get_zone_states(), + self._controller.get_rain_sensor_state(), + self._controller.get_rain_delay(), + ) return RainbirdDeviceState( zones=set(zones), active_zones={zone for zone in zones if states.active(zone)}, From 9f8e2d7890776a4f9772a0c523c9148140a74eef Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 14:31:14 -0800 Subject: [PATCH 13/18] Complete conversion to entity service --- homeassistant/components/rainbird/services.yaml | 12 ++++-------- homeassistant/components/rainbird/switch.py | 16 +++------------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/rainbird/services.yaml b/homeassistant/components/rainbird/services.yaml index 39a487b54cf047..addbd4203c5c1f 100644 --- a/homeassistant/components/rainbird/services.yaml +++ b/homeassistant/components/rainbird/services.yaml @@ -1,15 +1,11 @@ start_irrigation: name: Start irrigation description: Start the irrigation + target: + entity: + integration: rainbird + domain: switch fields: - entity_id: - name: Entity - description: Name of a single irrigation to turn on - required: true - selector: - entity: - integration: rainbird - domain: switch duration: name: Duration description: Duration for this sprinkler to be turned on diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 19953a0b8817a8..d5f9f32993209d 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -7,7 +7,6 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,18 +24,9 @@ SERVICE_START_IRRIGATION = "start_irrigation" -SERVICE_SCHEMA_IRRIGATION = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_DURATION): cv.positive_float, - } -) - -SERVICE_SCHEMA_RAIN_DELAY = vol.Schema( - { - vol.Required(ATTR_DURATION): cv.positive_float, - } -) +SERVICE_SCHEMA_IRRIGATION = { + vol.Required(ATTR_DURATION): cv.positive_float, +} async def async_setup_entry( From d8eb12ead6ea3d20c5bf1aa864ea8f7faecc98fd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 14:43:32 -0800 Subject: [PATCH 14/18] Update homeassistant/components/rainbird/switch.py Co-authored-by: Martin Hjelmare --- homeassistant/components/rainbird/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index d5f9f32993209d..63a4aaba869464 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -40,7 +40,7 @@ async def async_setup_entry( RainBirdSwitch( coordinator, zone, - config_entry.options.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), + config_entry.options[ATTR_DURATION], config_entry.data.get(CONF_IMPORTED_NAMES, {}).get(str(zone)), ) for zone in coordinator.data.zones From 63f43fb95a67db0b7f319798e49dbac219d860bc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 14:43:54 -0800 Subject: [PATCH 15/18] Update homeassistant/components/rainbird/config_flow.py Co-authored-by: Martin Hjelmare --- homeassistant/components/rainbird/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 4a757e48b90f54..ceb0e18be0f256 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -187,9 +187,9 @@ async def async_step_init( { vol.Optional( ATTR_DURATION, - default=self.config_entry.options.get( + default=self.config_entry.options[ ATTR_DURATION, - ), + ], ): cv.positive_int, } ), From 009c08d1428cf04984bd0db9ab9cd912e1183811 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 17:34:16 -0800 Subject: [PATCH 16/18] Remove unused import --- homeassistant/components/rainbird/switch.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 63a4aaba869464..12cc44c192b0a7 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -12,12 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_DURATION, - CONF_IMPORTED_NAMES, - DEFAULT_TRIGGER_TIME_MINUTES, - DOMAIN, -) +from .const import ATTR_DURATION, CONF_IMPORTED_NAMES, DOMAIN from .coordinator import RainbirdUpdateCoordinator _LOGGER = logging.getLogger(__name__) From b91275a3fdc49d31c457033bf9753480b8227921 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 17:36:59 -0800 Subject: [PATCH 17/18] Set default duration in options used in tests --- homeassistant/components/rainbird/config_flow.py | 4 +--- tests/components/rainbird/conftest.py | 5 +++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index ceb0e18be0f256..057fc6fe39662a 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -187,9 +187,7 @@ async def async_step_init( { vol.Optional( ATTR_DURATION, - default=self.config_entry.options[ - ATTR_DURATION, - ], + default=self.config_entry.options[ATTR_DURATION], ): cv.positive_int, } ), diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 426f7c3dcdb63a..22f238ce55360b 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -11,6 +11,10 @@ import pytest from homeassistant.components.rainbird import DOMAIN +from homeassistant.components.rainbird.const import ( + ATTR_DURATION, + DEFAULT_TRIGGER_TIME_MINUTES, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -100,6 +104,7 @@ async def config_entry( unique_id=SERIAL_NUMBER, domain=DOMAIN, data=config_entry_data, + options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, ) From 15edee13e3f3876be3296c7cf0a422bf211d101a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Jan 2023 19:10:47 -0800 Subject: [PATCH 18/18] Add separate devices for each sprinkler zone and update service to use config entry --- homeassistant/components/rainbird/__init__.py | 22 +++------ homeassistant/components/rainbird/const.py | 3 +- .../components/rainbird/coordinator.py | 5 +- .../components/rainbird/services.yaml | 10 ++-- homeassistant/components/rainbird/switch.py | 13 ++++-- tests/components/rainbird/test_init.py | 13 +++--- tests/components/rainbird/test_switch.py | 46 +++++++++---------- 7 files changed, 58 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 6dbad32c08355f..af2bb92bce5daa 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,7 +1,6 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations -import asyncio import logging from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController @@ -9,7 +8,6 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState from homeassistant.const import ( - ATTR_DEVICE_ID, CONF_FRIENDLY_NAME, CONF_HOST, CONF_PASSWORD, @@ -21,10 +19,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.service import async_extract_config_entry_ids from homeassistant.helpers.typing import ConfigType -from .const import ATTR_DURATION, CONF_SERIAL_NUMBER, CONF_ZONES +from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DURATION, CONF_SERIAL_NUMBER, CONF_ZONES from .coordinator import RainbirdUpdateCoordinator PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR] @@ -60,11 +57,10 @@ SERVICE_SCHEMA_RAIN_DELAY = vol.All( vol.Schema( { - **cv.TARGET_SERVICE_FIELDS, + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, vol.Required(ATTR_DURATION): cv.positive_float, } ), - cv.has_at_least_one_key(ATTR_DEVICE_ID), ) @@ -121,16 +117,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def set_rain_delay(call: ServiceCall) -> None: """Service call to delay automatic irrigigation.""" + entry_id = call.data[ATTR_CONFIG_ENTRY_ID] duration = call.data[ATTR_DURATION] - entry_ids = await async_extract_config_entry_ids(hass, call) - if not entry_ids: - raise HomeAssistantError("Device id did not match any devices") - await asyncio.gather( - *( - hass.data[DOMAIN][entry_id].controller.set_rain_delay(duration) - for entry_id in entry_ids - ) - ) + if entry_id not in hass.data[DOMAIN]: + raise HomeAssistantError(f"Config entry id does not exist: {entry_id}") + coordinator = hass.data[DOMAIN][entry_id] + await coordinator.controller.set_rain_delay(duration) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index e105a99c528db7..162e3a16b6c6b4 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -1,7 +1,7 @@ """Constants for rainbird.""" DOMAIN = "rainbird" - +MANUFACTURER = "Rain Bird" DEFAULT_TRIGGER_TIME_MINUTES = 6 CONF_ZONES = "zones" @@ -9,5 +9,6 @@ CONF_IMPORTED_NAMES = "imported_names" ATTR_DURATION = "duration" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" TIMEOUT_SECONDS = 20 diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 123f5a0d5dccc6..ddb2b70324d62c 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -15,10 +15,9 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, TIMEOUT_SECONDS +from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS UPDATE_INTERVAL = datetime.timedelta(minutes=1) -MANUFACTURER = "Rain Bird" _LOGGER = logging.getLogger(__name__) @@ -71,7 +70,7 @@ def serial_number(self) -> str: def device_info(self) -> DeviceInfo: """Return information about the device.""" return DeviceInfo( - default_name=MANUFACTURER, + default_name=f"{MANUFACTURER} Controller", identifiers={(DOMAIN, self._serial_number)}, manufacturer=MANUFACTURER, ) diff --git a/homeassistant/components/rainbird/services.yaml b/homeassistant/components/rainbird/services.yaml index addbd4203c5c1f..34f89ec279bdd5 100644 --- a/homeassistant/components/rainbird/services.yaml +++ b/homeassistant/components/rainbird/services.yaml @@ -18,10 +18,14 @@ start_irrigation: set_rain_delay: name: Set rain delay description: Set how long automatic irrigation is turned off. - target: - device: - integration: rainbird fields: + config_entry_id: + name: Rainbird Controller Configuration Entry + description: The setting will be adjusted on the specified controller + required: true + selector: + config_entry: + integration: rainbird duration: name: Duration description: Duration for this system to be turned off. diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 12cc44c192b0a7..38f3c03fb03479 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -9,10 +9,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_DURATION, CONF_IMPORTED_NAMES, DOMAIN +from .const import ATTR_DURATION, CONF_IMPORTED_NAMES, DOMAIN, MANUFACTURER from .coordinator import RainbirdUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -64,12 +65,18 @@ def __init__( self._zone = zone if imported_name: self._attr_name = imported_name + self._attr_has_entity_name = False else: - self._attr_name = f"Sprinkler {zone}" + self._attr_has_entity_name = True self._state = None self._duration_minutes = duration_minutes self._attr_unique_id = f"{coordinator.serial_number}-{zone}" - self._attr_device_info = coordinator.device_info + self._attr_device_info = DeviceInfo( + default_name=f"{MANUFACTURER} Sprinkler {zone}", + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=MANUFACTURER, + via_device=(DOMAIN, coordinator.serial_number), + ) @property def extra_state_attributes(self): diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 8cb5aff95ec3d5..7a8eb17bf1d20b 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -5,8 +5,9 @@ import pytest from homeassistant.components.rainbird import DOMAIN +from homeassistant.components.rainbird.const import ATTR_CONFIG_ENTRY_ID, ATTR_DURATION from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import ATTR_DEVICE_ID, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -116,7 +117,7 @@ async def test_rain_delay_service( device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, SERIAL_NUMBER)}) assert device - assert device.name == "Rain Bird" + assert device.name == "Rain Bird Controller" aioclient_mock.mock_calls.clear() responses.append(mock_response(ACK_ECHO)) @@ -124,14 +125,14 @@ async def test_rain_delay_service( await hass.services.async_call( DOMAIN, "set_rain_delay", - {ATTR_DEVICE_ID: device.id, "duration": 30}, + {ATTR_CONFIG_ENTRY_ID: config_entry.entry_id, ATTR_DURATION: 3}, blocking=True, ) assert len(aioclient_mock.mock_calls) == 1 -async def test_rain_delay_invalid_device( +async def test_rain_delay_invalid_config_entry( hass: HomeAssistant, setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, @@ -143,11 +144,11 @@ async def test_rain_delay_invalid_device( aioclient_mock.mock_calls.clear() - with pytest.raises(HomeAssistantError, match="Device id did not match"): + with pytest.raises(HomeAssistantError, match="Config entry id does not exist"): await hass.services.async_call( DOMAIN, "set_rain_delay", - {ATTR_DEVICE_ID: "invalid-device-id", "duration": 30}, + {ATTR_CONFIG_ENTRY_ID: "invalid", ATTR_DURATION: 3}, blocking=True, ) diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 25ace74431ea05..5f84c5d154e3ee 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -44,7 +44,7 @@ async def test_no_zones( assert await setup_integration() - zone = hass.states.get("switch.sprinkler_1") + zone = hass.states.get("switch.rain_bird_sprinkler_1") assert zone is None @@ -61,43 +61,43 @@ async def test_zones( assert await setup_integration() - zone = hass.states.get("switch.sprinkler_1") + zone = hass.states.get("switch.rain_bird_sprinkler_1") assert zone is not None assert zone.state == "off" assert zone.attributes == { - "friendly_name": "Sprinkler 1", + "friendly_name": "Rain Bird Sprinkler 1", "zone": 1, } - zone = hass.states.get("switch.sprinkler_2") + zone = hass.states.get("switch.rain_bird_sprinkler_2") assert zone is not None assert zone.state == "off" assert zone.attributes == { - "friendly_name": "Sprinkler 2", + "friendly_name": "Rain Bird Sprinkler 2", "zone": 2, } - zone = hass.states.get("switch.sprinkler_3") + zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "off" - zone = hass.states.get("switch.sprinkler_4") + zone = hass.states.get("switch.rain_bird_sprinkler_4") assert zone is not None assert zone.state == "off" - zone = hass.states.get("switch.sprinkler_5") + zone = hass.states.get("switch.rain_bird_sprinkler_5") assert zone is not None assert zone.state == "on" - zone = hass.states.get("switch.sprinkler_6") + zone = hass.states.get("switch.rain_bird_sprinkler_6") assert zone is not None assert zone.state == "off" - zone = hass.states.get("switch.sprinkler_7") + zone = hass.states.get("switch.rain_bird_sprinkler_7") assert zone is not None assert zone.state == "off" - assert not hass.states.get("switch.sprinkler_8") + assert not hass.states.get("switch.rain_bird_sprinkler_8") async def test_switch_on( @@ -112,7 +112,7 @@ async def test_switch_on( # Initially all zones are off. Pick zone3 as an arbitrary to assert # state, then update below as a switch. - zone = hass.states.get("switch.sprinkler_3") + zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "off" @@ -126,11 +126,11 @@ async def test_switch_on( mock_response(RAIN_DELAY_OFF), ] ) - await switch_common.async_turn_on(hass, "switch.sprinkler_3") + await switch_common.async_turn_on(hass, "switch.rain_bird_sprinkler_3") await hass.async_block_till_done() # Verify switch state is updated - zone = hass.states.get("switch.sprinkler_3") + zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "on" @@ -150,7 +150,7 @@ async def test_switch_off( assert await setup_integration() # Initially the test zone is on - zone = hass.states.get("switch.sprinkler_3") + zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "on" @@ -163,11 +163,11 @@ async def test_switch_off( mock_response(RAIN_DELAY_OFF), ] ) - await switch_common.async_turn_off(hass, "switch.sprinkler_3") + await switch_common.async_turn_off(hass, "switch.rain_bird_sprinkler_3") await hass.async_block_till_done() # Verify switch state is updated - zone = hass.states.get("switch.sprinkler_3") + zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "off" @@ -183,7 +183,7 @@ async def test_irrigation_service( assert await setup_integration() - zone = hass.states.get("switch.sprinkler_3") + zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "off" @@ -201,11 +201,11 @@ async def test_irrigation_service( await hass.services.async_call( DOMAIN, "start_irrigation", - {ATTR_ENTITY_ID: "switch.sprinkler_3", "duration": 30}, + {ATTR_ENTITY_ID: "switch.rain_bird_sprinkler_3", "duration": 30}, blocking=True, ) - zone = hass.states.get("switch.sprinkler_3") + zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "on" @@ -243,7 +243,7 @@ async def test_yaml_config( assert await setup_integration() assert hass.states.get("switch.garden_sprinkler") - assert not hass.states.get("switch.sprinkler_1") + assert not hass.states.get("switch.rain_bird_sprinkler_1") assert hass.states.get("switch.back_yard") - assert not hass.states.get("switch.sprinkler_2") - assert hass.states.get("switch.sprinkler_3") + assert not hass.states.get("switch.rain_bird_sprinkler_2") + assert hass.states.get("switch.rain_bird_sprinkler_3")