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:
parent
e3e64c103d
commit
5000c426c6
19 changed files with 1016 additions and 471 deletions
|
@ -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(
|
|
||||||
*[
|
|
||||||
_setup_controller(hass, controller_config, config)
|
|
||||||
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(
|
|
||||||
discovery.async_load_platform(
|
|
||||||
hass,
|
|
||||||
platform,
|
|
||||||
DOMAIN,
|
|
||||||
{
|
|
||||||
RAINBIRD_CONTROLLER: controller,
|
|
||||||
SENSOR_TYPE_RAINSENSOR: rain_coordinator,
|
|
||||||
SENSOR_TYPE_RAINDELAY: delay_coordinator,
|
|
||||||
**controller_config,
|
|
||||||
},
|
|
||||||
config,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
for controller_config in config[DOMAIN]:
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_IMPORT},
|
||||||
|
data=controller_config,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async_create_issue(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
"deprecated_yaml",
|
||||||
|
breaks_in_ha_version="2023.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
|
||||||
|
|
|
@ -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
|
||||||
|
|
194
homeassistant/components/rainbird/config_flow.py
Normal file
194
homeassistant/components/rainbird/config_flow.py
Normal 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,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"],
|
||||||
|
|
|
@ -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="Rainsensor",
|
|
||||||
icon="mdi:water",
|
|
||||||
),
|
|
||||||
SensorEntityDescription(
|
|
||||||
key=SENSOR_TYPE_RAINDELAY,
|
|
||||||
name="Raindelay",
|
name="Raindelay",
|
||||||
icon="mdi:water-off",
|
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
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
start_irrigation:
|
start_irrigation:
|
||||||
name: Start irrigation
|
name: Start irrigation
|
||||||
description: Start the irrigation
|
description: Start the irrigation
|
||||||
fields:
|
target:
|
||||||
entity_id:
|
|
||||||
name: Entity
|
|
||||||
description: Name of a single irrigation to turn on
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
entity:
|
entity:
|
||||||
integration: rainbird
|
integration: rainbird
|
||||||
domain: switch
|
domain: switch
|
||||||
|
fields:
|
||||||
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.
|
||||||
|
|
34
homeassistant/components/rainbird/strings.json
Normal file
34
homeassistant/components/rainbird/strings.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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_ENTITY_ID): cv.entity_id,
|
|
||||||
vol.Required(ATTR_DURATION): cv.positive_float,
|
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
|
|
||||||
|
|
||||||
controller: AsyncRainbirdController = discovery_info[RAINBIRD_CONTROLLER]
|
|
||||||
try:
|
|
||||||
available_stations: AvailableStations = (
|
|
||||||
await controller.get_available_stations()
|
|
||||||
)
|
|
||||||
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(
|
RainBirdSwitch(
|
||||||
coordinator,
|
coordinator,
|
||||||
controller,
|
|
||||||
zone,
|
zone,
|
||||||
time,
|
config_entry.options[ATTR_DURATION],
|
||||||
name if name else f"Sprinkler {zone}",
|
config_entry.data.get(CONF_IMPORTED_NAMES, {}).get(str(zone)),
|
||||||
)
|
)
|
||||||
|
for zone in coordinator.data.zones
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
34
homeassistant/components/rainbird/translations/en.json
Normal file
34
homeassistant/components/rainbird/translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -333,6 +333,7 @@ FLOWS = {
|
||||||
"radarr",
|
"radarr",
|
||||||
"radio_browser",
|
"radio_browser",
|
||||||
"radiotherm",
|
"radiotherm",
|
||||||
|
"rainbird",
|
||||||
"rainforest_eagle",
|
"rainforest_eagle",
|
||||||
"rainmachine",
|
"rainmachine",
|
||||||
"rdw",
|
"rdw",
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
149
tests/components/rainbird/test_config_flow.py
Normal file
149
tests/components/rainbird/test_config_flow.py
Normal 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,
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
|
@ -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,99 +177,43 @@ 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: {
|
DOMAIN: {
|
||||||
"host": HOST,
|
"host": HOST,
|
||||||
|
@ -279,6 +229,8 @@ async def test_coordinator_unavailable(
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
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")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue