From 5000c426c655af1abbbfa4eaafd99f330506b79d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 7 Jan 2023 09:34:01 -0800 Subject: [PATCH] Add config flow for Rain Bird (#85271) * Rainbird config flow Convert rainbird to a config flow. Still need to handle irrigation numbers. * Add options for irrigation time and deprecate yaml * Combine exception handling paths to get 100% test coverage * Bump the rainird config deprecation release * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Remove unnecessary sensor/binary sensor and address some PR feedback * Simplify configuration flow and options based on PR feedback * Consolidate data update coordinators to simplify overall integration * Fix type error on python3.9 * Handle yaml name import * Fix naming import post serialization * Parallelize requests to the device * Complete conversion to entity service * Update homeassistant/components/rainbird/switch.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/rainbird/config_flow.py Co-authored-by: Martin Hjelmare * Remove unused import * Set default duration in options used in tests * Add separate devices for each sprinkler zone and update service to use config entry Co-authored-by: Martin Hjelmare --- homeassistant/components/rainbird/__init__.py | 142 +++++++---- .../components/rainbird/binary_sensor.py | 49 ++-- .../components/rainbird/config_flow.py | 194 +++++++++++++++ homeassistant/components/rainbird/const.py | 14 +- .../components/rainbird/coordinator.py | 85 ++++++- .../components/rainbird/manifest.json | 1 + homeassistant/components/rainbird/sensor.py | 51 ++-- .../components/rainbird/services.yaml | 19 +- .../components/rainbird/strings.json | 34 +++ homeassistant/components/rainbird/switch.py | 154 ++++-------- .../components/rainbird/translations/en.json | 34 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/rainbird/conftest.py | 97 +++++++- .../components/rainbird/test_binary_sensor.py | 50 +--- tests/components/rainbird/test_config_flow.py | 149 ++++++++++++ tests/components/rainbird/test_init.py | 155 ++++++++++-- tests/components/rainbird/test_sensor.py | 30 +-- tests/components/rainbird/test_switch.py | 226 +++++++----------- 19 files changed, 1016 insertions(+), 471 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 1e80cfb1cbc..af2bb92bce5 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,16 +1,12 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations -import asyncio import logging -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 from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_HOST, @@ -18,18 +14,14 @@ from homeassistant.const import ( CONF_TRIGGER_TIME, Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.core import HomeAssistant, ServiceCall +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.typing import ConfigType -from .const import ( - CONF_ZONES, - RAINBIRD_CONTROLLER, - SENSOR_TYPE_RAINDELAY, - SENSOR_TYPE_RAINSENSOR, -) +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] @@ -61,47 +53,99 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +SERVICE_SET_RAIN_DELAY = "set_rain_delay" +SERVICE_SCHEMA_RAIN_DELAY = vol.All( + vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + 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 - -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) - - for platform in PLATFORMS: + for controller_config in config[DOMAIN]: hass.async_create_task( - discovery.async_load_platform( - hass, - platform, + hass.config_entries.flow.async_init( DOMAIN, - { - RAINBIRD_CONTROLLER: controller, - SENSOR_TYPE_RAINSENSOR: rain_coordinator, - SENSOR_TYPE_RAINDELAY: delay_coordinator, - **controller_config, - }, - config, + context={"source": SOURCE_IMPORT}, + data=controller_config, ) ) + + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the config entry for Rain Bird.""" + + hass.data.setdefault(DOMAIN, {}) + + controller = AsyncRainbirdController( + AsyncRainbirdClient( + async_get_clientsession(hass), + entry.data[CONF_HOST], + 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() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + 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] + 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, + 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) + + 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 02ea8b21bb1..ee5be0e4617 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -2,71 +2,54 @@ from __future__ import annotations import logging -from typing import Union from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR +from .const import DOMAIN from .coordinator import RainbirdUpdateCoordinator _LOGGER = logging.getLogger(__name__) -BINARY_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="rainsensor", + name="Rainsensor", + icon="mdi:water", ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> 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 - ], - True, - ) + """Set up entry for a Rain Bird binary_sensor.""" + 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, ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description + 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 new file mode 100644 index 00000000000..057fc6fe396 --- /dev/null +++ b/homeassistant/components/rainbird/config_flow.py @@ -0,0 +1,194 @@ +"""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.config_entries import ConfigEntry +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 +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + ATTR_DURATION, + CONF_IMPORTED_NAMES, + CONF_SERIAL_NUMBER, + CONF_ZONES, + DEFAULT_TRIGGER_TIME_MINUTES, + 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 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.""" + + @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: + """Configure the Rain Bird device.""" + error_code: str | None = None + if user_input: + try: + serial_number = await self._test_connection( + user_input[CONF_HOST], user_input[CONF_PASSWORD] + ) + 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, + 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( + 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 ConfigFlowError on failure. + """ + controller = AsyncRainbirdController( + AsyncRainbirdClient( + async_get_clientsession(self.hass), + host, + password, + ) + ) + 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.""" + self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) + try: + serial_number = await self._test_connection( + config[CONF_HOST], config[CONF_PASSWORD] + ) + 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[str, str] = {} + for (zone, zone_config) in config.get(CONF_ZONES, {}).items(): + if name := zone_config.get(CONF_FRIENDLY_NAME): + names[str(zone)] = name + if names: + data[CONF_IMPORTED_NAMES] = names + return await self.async_finish( + serial_number, + data=data, + 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], + ) -> 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, + 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[ATTR_DURATION], + ): cv.positive_int, + } + ), + ) diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index be06fdb8224..162e3a16b6c 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -1,10 +1,14 @@ """Constants for rainbird.""" DOMAIN = "rainbird" - -SENSOR_TYPE_RAINDELAY = "raindelay" -SENSOR_TYPE_RAINSENSOR = "rainsensor" - -RAINBIRD_CONTROLLER = "controller" +MANUFACTURER = "Rain Bird" +DEFAULT_TRIGGER_TIME_MINUTES = 6 CONF_ZONES = "zones" +CONF_SERIAL_NUMBER = "serial_number" +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 ee6857fe93c..ddb2b70324d 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -2,18 +2,21 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +import asyncio +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 -TIMEOUT_SECONDS = 20 +from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS + UPDATE_INTERVAL = datetime.timedelta(minutes=1) _LOGGER = logging.getLogger(__name__) @@ -21,27 +24,87 @@ _LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") -class RainbirdUpdateCoordinator(DataUpdateCoordinator[_T]): +@dataclass +class RainbirdDeviceState: + """Data retrieved from a Rain Bird device.""" + + zones: set[int] + active_zones: set[int] + rain: bool + rain_delay: int + + +class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): """Coordinator for rainbird API calls.""" def __init__( self, hass: HomeAssistant, - update_method: Callable[[], Awaitable[_T]], + name: str, + controller: AsyncRainbirdController, + serial_number: str, ) -> None: """Initialize ZoneStateUpdateCoordinator.""" super().__init__( hass, _LOGGER, - name="Rainbird Zones", - update_method=update_method, + name=name, + update_method=self._async_update_data, update_interval=UPDATE_INTERVAL, ) + self._controller = controller + self._serial_number = serial_number + self._zones: set[int] | None = None - async def _async_update_data(self) -> _T: - """Fetch data from API endpoint.""" + @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=f"{MANUFACTURER} Controller", + identifiers={(DOMAIN, self._serial_number)}, + manufacturer=MANUFACTURER, + ) + + 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.""" + (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)}, + 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/manifest.json b/homeassistant/components/rainbird/manifest.json index 8ef49143f62..50eb11c3fe9 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==1.1.0"], "codeowners": ["@konikvranik", "@allenporter"], diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index e1dd56d1fb3..de74943baf9 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -2,69 +2,58 @@ 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_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +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 from .coordinator import RainbirdUpdateCoordinator _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="raindelay", + name="Raindelay", + icon="mdi:water-off", ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up a Rain Bird sensor.""" - - if discovery_info is None: - return - + """Set up entry for a Rain Bird sensor.""" async_add_entities( [ - RainBirdSensor(discovery_info[description.key], description) - for description in SENSOR_TYPES - ], - True, + RainBirdSensor( + hass.data[DOMAIN][config_entry.entry_id], + RAIN_DELAY_ENTITY_DESCRIPTION, + ) + ] ) -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, ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description + 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/services.yaml b/homeassistant/components/rainbird/services.yaml index 3d5f55dba14..34f89ec279b 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 @@ -23,6 +19,13 @@ set_rain_delay: name: Set rain delay description: Set how long automatic irrigation is turned off. 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/strings.json b/homeassistant/components/rainbird/strings.json new file mode 100644 index 00000000000..74bd43f2c0b --- /dev/null +++ b/homeassistant/components/rainbird/strings.json @@ -0,0 +1,34 @@ +{ + "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%]" + } + }, + "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.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 5a9edee2753..38f3c03fb03 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -3,162 +3,100 @@ from __future__ import annotations 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.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.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.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_IMPORTED_NAMES, DOMAIN, MANUFACTURER 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( - { - 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_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up Rain Bird switches over a Rain Bird controller.""" - - if discovery_info is None: - return - - controller: AsyncRainbirdController = discovery_info[RAINBIRD_CONTROLLER] - try: - available_stations: AvailableStations = ( - await controller.get_available_stations() + """Set up entry for a Rain Bird irrigation switches.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + RainBirdSwitch( + coordinator, + zone, + config_entry.options[ATTR_DURATION], + config_entry.data.get(CONF_IMPORTED_NAMES, {}).get(str(zone)), ) - 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, 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}", - ) - ) + for zone in coordinator.data.zones + ) - try: - await coordinator.async_config_entry_first_refresh() - except ConfigEntryNotReady as err: - raise PlatformNotReady(f"Failed to load zone state: {str(err)}") from err - - async_add_entities(devices) - - async def start_irrigation(service: ServiceCall) -> None: - entity_id = service.data[ATTR_ENTITY_ID] - duration = service.data[ATTR_DURATION] - - for device in devices: - if device.entity_id == entity_id: - await device.async_turn_on(duration=duration) - - 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", ) -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, - time: int, - name: str, + duration_minutes: int, + imported_name: str | None, ) -> None: """Initialize a Rain Bird Switch Device.""" super().__init__(coordinator) - self._rainbird = rainbird self._zone = zone - self._name = name + if imported_name: + self._attr_name = imported_name + self._attr_has_entity_name = False + else: + self._attr_has_entity_name = True self._state = None - self._duration = time - self._attributes = {ATTR_DURATION: self._duration, "zone": self._zone} + self._duration_minutes = duration_minutes + self._attr_unique_id = f"{coordinator.serial_number}-{zone}" + 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): """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[ATTR_DURATION] if ATTR_DURATION in kwargs else self._duration), + int(kwargs.get(ATTR_DURATION, self._duration_minutes)), ) await self.coordinator.async_request_refresh() 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/homeassistant/components/rainbird/translations/en.json b/homeassistant/components/rainbird/translations/en.json new file mode 100644 index 00000000000..f9b7c25733b --- /dev/null +++ b/homeassistant/components/rainbird/translations/en.json @@ -0,0 +1,34 @@ +{ + "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, 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/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4e56bdf5fc1..7e952bf101b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -333,6 +333,7 @@ FLOWS = { "radarr", "radio_browser", "radiotherm", + "rainbird", "rainforest_eagle", "rainmachine", "rdw", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9db269b9c1c..d3d7e49a27e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4336,7 +4336,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 660307f1c60..22f238ce553 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 @@ -10,10 +11,15 @@ from pyrainbird import encryption 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 +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse ComponentSetup = Callable[[], Awaitable[bool]] @@ -21,6 +27,7 @@ ComponentSetup = Callable[[], Awaitable[bool]] HOST = "example.com" URL = "http://example.com/stick" PASSWORD = "password" +SERIAL_NUMBER = 0x12635436566 # # Response payloads below come from pyrainbird test cases. @@ -45,14 +52,28 @@ RAIN_DELAY_OFF = "B60000" # ACK command 0x10, Echo 0x06 ACK_ECHO = "0106" + CONFIG = { DOMAIN: { "host": HOST, "password": PASSWORD, - "trigger_time": 360, + "trigger_time": { + "minutes": 6, + }, } } +CONFIG_ENTRY_DATA = { + "host": HOST, + "password": PASSWORD, + "serial_number": SERIAL_NUMBER, +} + + +UNAVAILABLE_RESPONSE = AiohttpClientMockResponse( + "POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE +) + @pytest.fixture def platforms() -> list[Platform]: @@ -63,7 +84,37 @@ 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( + unique_id=SERIAL_NUMBER, + domain=DOMAIN, + data=config_entry_data, + options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, + ) + + +@pytest.fixture(autouse=True) +async def add_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry | None +) -> None: + """Fixture to add the config entry.""" + if config_entry: + config_entry.add_to_hass(hass) @pytest.fixture @@ -97,10 +148,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 7ed6f2d1a29..2cb49de49e1 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -6,14 +6,7 @@ import pytest 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 from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -25,54 +18,23 @@ 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.extend( - [ - mock_response(sensor_payload), - mock_response(RAIN_DELAY), - ] - ) - assert await setup_integration() rainsensor = hass.states.get("binary_sensor.rainsensor") assert rainsensor is not None assert rainsensor.state == expected_state - - -@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 rainsensor.attributes == { + "friendly_name": "Rainsensor", + "icon": "mdi:water", + } diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py new file mode 100644 index 00000000000..31650a0828a --- /dev/null +++ b/tests/components/rainbird/test_config_flow.py @@ -0,0 +1,149 @@ +"""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.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, FlowResultType + +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.""" + 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") == FlowResultType.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 result["result"].options == {ATTR_DURATION: 6} + + 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") == FlowResultType.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") == 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 == {ATTR_DURATION: 6} + + # 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: 5} + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert config_entry.options == { + ATTR_DURATION: 5, + } diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index acf6a92d4a5..7a8eb17bf1d 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -1,34 +1,155 @@ """Tests for rainbird initialization.""" -from http import HTTPStatus +from __future__ import annotations +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 Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr -from .conftest import URL, ComponentSetup +from .conftest import ( + ACK_ECHO, + CONFIG, + CONFIG_ENTRY_DATA, + SERIAL_NUMBER, + SERIAL_RESPONSE, + UNAVAILABLE_RESPONSE, + ComponentSetup, + mock_response, +) from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse -async def test_setup_success( - hass: HomeAssistant, - setup_integration: ComponentSetup, -) -> None: - """Test successful setup and unload.""" - - assert await setup_integration() - - -async def test_setup_communication_failure( +@pytest.mark.parametrize( + "yaml_config,config_entry_data,initial_response", + [ + ({}, CONFIG_ENTRY_DATA, None), + ( + CONFIG, + None, + mock_response(SERIAL_RESPONSE), # Extra import request + ), + ( + CONFIG, + CONFIG_ENTRY_DATA, + None, + ), + ], + ids=["config_entry", "yaml", "already_exists"], +) +async def test_init_success( hass: HomeAssistant, setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], - aioclient_mock: AiohttpClientMocker, + initial_response: AiohttpClientMockResponse | None, +) -> None: + """Test successful setup and unload.""" + if initial_response: + responses.insert(0, initial_response) + + assert await setup_integration() + + 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( + "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, + 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 [ + 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[str], + config_entry: ConfigEntry, +) -> None: + """Test calling the rain delay service.""" + + 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 Controller" + + aioclient_mock.mock_calls.clear() + responses.append(mock_response(ACK_ECHO)) + + await hass.services.async_call( + DOMAIN, + "set_rain_delay", + {ATTR_CONFIG_ENTRY_ID: config_entry.entry_id, ATTR_DURATION: 3}, + blocking=True, ) - assert not await setup_integration() + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rain_delay_invalid_config_entry( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + config_entry: ConfigEntry, +) -> None: + """Test calling the rain delay service.""" + + assert await setup_integration() + + aioclient_mock.mock_calls.clear() + + with pytest.raises(HomeAssistantError, match="Config entry id does not exist"): + await hass.services.async_call( + DOMAIN, + "set_rain_delay", + {ATTR_CONFIG_ENTRY_ID: "invalid", ATTR_DURATION: 3}, + 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 b80e014b236..694c7245b38 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -6,15 +6,7 @@ import pytest 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 tests.test_util.aiohttp import AiohttpClientMockResponse +from .conftest import RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup @pytest.fixture @@ -24,26 +16,22 @@ def platforms() -> list[str]: @pytest.mark.parametrize( - "sensor_payload,expected_state", - [(RAIN_SENSOR_OFF, "False"), (RAIN_SENSOR_ON, "True")], + "rain_delay_response,expected_state", + [(RAIN_DELAY, "16"), (RAIN_DELAY_OFF, "0")], ) async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], - sensor_payload: str, - expected_state: bool, + expected_state: str, ) -> None: """Test sensor platform.""" - responses.extend([mock_response(sensor_payload), mock_response(RAIN_DELAY)]) - assert await setup_integration() - rainsensor = hass.states.get("sensor.rainsensor") - assert rainsensor is not None - assert rainsensor.state == expected_state - 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 d6e89c58527..5f84c5d154e 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,11 +9,12 @@ from homeassistant.core import HomeAssistant from .conftest import ( ACK_ECHO, - AVAILABLE_STATIONS_RESPONSE, EMPTY_STATIONS_RESPONSE, HOST, PASSWORD, - URL, + RAIN_DELAY_OFF, + RAIN_SENSOR_OFF, + SERIAL_RESPONSE, ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, ZONE_OFF_RESPONSE, @@ -34,20 +32,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") + zone = hass.states.get("switch.rain_bird_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, @@ -55,41 +59,45 @@ 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") + zone = hass.states.get("switch.rain_bird_sprinkler_1") + assert zone is not None + assert zone.state == "off" + assert zone.attributes == { + "friendly_name": "Rain Bird Sprinkler 1", + "zone": 1, + } + + zone = hass.states.get("switch.rain_bird_sprinkler_2") + assert zone is not None + assert zone.state == "off" + assert zone.attributes == { + "friendly_name": "Rain Bird Sprinkler 2", + "zone": 2, + } + + zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "off" - zone = hass.states.get("switch.sprinkler_2") + zone = hass.states.get("switch.rain_bird_sprinkler_4") assert zone is not None assert zone.state == "off" - zone = hass.states.get("switch.sprinkler_3") - assert zone is not None - assert zone.state == "off" - - zone = hass.states.get("switch.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( @@ -100,14 +108,11 @@ 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 # 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" @@ -115,20 +120,25 @@ 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 switch_common.async_turn_on(hass, "switch.rain_bird_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") + zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "on" +@pytest.mark.parametrize( + "zone_state_response", + [ZONE_3_ON_RESPONSE], +) async def test_switch_off( hass: HomeAssistant, setup_integration: ComponentSetup, @@ -137,13 +147,10 @@ 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 - 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" @@ -152,16 +159,15 @@ 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 switch_common.async_turn_off(hass, "switch.rain_bird_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") + zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "off" @@ -171,114 +177,60 @@ 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() - aioclient_mock.mock_calls.clear() - responses.extend([mock_response(ACK_ECHO), mock_response(ZONE_OFF_RESPONSE)]) - - await hass.services.async_call( - DOMAIN, - "start_irrigation", - {ATTR_ENTITY_ID: "switch.sprinkler_5", "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_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() + zone = hass.states.get("switch.rain_bird_sprinkler_3") + assert zone is not None + assert zone.state == "off" aioclient_mock.mock_calls.clear() 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, "set_rain_delay", {"duration": 30}, blocking=True + DOMAIN, + "start_irrigation", + {ATTR_ENTITY_ID: "switch.rain_bird_sprinkler_3", "duration": 30}, + blocking=True, ) - assert len(aioclient_mock.mock_calls) == 1 - - -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 "Failed to load zone state" in caplog.text + zone = hass.states.get("switch.rain_bird_sprinkler_3") + assert zone is not None + assert zone.state == "on" @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( @@ -287,15 +239,11 @@ async def test_yaml_config( responses: list[AiohttpClientMockResponse], ) -> 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)] - ) - + 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 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")