diff --git a/.coveragerc b/.coveragerc index 9a3ee5b0af8..06e0b61d9d1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1033,7 +1033,6 @@ omit = homeassistant/components/radiotherm/entity.py homeassistant/components/radiotherm/switch.py homeassistant/components/radiotherm/util.py - homeassistant/components/rainbird/* homeassistant/components/raincloud/* homeassistant/components/rainmachine/__init__.py homeassistant/components/rainmachine/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1eee8b2ebec..398201ff637 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -926,6 +926,7 @@ build.json @home-assistant/supervisor /homeassistant/components/radiotherm/ @bdraco @vinnyfuria /tests/components/radiotherm/ @bdraco @vinnyfuria /homeassistant/components/rainbird/ @konikvranik @allenporter +/tests/components/rainbird/ @konikvranik @allenporter /homeassistant/components/raincloud/ @vanstinator /homeassistant/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin /tests/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index d36cf7786ac..fe29c48ed0d 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,13 +1,16 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations +import asyncio import logging -from pyrainbird import RainbirdController +from pyrainbird.async_client import ( + AsyncRainbirdClient, + AsyncRainbirdController, + RainbirdApiException, +) import voluptuous as vol -from homeassistant.components.binary_sensor import BinarySensorEntityDescription -from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_HOST, @@ -17,49 +20,25 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -CONF_ZONES = "zones" +from .const import ( + CONF_ZONES, + RAINBIRD_CONTROLLER, + SENSOR_TYPE_RAINDELAY, + SENSOR_TYPE_RAINSENSOR, +) +from .coordinator import RainbirdUpdateCoordinator PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR] _LOGGER = logging.getLogger(__name__) -RAINBIRD_CONTROLLER = "controller" DATA_RAINBIRD = "rainbird" DOMAIN = "rainbird" -SENSOR_TYPE_RAINDELAY = "raindelay" -SENSOR_TYPE_RAINSENSOR = "rainsensor" - - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key=SENSOR_TYPE_RAINSENSOR, - name="Rainsensor", - icon="mdi:water", - ), - SensorEntityDescription( - key=SENSOR_TYPE_RAINDELAY, - name="Raindelay", - icon="mdi:water-off", - ), -) - -BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( - key=SENSOR_TYPE_RAINSENSOR, - name="Rainsensor", - icon="mdi:water", - ), - BinarySensorEntityDescription( - key=SENSOR_TYPE_RAINDELAY, - name="Raindelay", - icon="mdi:water-off", - ), -) - TRIGGER_TIME_SCHEMA = vol.All( cv.time_period, cv.positive_timedelta, lambda td: (td.total_seconds() // 60) ) @@ -84,36 +63,46 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Rain Bird component.""" hass.data[DATA_RAINBIRD] = [] - success = False + + tasks = [] for controller_config in config[DOMAIN]: - success = success or _setup_controller(hass, controller_config, config) - - return success + tasks.append(_setup_controller(hass, controller_config, config)) + return all(await asyncio.gather(*tasks)) -def _setup_controller(hass, controller_config, config): +async def _setup_controller(hass, controller_config, config): """Set up a controller.""" server = controller_config[CONF_HOST] password = controller_config[CONF_PASSWORD] - controller = RainbirdController(server, password) + client = AsyncRainbirdClient(async_get_clientsession(hass), server, password) + controller = AsyncRainbirdController(client) position = len(hass.data[DATA_RAINBIRD]) try: - controller.get_serial_number() - except Exception as exc: # pylint: disable=broad-except + await controller.get_serial_number() + except RainbirdApiException as exc: _LOGGER.error("Unable to setup controller: %s", exc) return False hass.data[DATA_RAINBIRD].append(controller) + + rain_coordinator = RainbirdUpdateCoordinator(hass, controller.get_rain_sensor_state) + delay_coordinator = RainbirdUpdateCoordinator(hass, controller.get_rain_delay) + _LOGGER.debug("Rain Bird Controller %d set to: %s", position, server) for platform in PLATFORMS: discovery.load_platform( hass, platform, DOMAIN, - {RAINBIRD_CONTROLLER: position, **controller_config}, + { + RAINBIRD_CONTROLLER: controller, + SENSOR_TYPE_RAINSENSOR: rain_coordinator, + SENSOR_TYPE_RAINDELAY: delay_coordinator, + **controller_config, + }, config, ) return True diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 9b7a3f89da5..c10b6a43d3f 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -3,8 +3,6 @@ from __future__ import annotations import logging -from pyrainbird import RainbirdController - from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, @@ -12,56 +10,62 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from . import ( - BINARY_SENSOR_TYPES, - DATA_RAINBIRD, - RAINBIRD_CONTROLLER, - SENSOR_TYPE_RAINDELAY, - SENSOR_TYPE_RAINSENSOR, +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, ) +from .const import SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR + _LOGGER = logging.getLogger(__name__) -def setup_platform( +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key=SENSOR_TYPE_RAINSENSOR, + name="Rainsensor", + icon="mdi:water", + ), + BinarySensorEntityDescription( + key=SENSOR_TYPE_RAINDELAY, + name="Raindelay", + icon="mdi:water-off", + ), +) + + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a Rain Bird sensor.""" if discovery_info is None: return - controller = hass.data[DATA_RAINBIRD][discovery_info[RAINBIRD_CONTROLLER]] - add_entities( + async_add_entities( [ - RainBirdSensor(controller, description) + RainBirdSensor(discovery_info[description.key], description) for description in BINARY_SENSOR_TYPES ], True, ) -class RainBirdSensor(BinarySensorEntity): +class RainBirdSensor(CoordinatorEntity, BinarySensorEntity): """A sensor implementation for Rain Bird device.""" def __init__( self, - controller: RainbirdController, + coordinator: DataUpdateCoordinator, description: BinarySensorEntityDescription, ) -> None: """Initialize the Rain Bird sensor.""" + super().__init__(coordinator) self.entity_description = description - self._controller = controller - def update(self) -> None: - """Get the latest data and updates the states.""" - _LOGGER.debug("Updating sensor: %s", self.name) - state = None - if self.entity_description.key == SENSOR_TYPE_RAINSENSOR: - state = self._controller.get_rain_sensor_state() - elif self.entity_description.key == SENSOR_TYPE_RAINDELAY: - state = self._controller.get_rain_delay() - self._attr_is_on = None if state is None else bool(state) + @property + def is_on(self) -> bool | None: + """Return True if entity is on.""" + return None if self.coordinator.data is None else bool(self.coordinator.data) diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py new file mode 100644 index 00000000000..be06fdb8224 --- /dev/null +++ b/homeassistant/components/rainbird/const.py @@ -0,0 +1,10 @@ +"""Constants for rainbird.""" + +DOMAIN = "rainbird" + +SENSOR_TYPE_RAINDELAY = "raindelay" +SENSOR_TYPE_RAINSENSOR = "rainsensor" + +RAINBIRD_CONTROLLER = "controller" + +CONF_ZONES = "zones" diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py new file mode 100644 index 00000000000..ee6857fe93c --- /dev/null +++ b/homeassistant/components/rainbird/coordinator.py @@ -0,0 +1,47 @@ +"""Update coordinators for rainbird.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +import datetime +import logging +from typing import TypeVar + +import async_timeout +from pyrainbird.async_client import RainbirdApiException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +TIMEOUT_SECONDS = 20 +UPDATE_INTERVAL = datetime.timedelta(minutes=1) + +_LOGGER = logging.getLogger(__name__) + +_T = TypeVar("_T") + + +class RainbirdUpdateCoordinator(DataUpdateCoordinator[_T]): + """Coordinator for rainbird API calls.""" + + def __init__( + self, + hass: HomeAssistant, + update_method: Callable[[], Awaitable[_T]], + ) -> None: + """Initialize ZoneStateUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name="Rainbird Zones", + update_method=update_method, + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> _T: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(TIMEOUT_SECONDS): + return await self.update_method() # type: ignore[misc] + except RainbirdApiException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index a3e431418f8..7be91b15ed4 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -3,28 +3,38 @@ from __future__ import annotations import logging -from pyrainbird import RainbirdController - from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from . import ( - DATA_RAINBIRD, - RAINBIRD_CONTROLLER, - SENSOR_TYPE_RAINDELAY, - SENSOR_TYPE_RAINSENSOR, - SENSOR_TYPES, +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, ) +from .const import SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR + _LOGGER = logging.getLogger(__name__) -def setup_platform( +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TYPE_RAINSENSOR, + name="Rainsensor", + icon="mdi:water", + ), + SensorEntityDescription( + key=SENSOR_TYPE_RAINDELAY, + name="Raindelay", + icon="mdi:water-off", + ), +) + + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a Rain Bird sensor.""" @@ -32,29 +42,28 @@ def setup_platform( if discovery_info is None: return - controller = hass.data[DATA_RAINBIRD][discovery_info[RAINBIRD_CONTROLLER]] - add_entities( - [RainBirdSensor(controller, description) for description in SENSOR_TYPES], + async_add_entities( + [ + RainBirdSensor(discovery_info[description.key], description) + for description in SENSOR_TYPES + ], True, ) -class RainBirdSensor(SensorEntity): +class RainBirdSensor(CoordinatorEntity, SensorEntity): """A sensor implementation for Rain Bird device.""" def __init__( self, - controller: RainbirdController, + coordinator: DataUpdateCoordinator, description: SensorEntityDescription, ) -> None: """Initialize the Rain Bird sensor.""" + super().__init__(coordinator) self.entity_description = description - self._controller = controller - def update(self) -> None: - """Get the latest data and updates the states.""" - _LOGGER.debug("Updating sensor: %s", self.name) - if self.entity_description.key == SENSOR_TYPE_RAINSENSOR: - self._attr_native_value = self._controller.get_rain_sensor_state() - elif self.entity_description.key == SENSOR_TYPE_RAINDELAY: - self._attr_native_value = self._controller.get_rain_delay() + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index f18e42b058f..50177c9a28c 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -1,17 +1,26 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations -from pyrainbird import AvailableStations, RainbirdController +import logging + +from pyrainbird import AvailableStations +from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException +from pyrainbird.data import States import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.const import ATTR_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_TRIGGER_TIME from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import CONF_ZONES, DATA_RAINBIRD, DOMAIN, RAINBIRD_CONTROLLER +from .const import CONF_ZONES, DOMAIN, RAINBIRD_CONTROLLER +from .coordinator import RainbirdUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) ATTR_DURATION = "duration" @@ -32,10 +41,10 @@ SERVICE_SCHEMA_RAIN_DELAY = vol.Schema( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up Rain Bird switches over a Rain Bird controller.""" @@ -43,12 +52,16 @@ def setup_platform( if discovery_info is None: return - controller: RainbirdController = hass.data[DATA_RAINBIRD][ - discovery_info[RAINBIRD_CONTROLLER] - ] - available_stations: AvailableStations = controller.get_available_stations() + 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): @@ -57,6 +70,7 @@ def setup_platform( name = zone_config.get(CONF_FRIENDLY_NAME) devices.append( RainBirdSwitch( + coordinator, controller, zone, time, @@ -64,29 +78,34 @@ def setup_platform( ) ) - add_entities(devices, True) + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady as err: + raise PlatformNotReady(f"Failed to load zone state: {str(err)}") from err - def start_irrigation(service: ServiceCall) -> None: + 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: - device.turn_on(duration=duration) + await device.async_turn_on(duration=duration) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_START_IRRIGATION, start_irrigation, schema=SERVICE_SCHEMA_IRRIGATION, ) - def set_rain_delay(service: ServiceCall) -> None: + async def set_rain_delay(service: ServiceCall) -> None: duration = service.data[ATTR_DURATION] - controller.set_rain_delay(duration) + await controller.set_rain_delay(duration) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SET_RAIN_DELAY, set_rain_delay, @@ -94,12 +113,20 @@ def setup_platform( ) -class RainBirdSwitch(SwitchEntity): +class RainBirdSwitch(CoordinatorEntity, SwitchEntity): """Representation of a Rain Bird switch.""" - def __init__(self, controller: RainbirdController, zone, time, name): + def __init__( + self, + coordinator: RainbirdUpdateCoordinator[States], + rainbird: AsyncRainbirdController, + zone: int, + time: int, + name: str, + ) -> None: """Initialize a Rain Bird Switch Device.""" - self._rainbird = controller + super().__init__(coordinator) + self._rainbird = rainbird self._zone = zone self._name = name self._state = None @@ -116,24 +143,20 @@ class RainBirdSwitch(SwitchEntity): """Get the name of the switch.""" return self._name - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the switch on.""" - if self._rainbird.irrigate_zone( + await self._rainbird.irrigate_zone( int(self._zone), int(kwargs[ATTR_DURATION] if ATTR_DURATION in kwargs else self._duration), - ): - self._state = True + ) + await self.coordinator.async_request_refresh() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the switch off.""" - if self._rainbird.stop_irrigation(): - self._state = False - - def update(self): - """Update switch status.""" - self._state = self._rainbird.get_zone_state(self._zone) + await self._rainbird.stop_irrigation() + await self.coordinator.async_request_refresh() @property def is_on(self): """Return true if switch is on.""" - return self._state + return self.coordinator.data.active(self._zone) diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1808542208f..6b1f4df67c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1329,6 +1329,9 @@ pyps4-2ndscreen==1.3.1 # homeassistant.components.qwikswitch pyqwikswitch==0.93 +# homeassistant.components.rainbird +pyrainbird==0.7.1 + # homeassistant.components.risco pyrisco==0.5.7 diff --git a/tests/components/rainbird/__init__.py b/tests/components/rainbird/__init__.py new file mode 100644 index 00000000000..f2d3f91e098 --- /dev/null +++ b/tests/components/rainbird/__init__.py @@ -0,0 +1 @@ +"""Tests for the rainbird integration.""" diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py new file mode 100644 index 00000000000..660307f1c60 --- /dev/null +++ b/tests/components/rainbird/conftest.py @@ -0,0 +1,116 @@ +"""Test fixtures for rainbird.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Generator +from typing import Any +from unittest.mock import patch + +from pyrainbird import encryption +import pytest + +from homeassistant.components.rainbird import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse + +ComponentSetup = Callable[[], Awaitable[bool]] + +HOST = "example.com" +URL = "http://example.com/stick" +PASSWORD = "password" + +# +# Response payloads below come from pyrainbird test cases. +# + +# Get serial number Command 0x85. Serial is 0x12635436566 +SERIAL_RESPONSE = "850000012635436566" +# Get available stations command 0x83 +AVAILABLE_STATIONS_RESPONSE = "83017F000000" # Mask for 7 zones +EMPTY_STATIONS_RESPONSE = "830000000000" +# Get zone state command 0xBF. +ZONE_3_ON_RESPONSE = "BF0004000000" # Zone 3 is on +ZONE_5_ON_RESPONSE = "BF0010000000" # Zone 5 is on +ZONE_OFF_RESPONSE = "BF0000000000" # All zones off +ZONE_STATE_OFF_RESPONSE = "BF0000000000" +# Get rain sensor state command 0XBE +RAIN_SENSOR_OFF = "BE00" +RAIN_SENSOR_ON = "BE01" +# Get rain delay command 0xB6 +RAIN_DELAY = "B60010" # 0x10 is 16 +RAIN_DELAY_OFF = "B60000" +# ACK command 0x10, Echo 0x06 +ACK_ECHO = "0106" + +CONFIG = { + DOMAIN: { + "host": HOST, + "password": PASSWORD, + "trigger_time": 360, + } +} + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture +def yaml_config() -> dict[str, Any]: + """Fixture for configuration.yaml.""" + return CONFIG + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + platforms: list[str], + yaml_config: dict[str, Any], +) -> Generator[ComponentSetup, None, None]: + """Fixture for setting up the component.""" + + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + + async def func() -> bool: + result = await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + return result + + yield func + + +def rainbird_response(data: str) -> bytes: + """Create a fake API response.""" + return encryption.encrypt( + '{"jsonrpc": "2.0", "result": {"data":"%s"}, "id": 1} ' % data, + PASSWORD, + ) + + +def mock_response(data: str) -> AiohttpClientMockResponse: + """Create a fake AiohttpClientMockResponse.""" + return AiohttpClientMockResponse("POST", URL, response=rainbird_response(data)) + + +@pytest.fixture(name="responses") +def mock_responses() -> list[AiohttpClientMockResponse]: + """Fixture to set up a list of fake API responsees for tests to extend.""" + return [mock_response(SERIAL_RESPONSE)] + + +@pytest.fixture(autouse=True) +def handle_responses( + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], +) -> None: + """Fixture for command mocking for fake responses to the API url.""" + + async def handle(method, url, data) -> AiohttpClientMockResponse: + return responses.pop(0) + + aioclient_mock.post(URL, side_effect=handle) diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py new file mode 100644 index 00000000000..7ed6f2d1a29 --- /dev/null +++ b/tests/components/rainbird/test_binary_sensor.py @@ -0,0 +1,78 @@ +"""Tests for rainbird sensor platform.""" + + +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import ( + RAIN_DELAY, + RAIN_DELAY_OFF, + RAIN_SENSOR_OFF, + RAIN_SENSOR_ON, + ComponentSetup, + mock_response, +) + +from tests.test_util.aiohttp import AiohttpClientMockResponse + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.BINARY_SENSOR] + + +@pytest.mark.parametrize( + "sensor_payload,expected_state", + [(RAIN_SENSOR_OFF, "off"), (RAIN_SENSOR_ON, "on")], +) +async def test_rainsensor( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], + sensor_payload: str, + expected_state: bool, +) -> None: + """Test rainsensor binary sensor.""" + + responses.extend( + [ + mock_response(sensor_payload), + mock_response(RAIN_DELAY), + ] + ) + + assert await setup_integration() + + rainsensor = hass.states.get("binary_sensor.rainsensor") + assert rainsensor is not None + assert rainsensor.state == expected_state + + +@pytest.mark.parametrize( + "sensor_payload,expected_state", + [(RAIN_DELAY_OFF, "off"), (RAIN_DELAY, "on")], +) +async def test_raindelay( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], + sensor_payload: str, + expected_state: bool, +) -> None: + """Test raindelay binary sensor.""" + + responses.extend( + [ + mock_response(RAIN_SENSOR_OFF), + mock_response(sensor_payload), + ] + ) + + assert await setup_integration() + + raindelay = hass.states.get("binary_sensor.raindelay") + assert raindelay is not None + assert raindelay.state == expected_state diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py new file mode 100644 index 00000000000..acf6a92d4a5 --- /dev/null +++ b/tests/components/rainbird/test_init.py @@ -0,0 +1,34 @@ +"""Tests for rainbird initialization.""" + +from http import HTTPStatus + +from homeassistant.core import HomeAssistant + +from .conftest import URL, ComponentSetup + +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse + + +async def test_setup_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test successful setup and unload.""" + + assert await setup_integration() + + +async def test_setup_communication_failure( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test unable to talk to server on startup, which permanently fails setup.""" + + responses.clear() + responses.append( + AiohttpClientMockResponse("POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE) + ) + + assert not await setup_integration() diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py new file mode 100644 index 00000000000..b80e014b236 --- /dev/null +++ b/tests/components/rainbird/test_sensor.py @@ -0,0 +1,49 @@ +"""Tests for rainbird sensor platform.""" + + +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import ( + RAIN_DELAY, + RAIN_SENSOR_OFF, + RAIN_SENSOR_ON, + ComponentSetup, + mock_response, +) + +from tests.test_util.aiohttp import AiohttpClientMockResponse + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + +@pytest.mark.parametrize( + "sensor_payload,expected_state", + [(RAIN_SENSOR_OFF, "False"), (RAIN_SENSOR_ON, "True")], +) +async def test_sensors( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], + sensor_payload: str, + expected_state: bool, +) -> None: + """Test sensor platform.""" + + responses.extend([mock_response(sensor_payload), mock_response(RAIN_DELAY)]) + + assert await setup_integration() + + rainsensor = hass.states.get("sensor.rainsensor") + assert rainsensor is not None + assert rainsensor.state == expected_state + + raindelay = hass.states.get("sensor.raindelay") + assert raindelay is not None + assert raindelay.state == "16" diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py new file mode 100644 index 00000000000..d6e89c58527 --- /dev/null +++ b/tests/components/rainbird/test_switch.py @@ -0,0 +1,301 @@ +"""Tests for rainbird sensor platform.""" + + +from http import HTTPStatus +import logging + +import pytest + +from homeassistant.components.rainbird import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant + +from .conftest import ( + ACK_ECHO, + AVAILABLE_STATIONS_RESPONSE, + EMPTY_STATIONS_RESPONSE, + HOST, + PASSWORD, + URL, + ZONE_3_ON_RESPONSE, + ZONE_5_ON_RESPONSE, + ZONE_OFF_RESPONSE, + ComponentSetup, + mock_response, +) + +from tests.components.switch import common as switch_common +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SWITCH] + + +async def test_no_zones( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], +) -> None: + """Test case where listing stations returns no stations.""" + + responses.append(mock_response(EMPTY_STATIONS_RESPONSE)) + assert await setup_integration() + + zone = hass.states.get("switch.sprinkler_1") + assert zone is None + + +async def test_zones( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], +) -> None: + """Test switch platform with fake data that creates 7 zones with one enabled.""" + + responses.extend( + [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_5_ON_RESPONSE)] + ) + + assert await setup_integration() + + zone = hass.states.get("switch.sprinkler_1") + assert zone is not None + assert zone.state == "off" + + zone = hass.states.get("switch.sprinkler_2") + assert zone is not None + assert zone.state == "off" + + zone = hass.states.get("switch.sprinkler_3") + assert zone is not None + assert zone.state == "off" + + zone = hass.states.get("switch.sprinkler_4") + assert zone is not None + assert zone.state == "off" + + zone = hass.states.get("switch.sprinkler_5") + assert zone is not None + assert zone.state == "on" + + zone = hass.states.get("switch.sprinkler_6") + assert zone is not None + assert zone.state == "off" + + zone = hass.states.get("switch.sprinkler_7") + assert zone is not None + assert zone.state == "off" + + assert not hass.states.get("switch.sprinkler_8") + + +async def test_switch_on( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], +) -> None: + """Test turning on irrigation switch.""" + + responses.extend( + [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_OFF_RESPONSE)] + ) + assert await setup_integration() + + # Initially all zones are off. Pick zone3 as an arbitrary to assert + # state, then update below as a switch. + zone = hass.states.get("switch.sprinkler_3") + assert zone is not None + assert zone.state == "off" + + aioclient_mock.mock_calls.clear() + responses.extend( + [ + mock_response(ACK_ECHO), # Switch on response + mock_response(ZONE_3_ON_RESPONSE), # Updated zone state + ] + ) + await switch_common.async_turn_on(hass, "switch.sprinkler_3") + await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 2 + aioclient_mock.mock_calls.clear() + + # Verify switch state is updated + zone = hass.states.get("switch.sprinkler_3") + assert zone is not None + assert zone.state == "on" + + +async def test_switch_off( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], +) -> None: + """Test turning off irrigation switch.""" + + responses.extend( + [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_3_ON_RESPONSE)] + ) + assert await setup_integration() + + # Initially the test zone is on + zone = hass.states.get("switch.sprinkler_3") + assert zone is not None + assert zone.state == "on" + + aioclient_mock.mock_calls.clear() + responses.extend( + [ + mock_response(ACK_ECHO), # Switch off response + mock_response(ZONE_OFF_RESPONSE), # Updated zone state + ] + ) + await switch_common.async_turn_off(hass, "switch.sprinkler_3") + await hass.async_block_till_done() + + # One call to change the service and one to refresh state + assert len(aioclient_mock.mock_calls) == 2 + + # Verify switch state is updated + zone = hass.states.get("switch.sprinkler_3") + assert zone is not None + assert zone.state == "off" + + +async def test_irrigation_service( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], +) -> None: + """Test calling the irrigation service.""" + + responses.extend( + [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_3_ON_RESPONSE)] + ) + assert await setup_integration() + + aioclient_mock.mock_calls.clear() + responses.extend([mock_response(ACK_ECHO), mock_response(ZONE_OFF_RESPONSE)]) + + await hass.services.async_call( + DOMAIN, + "start_irrigation", + {ATTR_ENTITY_ID: "switch.sprinkler_5", "duration": 30}, + blocking=True, + ) + + # One call to change the service and one to refresh state + assert len(aioclient_mock.mock_calls) == 2 + + +async def test_rain_delay_service( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], +) -> None: + """Test calling the rain delay service.""" + + responses.extend( + [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_3_ON_RESPONSE)] + ) + assert await setup_integration() + + aioclient_mock.mock_calls.clear() + responses.extend( + [ + mock_response(ACK_ECHO), + ] + ) + + await hass.services.async_call( + DOMAIN, "set_rain_delay", {"duration": 30}, blocking=True + ) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_platform_unavailable( + hass: HomeAssistant, + setup_integration: ComponentSetup, + 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( + "yaml_config", + [ + { + DOMAIN: { + "host": HOST, + "password": PASSWORD, + "trigger_time": 360, + "zones": { + 1: { + "friendly_name": "Garden Sprinkler", + }, + 2: { + "friendly_name": "Back Yard", + }, + }, + } + }, + ], +) +async def test_yaml_config( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], +) -> None: + """Test switch platform with fake data that creates 7 zones with one enabled.""" + + responses.extend( + [mock_response(AVAILABLE_STATIONS_RESPONSE), mock_response(ZONE_5_ON_RESPONSE)] + ) + + assert await setup_integration() + + assert hass.states.get("switch.garden_sprinkler") + assert not hass.states.get("switch.sprinkler_1") + assert hass.states.get("switch.back_yard") + assert not hass.states.get("switch.sprinkler_2") + assert hass.states.get("switch.sprinkler_3")