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 <marhje52@gmail.com>

* 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 <marhje52@gmail.com>

* Update homeassistant/components/rainbird/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* 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 <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2023-01-07 09:34:01 -08:00 committed by GitHub
parent e3e64c103d
commit 5000c426c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1016 additions and 471 deletions

View file

@ -1,16 +1,12 @@
"""Support for Rain Bird Irrigation system LNK WiFi Module.""" """Support for Rain Bird Irrigation system LNK WiFi Module."""
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from pyrainbird.async_client import ( from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
AsyncRainbirdClient,
AsyncRainbirdController,
RainbirdApiException,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME,
CONF_HOST, CONF_HOST,
@ -18,18 +14,14 @@ from homeassistant.const import (
CONF_TRIGGER_TIME, CONF_TRIGGER_TIME,
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import discovery from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DURATION, CONF_SERIAL_NUMBER, CONF_ZONES
CONF_ZONES,
RAINBIRD_CONTROLLER,
SENSOR_TYPE_RAINDELAY,
SENSOR_TYPE_RAINSENSOR,
)
from .coordinator import RainbirdUpdateCoordinator from .coordinator import RainbirdUpdateCoordinator
PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR] PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR]
@ -61,47 +53,99 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, 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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Rain Bird component.""" """Set up the Rain Bird component."""
return all( if DOMAIN not in config:
await asyncio.gather( return True
*[
_setup_controller(hass, controller_config, config)
for controller_config in config[DOMAIN]
]
)
)
for controller_config in config[DOMAIN]:
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:
hass.async_create_task( hass.async_create_task(
discovery.async_load_platform( hass.config_entries.flow.async_init(
hass,
platform,
DOMAIN, DOMAIN,
{ context={"source": SOURCE_IMPORT},
RAINBIRD_CONTROLLER: controller, data=controller_config,
SENSOR_TYPE_RAINSENSOR: rain_coordinator,
SENSOR_TYPE_RAINDELAY: delay_coordinator,
**controller_config,
},
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 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

View file

@ -2,71 +2,54 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Union
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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
from .const import SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR from .const import DOMAIN
from .coordinator import RainbirdUpdateCoordinator from .coordinator import RainbirdUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( RAIN_SENSOR_ENTITY_DESCRIPTION = BinarySensorEntityDescription(
BinarySensorEntityDescription( key="rainsensor",
key=SENSOR_TYPE_RAINSENSOR, name="Rainsensor",
name="Rainsensor", icon="mdi:water",
icon="mdi:water",
),
BinarySensorEntityDescription(
key=SENSOR_TYPE_RAINDELAY,
name="Raindelay",
icon="mdi:water-off",
),
) )
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up a Rain Bird sensor.""" """Set up entry for a Rain Bird binary_sensor."""
if discovery_info is None: coordinator = hass.data[DOMAIN][config_entry.entry_id]
return async_add_entities([RainBirdSensor(coordinator, RAIN_SENSOR_ENTITY_DESCRIPTION)])
async_add_entities(
[
RainBirdSensor(discovery_info[description.key], description)
for description in BINARY_SENSOR_TYPES
],
True,
)
class RainBirdSensor( class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], BinarySensorEntity):
CoordinatorEntity[RainbirdUpdateCoordinator[Union[int, bool]]], BinarySensorEntity
):
"""A sensor implementation for Rain Bird device.""" """A sensor implementation for Rain Bird device."""
def __init__( def __init__(
self, self,
coordinator: RainbirdUpdateCoordinator[int | bool], coordinator: RainbirdUpdateCoordinator,
description: BinarySensorEntityDescription, description: BinarySensorEntityDescription,
) -> None: ) -> None:
"""Initialize the Rain Bird sensor.""" """Initialize the Rain Bird sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
self._attr_device_info = coordinator.device_info
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
"""Return True if entity is on.""" """Return True if entity is on."""
return None if self.coordinator.data is None else bool(self.coordinator.data) return self.coordinator.data.rain

View file

@ -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,
}
),
)

View file

@ -1,10 +1,14 @@
"""Constants for rainbird.""" """Constants for rainbird."""
DOMAIN = "rainbird" DOMAIN = "rainbird"
MANUFACTURER = "Rain Bird"
SENSOR_TYPE_RAINDELAY = "raindelay" DEFAULT_TRIGGER_TIME_MINUTES = 6
SENSOR_TYPE_RAINSENSOR = "rainsensor"
RAINBIRD_CONTROLLER = "controller"
CONF_ZONES = "zones" 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

View file

@ -2,18 +2,21 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable import asyncio
from dataclasses import dataclass
import datetime import datetime
import logging import logging
from typing import TypeVar from typing import TypeVar
import async_timeout import async_timeout
from pyrainbird.async_client import RainbirdApiException from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
TIMEOUT_SECONDS = 20 from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS
UPDATE_INTERVAL = datetime.timedelta(minutes=1) UPDATE_INTERVAL = datetime.timedelta(minutes=1)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -21,27 +24,87 @@ _LOGGER = logging.getLogger(__name__)
_T = TypeVar("_T") _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.""" """Coordinator for rainbird API calls."""
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
update_method: Callable[[], Awaitable[_T]], name: str,
controller: AsyncRainbirdController,
serial_number: str,
) -> None: ) -> None:
"""Initialize ZoneStateUpdateCoordinator.""" """Initialize ZoneStateUpdateCoordinator."""
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name="Rainbird Zones", name=name,
update_method=update_method, update_method=self._async_update_data,
update_interval=UPDATE_INTERVAL, 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: @property
"""Fetch data from API endpoint.""" 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: try:
async with async_timeout.timeout(TIMEOUT_SECONDS): async with async_timeout.timeout(TIMEOUT_SECONDS):
return await self.update_method() # type: ignore[misc] return await self._fetch_data()
except RainbirdApiException as err: 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

View file

@ -1,6 +1,7 @@
{ {
"domain": "rainbird", "domain": "rainbird",
"name": "Rain Bird", "name": "Rain Bird",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rainbird", "documentation": "https://www.home-assistant.io/integrations/rainbird",
"requirements": ["pyrainbird==1.1.0"], "requirements": ["pyrainbird==1.1.0"],
"codeowners": ["@konikvranik", "@allenporter"], "codeowners": ["@konikvranik", "@allenporter"],

View file

@ -2,69 +2,58 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Union
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR from .const import DOMAIN
from .coordinator import RainbirdUpdateCoordinator from .coordinator import RainbirdUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( RAIN_DELAY_ENTITY_DESCRIPTION = SensorEntityDescription(
SensorEntityDescription( key="raindelay",
key=SENSOR_TYPE_RAINSENSOR, name="Raindelay",
name="Rainsensor", icon="mdi:water-off",
icon="mdi:water",
),
SensorEntityDescription(
key=SENSOR_TYPE_RAINDELAY,
name="Raindelay",
icon="mdi:water-off",
),
) )
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up a Rain Bird sensor.""" """Set up entry for a Rain Bird sensor."""
if discovery_info is None:
return
async_add_entities( async_add_entities(
[ [
RainBirdSensor(discovery_info[description.key], description) RainBirdSensor(
for description in SENSOR_TYPES hass.data[DOMAIN][config_entry.entry_id],
], RAIN_DELAY_ENTITY_DESCRIPTION,
True, )
]
) )
class RainBirdSensor( class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], SensorEntity):
CoordinatorEntity[RainbirdUpdateCoordinator[Union[int, bool]]], SensorEntity
):
"""A sensor implementation for Rain Bird device.""" """A sensor implementation for Rain Bird device."""
def __init__( def __init__(
self, self,
coordinator: RainbirdUpdateCoordinator[int | bool], coordinator: RainbirdUpdateCoordinator,
description: SensorEntityDescription, description: SensorEntityDescription,
) -> None: ) -> None:
"""Initialize the Rain Bird sensor.""" """Initialize the Rain Bird sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
self._attr_device_info = coordinator.device_info
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return the value reported by the sensor.""" """Return the value reported by the sensor."""
return self.coordinator.data return self.coordinator.data.rain_delay

View file

@ -1,15 +1,11 @@
start_irrigation: start_irrigation:
name: Start irrigation name: Start irrigation
description: Start the irrigation description: Start the irrigation
target:
entity:
integration: rainbird
domain: switch
fields: fields:
entity_id:
name: Entity
description: Name of a single irrigation to turn on
required: true
selector:
entity:
integration: rainbird
domain: switch
duration: duration:
name: Duration name: Duration
description: Duration for this sprinkler to be turned on description: Duration for this sprinkler to be turned on
@ -23,6 +19,13 @@ set_rain_delay:
name: Set rain delay name: Set rain delay
description: Set how long automatic irrigation is turned off. description: Set how long automatic irrigation is turned off.
fields: 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: duration:
name: Duration name: Duration
description: Duration for this system to be turned off. description: Duration for this system to be turned off.

View file

@ -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."
}
}
}

View file

@ -3,162 +3,100 @@ from __future__ import annotations
import logging import logging
from pyrainbird import AvailableStations
from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException
from pyrainbird.data import States
import voluptuous as vol import voluptuous as vol
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.const import ATTR_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_TRIGGER_TIME from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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
from .const import CONF_ZONES, DOMAIN, RAINBIRD_CONTROLLER from .const import ATTR_DURATION, CONF_IMPORTED_NAMES, DOMAIN, MANUFACTURER
from .coordinator import RainbirdUpdateCoordinator from .coordinator import RainbirdUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ATTR_DURATION = "duration"
SERVICE_START_IRRIGATION = "start_irrigation" SERVICE_START_IRRIGATION = "start_irrigation"
SERVICE_SET_RAIN_DELAY = "set_rain_delay"
SERVICE_SCHEMA_IRRIGATION = vol.Schema( SERVICE_SCHEMA_IRRIGATION = {
{ vol.Required(ATTR_DURATION): cv.positive_float,
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,
}
)
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up Rain Bird switches over a Rain Bird controller.""" """Set up entry for a Rain Bird irrigation switches."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
if discovery_info is None: async_add_entities(
return RainBirdSwitch(
coordinator,
controller: AsyncRainbirdController = discovery_info[RAINBIRD_CONTROLLER] zone,
try: config_entry.options[ATTR_DURATION],
available_stations: AvailableStations = ( config_entry.data.get(CONF_IMPORTED_NAMES, {}).get(str(zone)),
await controller.get_available_stations()
) )
except RainbirdApiException as err: for zone in coordinator.data.zones
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: platform = entity_platform.async_get_current_platform()
await coordinator.async_config_entry_first_refresh() platform.async_register_entity_service(
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,
SERVICE_START_IRRIGATION, SERVICE_START_IRRIGATION,
start_irrigation, SERVICE_SCHEMA_IRRIGATION,
schema=SERVICE_SCHEMA_IRRIGATION, "async_turn_on",
)
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,
) )
class RainBirdSwitch( class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity):
CoordinatorEntity[RainbirdUpdateCoordinator[States]], SwitchEntity
):
"""Representation of a Rain Bird switch.""" """Representation of a Rain Bird switch."""
def __init__( def __init__(
self, self,
coordinator: RainbirdUpdateCoordinator[States], coordinator: RainbirdUpdateCoordinator,
rainbird: AsyncRainbirdController,
zone: int, zone: int,
time: int, duration_minutes: int,
name: str, imported_name: str | None,
) -> None: ) -> None:
"""Initialize a Rain Bird Switch Device.""" """Initialize a Rain Bird Switch Device."""
super().__init__(coordinator) super().__init__(coordinator)
self._rainbird = rainbird
self._zone = zone 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._state = None
self._duration = time self._duration_minutes = duration_minutes
self._attributes = {ATTR_DURATION: self._duration, "zone": self._zone} 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 @property
def extra_state_attributes(self): def extra_state_attributes(self):
"""Return state attributes.""" """Return state attributes."""
return self._attributes return {"zone": self._zone}
@property
def name(self):
"""Get the name of the switch."""
return self._name
async def async_turn_on(self, **kwargs): async def async_turn_on(self, **kwargs):
"""Turn the switch on.""" """Turn the switch on."""
await self._rainbird.irrigate_zone( await self.coordinator.controller.irrigate_zone(
int(self._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() await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs): async def async_turn_off(self, **kwargs):
"""Turn the switch off.""" """Turn the switch off."""
await self._rainbird.stop_irrigation() await self.coordinator.controller.stop_irrigation()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
@property @property
def is_on(self): def is_on(self):
"""Return true if switch is on.""" """Return true if switch is on."""
return self.coordinator.data.active(self._zone) return self._zone in self.coordinator.data.active_zones

View file

@ -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"
}
}
}
}

View file

@ -333,6 +333,7 @@ FLOWS = {
"radarr", "radarr",
"radio_browser", "radio_browser",
"radiotherm", "radiotherm",
"rainbird",
"rainforest_eagle", "rainforest_eagle",
"rainmachine", "rainmachine",
"rdw", "rdw",

View file

@ -4336,7 +4336,7 @@
"rainbird": { "rainbird": {
"name": "Rain Bird", "name": "Rain Bird",
"integration_type": "hub", "integration_type": "hub",
"config_flow": false, "config_flow": true,
"iot_class": "local_polling" "iot_class": "local_polling"
}, },
"rainforest_eagle": { "rainforest_eagle": {

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable, Generator from collections.abc import Awaitable, Callable, Generator
from http import HTTPStatus
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import patch
@ -10,10 +11,15 @@ from pyrainbird import encryption
import pytest import pytest
from homeassistant.components.rainbird import DOMAIN 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.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse
ComponentSetup = Callable[[], Awaitable[bool]] ComponentSetup = Callable[[], Awaitable[bool]]
@ -21,6 +27,7 @@ ComponentSetup = Callable[[], Awaitable[bool]]
HOST = "example.com" HOST = "example.com"
URL = "http://example.com/stick" URL = "http://example.com/stick"
PASSWORD = "password" PASSWORD = "password"
SERIAL_NUMBER = 0x12635436566
# #
# Response payloads below come from pyrainbird test cases. # Response payloads below come from pyrainbird test cases.
@ -45,14 +52,28 @@ RAIN_DELAY_OFF = "B60000"
# ACK command 0x10, Echo 0x06 # ACK command 0x10, Echo 0x06
ACK_ECHO = "0106" ACK_ECHO = "0106"
CONFIG = { CONFIG = {
DOMAIN: { DOMAIN: {
"host": HOST, "host": HOST,
"password": PASSWORD, "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 @pytest.fixture
def platforms() -> list[Platform]: def platforms() -> list[Platform]:
@ -63,7 +84,37 @@ def platforms() -> list[Platform]:
@pytest.fixture @pytest.fixture
def yaml_config() -> dict[str, Any]: def yaml_config() -> dict[str, Any]:
"""Fixture for configuration.yaml.""" """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 @pytest.fixture
@ -97,10 +148,48 @@ def mock_response(data: str) -> AiohttpClientMockResponse:
return AiohttpClientMockResponse("POST", URL, response=rainbird_response(data)) 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") @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.""" """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) @pytest.fixture(autouse=True)

View file

@ -6,14 +6,7 @@ import pytest
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .conftest import ( from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, ComponentSetup
RAIN_DELAY,
RAIN_DELAY_OFF,
RAIN_SENSOR_OFF,
RAIN_SENSOR_ON,
ComponentSetup,
mock_response,
)
from tests.test_util.aiohttp import AiohttpClientMockResponse from tests.test_util.aiohttp import AiohttpClientMockResponse
@ -25,54 +18,23 @@ def platforms() -> list[Platform]:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"sensor_payload,expected_state", "rain_response,expected_state",
[(RAIN_SENSOR_OFF, "off"), (RAIN_SENSOR_ON, "on")], [(RAIN_SENSOR_OFF, "off"), (RAIN_SENSOR_ON, "on")],
) )
async def test_rainsensor( async def test_rainsensor(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: ComponentSetup, setup_integration: ComponentSetup,
responses: list[AiohttpClientMockResponse], responses: list[AiohttpClientMockResponse],
sensor_payload: str,
expected_state: bool, expected_state: bool,
) -> None: ) -> None:
"""Test rainsensor binary sensor.""" """Test rainsensor binary sensor."""
responses.extend(
[
mock_response(sensor_payload),
mock_response(RAIN_DELAY),
]
)
assert await setup_integration() assert await setup_integration()
rainsensor = hass.states.get("binary_sensor.rainsensor") rainsensor = hass.states.get("binary_sensor.rainsensor")
assert rainsensor is not None assert rainsensor is not None
assert rainsensor.state == expected_state assert rainsensor.state == expected_state
assert rainsensor.attributes == {
"friendly_name": "Rainsensor",
@pytest.mark.parametrize( "icon": "mdi:water",
"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

View file

@ -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,
}

View file

@ -1,34 +1,155 @@
"""Tests for rainbird initialization.""" """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.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 from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse
async def test_setup_success( @pytest.mark.parametrize(
hass: HomeAssistant, "yaml_config,config_entry_data,initial_response",
setup_integration: ComponentSetup, [
) -> None: ({}, CONFIG_ENTRY_DATA, None),
"""Test successful setup and unload.""" (
CONFIG,
assert await setup_integration() None,
mock_response(SERIAL_RESPONSE), # Extra import request
),
async def test_setup_communication_failure( (
CONFIG,
CONFIG_ENTRY_DATA,
None,
),
],
ids=["config_entry", "yaml", "already_exists"],
)
async def test_init_success(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: ComponentSetup, setup_integration: ComponentSetup,
responses: list[AiohttpClientMockResponse], 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: ) -> None:
"""Test unable to talk to server on startup, which permanently fails setup.""" """Test unable to talk to server on startup, which permanently fails setup."""
responses.clear() assert await setup_integration()
responses.append(
AiohttpClientMockResponse("POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE) 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

View file

@ -6,15 +6,7 @@ import pytest
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .conftest import ( from .conftest import RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup
RAIN_DELAY,
RAIN_SENSOR_OFF,
RAIN_SENSOR_ON,
ComponentSetup,
mock_response,
)
from tests.test_util.aiohttp import AiohttpClientMockResponse
@pytest.fixture @pytest.fixture
@ -24,26 +16,22 @@ def platforms() -> list[str]:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"sensor_payload,expected_state", "rain_delay_response,expected_state",
[(RAIN_SENSOR_OFF, "False"), (RAIN_SENSOR_ON, "True")], [(RAIN_DELAY, "16"), (RAIN_DELAY_OFF, "0")],
) )
async def test_sensors( async def test_sensors(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: ComponentSetup, setup_integration: ComponentSetup,
responses: list[AiohttpClientMockResponse], expected_state: str,
sensor_payload: str,
expected_state: bool,
) -> None: ) -> None:
"""Test sensor platform.""" """Test sensor platform."""
responses.extend([mock_response(sensor_payload), mock_response(RAIN_DELAY)])
assert await setup_integration() 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") raindelay = hass.states.get("sensor.raindelay")
assert raindelay is not None assert raindelay is not None
assert raindelay.state == "16" assert raindelay.state == expected_state
assert raindelay.attributes == {
"friendly_name": "Raindelay",
"icon": "mdi:water-off",
}

View file

@ -1,9 +1,6 @@
"""Tests for rainbird sensor platform.""" """Tests for rainbird sensor platform."""
from http import HTTPStatus
import logging
import pytest import pytest
from homeassistant.components.rainbird import DOMAIN from homeassistant.components.rainbird import DOMAIN
@ -12,11 +9,12 @@ from homeassistant.core import HomeAssistant
from .conftest import ( from .conftest import (
ACK_ECHO, ACK_ECHO,
AVAILABLE_STATIONS_RESPONSE,
EMPTY_STATIONS_RESPONSE, EMPTY_STATIONS_RESPONSE,
HOST, HOST,
PASSWORD, PASSWORD,
URL, RAIN_DELAY_OFF,
RAIN_SENSOR_OFF,
SERIAL_RESPONSE,
ZONE_3_ON_RESPONSE, ZONE_3_ON_RESPONSE,
ZONE_5_ON_RESPONSE, ZONE_5_ON_RESPONSE,
ZONE_OFF_RESPONSE, ZONE_OFF_RESPONSE,
@ -34,20 +32,26 @@ def platforms() -> list[str]:
return [Platform.SWITCH] return [Platform.SWITCH]
@pytest.mark.parametrize(
"stations_response",
[EMPTY_STATIONS_RESPONSE],
)
async def test_no_zones( async def test_no_zones(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: ComponentSetup, setup_integration: ComponentSetup,
responses: list[AiohttpClientMockResponse],
) -> None: ) -> None:
"""Test case where listing stations returns no stations.""" """Test case where listing stations returns no stations."""
responses.append(mock_response(EMPTY_STATIONS_RESPONSE))
assert await setup_integration() assert await setup_integration()
zone = hass.states.get("switch.sprinkler_1") zone = hass.states.get("switch.rain_bird_sprinkler_1")
assert zone is None assert zone is None
@pytest.mark.parametrize(
"zone_state_response",
[ZONE_5_ON_RESPONSE],
)
async def test_zones( async def test_zones(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: ComponentSetup, setup_integration: ComponentSetup,
@ -55,41 +59,45 @@ async def test_zones(
) -> None: ) -> None:
"""Test switch platform with fake data that creates 7 zones with one enabled.""" """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() 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 is not None
assert zone.state == "off" 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 is not None
assert zone.state == "off" assert zone.state == "off"
zone = hass.states.get("switch.sprinkler_3") zone = hass.states.get("switch.rain_bird_sprinkler_5")
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")
assert zone is not None assert zone is not None
assert zone.state == "on" 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 is not None
assert zone.state == "off" 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 is not None
assert zone.state == "off" 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( async def test_switch_on(
@ -100,14 +108,11 @@ async def test_switch_on(
) -> None: ) -> None:
"""Test turning on irrigation switch.""" """Test turning on irrigation switch."""
responses.extend(
[mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_OFF_RESPONSE)]
)
assert await setup_integration() assert await setup_integration()
# Initially all zones are off. Pick zone3 as an arbitrary to assert # Initially all zones are off. Pick zone3 as an arbitrary to assert
# state, then update below as a switch. # 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 is not None
assert zone.state == "off" assert zone.state == "off"
@ -115,20 +120,25 @@ async def test_switch_on(
responses.extend( responses.extend(
[ [
mock_response(ACK_ECHO), # Switch on response 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() await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 2
aioclient_mock.mock_calls.clear()
# Verify switch state is updated # 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 is not None
assert zone.state == "on" assert zone.state == "on"
@pytest.mark.parametrize(
"zone_state_response",
[ZONE_3_ON_RESPONSE],
)
async def test_switch_off( async def test_switch_off(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: ComponentSetup, setup_integration: ComponentSetup,
@ -137,13 +147,10 @@ async def test_switch_off(
) -> None: ) -> None:
"""Test turning off irrigation switch.""" """Test turning off irrigation switch."""
responses.extend(
[mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_3_ON_RESPONSE)]
)
assert await setup_integration() assert await setup_integration()
# Initially the test zone is on # 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 is not None
assert zone.state == "on" assert zone.state == "on"
@ -152,16 +159,15 @@ async def test_switch_off(
[ [
mock_response(ACK_ECHO), # Switch off response mock_response(ACK_ECHO), # Switch off response
mock_response(ZONE_OFF_RESPONSE), # Updated zone state 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() 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 # 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 is not None
assert zone.state == "off" assert zone.state == "off"
@ -171,114 +177,60 @@ async def test_irrigation_service(
setup_integration: ComponentSetup, setup_integration: ComponentSetup,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
responses: list[AiohttpClientMockResponse], responses: list[AiohttpClientMockResponse],
api_responses: list[str],
) -> None: ) -> None:
"""Test calling the irrigation service.""" """Test calling the irrigation service."""
responses.extend(
[mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_3_ON_RESPONSE)]
)
assert await setup_integration() assert await setup_integration()
aioclient_mock.mock_calls.clear() zone = hass.states.get("switch.rain_bird_sprinkler_3")
responses.extend([mock_response(ACK_ECHO), mock_response(ZONE_OFF_RESPONSE)]) assert zone is not None
assert zone.state == "off"
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()
aioclient_mock.mock_calls.clear() aioclient_mock.mock_calls.clear()
responses.extend( responses.extend(
[ [
mock_response(ACK_ECHO), 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( 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 zone = hass.states.get("switch.rain_bird_sprinkler_3")
assert zone is not None
assert zone.state == "on"
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
@pytest.mark.parametrize( @pytest.mark.parametrize(
"yaml_config", "yaml_config,config_entry_data",
[ [
{ (
DOMAIN: { {
"host": HOST, DOMAIN: {
"password": PASSWORD, "host": HOST,
"trigger_time": 360, "password": PASSWORD,
"zones": { "trigger_time": 360,
1: { "zones": {
"friendly_name": "Garden Sprinkler", 1: {
"friendly_name": "Garden Sprinkler",
},
2: {
"friendly_name": "Back Yard",
},
}, },
2: { }
"friendly_name": "Back Yard", },
}, None,
}, )
}
},
], ],
) )
async def test_yaml_config( async def test_yaml_config(
@ -287,15 +239,11 @@ async def test_yaml_config(
responses: list[AiohttpClientMockResponse], responses: list[AiohttpClientMockResponse],
) -> None: ) -> None:
"""Test switch platform with fake data that creates 7 zones with one enabled.""" """Test switch platform with fake data that creates 7 zones with one enabled."""
responses.insert(0, mock_response(SERIAL_RESPONSE)) # Extra import request
responses.extend(
[mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_5_ON_RESPONSE)]
)
assert await setup_integration() assert await setup_integration()
assert hass.states.get("switch.garden_sprinkler") 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 hass.states.get("switch.back_yard")
assert not hass.states.get("switch.sprinkler_2") assert not hass.states.get("switch.rain_bird_sprinkler_2")
assert hass.states.get("switch.sprinkler_3") assert hass.states.get("switch.rain_bird_sprinkler_3")