From 588db501fbf11aedecd549540661397557916e35 Mon Sep 17 00:00:00 2001 From: Marty Sun Date: Fri, 25 Aug 2023 06:48:49 +0800 Subject: [PATCH] Add new integration Yardian (#97326) Co-authored-by: J. Nick Koston --- .coveragerc | 3 + CODEOWNERS | 1 + homeassistant/components/yardian/__init__.py | 40 ++++ .../components/yardian/config_flow.py | 73 +++++++ homeassistant/components/yardian/const.py | 7 + .../components/yardian/coordinator.py | 73 +++++++ .../components/yardian/manifest.json | 9 + homeassistant/components/yardian/strings.json | 20 ++ homeassistant/components/yardian/switch.py | 71 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/yardian/conftest.py | 14 ++ tests/components/yardian/test_config_flow.py | 188 ++++++++++++++++++ 14 files changed, 509 insertions(+) create mode 100644 homeassistant/components/yardian/__init__.py create mode 100644 homeassistant/components/yardian/config_flow.py create mode 100644 homeassistant/components/yardian/const.py create mode 100644 homeassistant/components/yardian/coordinator.py create mode 100644 homeassistant/components/yardian/manifest.json create mode 100644 homeassistant/components/yardian/strings.json create mode 100644 homeassistant/components/yardian/switch.py create mode 100644 tests/components/yardian/conftest.py create mode 100644 tests/components/yardian/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 5155cac79f1..5753bc13195 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1521,6 +1521,9 @@ omit = homeassistant/components/yamaha_musiccast/select.py homeassistant/components/yamaha_musiccast/switch.py homeassistant/components/yandex_transport/sensor.py + homeassistant/components/yardian/__init__.py + homeassistant/components/yardian/coordinator.py + homeassistant/components/yardian/switch.py homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py homeassistant/components/yolink/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index e3e42b75280..dd52cb196a6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1444,6 +1444,7 @@ build.json @home-assistant/supervisor /tests/components/yamaha_musiccast/ @vigonotion @micha91 /homeassistant/components/yandex_transport/ @rishatik92 @devbis /tests/components/yandex_transport/ @rishatik92 @devbis +/homeassistant/components/yardian/ @h3l1o5 /homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelightsunflower/ @lindsaymarkward diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py new file mode 100644 index 00000000000..d6cee9015b8 --- /dev/null +++ b/homeassistant/components/yardian/__init__.py @@ -0,0 +1,40 @@ +"""The Yardian integration.""" +from __future__ import annotations + +from pyyardian import AsyncYardianClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import YardianUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Yardian from a config entry.""" + + host = entry.data[CONF_HOST] + access_token = entry.data[CONF_ACCESS_TOKEN] + + controller = AsyncYardianClient(async_get_clientsession(hass), host, access_token) + coordinator = YardianUpdateCoordinator(hass, entry, controller) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + 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.get(DOMAIN, {}).pop(entry.entry_id, None) + + return unload_ok diff --git a/homeassistant/components/yardian/config_flow.py b/homeassistant/components/yardian/config_flow.py new file mode 100644 index 00000000000..99258965f21 --- /dev/null +++ b/homeassistant/components/yardian/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for Yardian integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyyardian import ( + AsyncYardianClient, + DeviceInfo, + NetworkException, + NotAuthorizedException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, PRODUCT_NAME + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_ACCESS_TOKEN): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Yardian.""" + + VERSION = 1 + + async def async_fetch_device_info(self, host: str, access_token: str) -> DeviceInfo: + """Fetch device info from Yardian.""" + yarcli = AsyncYardianClient( + async_get_clientsession(self.hass), + host, + access_token, + ) + return await yarcli.fetch_device_info() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + device_info = await self.async_fetch_device_info( + user_input["host"], user_input["access_token"] + ) + except NotAuthorizedException: + errors["base"] = "invalid_auth" + except NetworkException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device_info["yid"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + data=user_input | device_info, + title=PRODUCT_NAME, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/yardian/const.py b/homeassistant/components/yardian/const.py new file mode 100644 index 00000000000..b4e75f2367b --- /dev/null +++ b/homeassistant/components/yardian/const.py @@ -0,0 +1,7 @@ +"""Constants for the Yardian integration.""" + +DOMAIN = "yardian" +MANUFACTURER = "Aeon Matrix" +PRODUCT_NAME = "Yardian Smart Sprinkler" + +DEFAULT_WATERING_DURATION = 6 diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py new file mode 100644 index 00000000000..526ee3c42ab --- /dev/null +++ b/homeassistant/components/yardian/coordinator.py @@ -0,0 +1,73 @@ +"""Update coordinators for Yardian.""" + +from __future__ import annotations + +import asyncio +import datetime +import logging + +from pyyardian import ( + AsyncYardianClient, + NetworkException, + NotAuthorizedException, + YardianDeviceState, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(seconds=30) + + +class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): + """Coordinator for Yardian API calls.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + controller: AsyncYardianClient, + ) -> None: + """Initialize Yardian API communication.""" + super().__init__( + hass, + _LOGGER, + name=entry.title, + update_method=self._async_update_data, + update_interval=SCAN_INTERVAL, + always_update=False, + ) + + self.controller = controller + self.yid = entry.data["yid"] + self._name = entry.title + self._model = entry.data["model"] + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return DeviceInfo( + name=self._name, + identifiers={(DOMAIN, self.yid)}, + manufacturer=MANUFACTURER, + model=self._model, + ) + + async def _async_update_data(self) -> YardianDeviceState: + """Fetch data from Yardian device.""" + try: + async with asyncio.timeout(10): + return await self.controller.fetch_device_state() + + except asyncio.TimeoutError as e: + raise UpdateFailed("Communication with Device was time out") from e + except NotAuthorizedException as e: + raise UpdateFailed("Invalid access token") from e + except NetworkException as e: + raise UpdateFailed("Failed to communicate with Device") from e diff --git a/homeassistant/components/yardian/manifest.json b/homeassistant/components/yardian/manifest.json new file mode 100644 index 00000000000..a20315278b4 --- /dev/null +++ b/homeassistant/components/yardian/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "yardian", + "name": "Yardian", + "codeowners": ["@h3l1o5"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/yardian", + "iot_class": "local_polling", + "requirements": ["pyyardian==1.1.0"] +} diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json new file mode 100644 index 00000000000..6577c99456c --- /dev/null +++ b/homeassistant/components/yardian/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "access_token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py new file mode 100644 index 00000000000..af5703e0fd4 --- /dev/null +++ b/homeassistant/components/yardian/switch.py @@ -0,0 +1,71 @@ +"""Support for Yardian integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_WATERING_DURATION, DOMAIN +from .coordinator import YardianUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entry for a Yardian irrigation switches.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + YardianSwitch( + coordinator, + i, + ) + for i in range(len(coordinator.data.zones)) + ) + + +class YardianSwitch(CoordinatorEntity[YardianUpdateCoordinator], SwitchEntity): + """Representation of a Yardian switch.""" + + _attr_icon = "mdi:water" + _attr_has_entity_name = True + + def __init__(self, coordinator: YardianUpdateCoordinator, zone_id) -> None: + """Initialize a Yardian Switch Device.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{coordinator.yid}-{zone_id}" + self._attr_device_info = coordinator.device_info + + @property + def name(self) -> str: + """Return the zone name.""" + return self.coordinator.data.zones[self._zone_id][0] + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self._zone_id in self.coordinator.data.active_zones + + @property + def available(self) -> bool: + """Return the switch is available or not.""" + return self.coordinator.data.zones[self._zone_id][1] == 1 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.controller.start_irrigation( + self._zone_id, + kwargs.get("duration", DEFAULT_WATERING_DURATION), + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.controller.stop_irrigation() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 82c2d82f423..93d7ec1fbdc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -535,6 +535,7 @@ FLOWS = { "yale_smart_alarm", "yalexs_ble", "yamaha_musiccast", + "yardian", "yeelight", "yolink", "youless", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 75540a3af83..07960a97fe5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6518,6 +6518,12 @@ } } }, + "yardian": { + "name": "Yardian", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "yeelight": { "name": "Yeelight", "integrations": { diff --git a/requirements_all.txt b/requirements_all.txt index be073ac19ab..0c96dc89643 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2248,6 +2248,9 @@ pyws66i==1.1 # homeassistant.components.xeoma pyxeoma==1.4.1 +# homeassistant.components.yardian +pyyardian==1.1.0 + # homeassistant.components.qrcode pyzbar==0.1.7 diff --git a/tests/components/yardian/conftest.py b/tests/components/yardian/conftest.py new file mode 100644 index 00000000000..d4f289c4242 --- /dev/null +++ b/tests/components/yardian/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Yardian tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.yardian.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/yardian/test_config_flow.py b/tests/components/yardian/test_config_flow.py new file mode 100644 index 00000000000..5f1fcc940cc --- /dev/null +++ b/tests/components/yardian/test_config_flow.py @@ -0,0 +1,188 @@ +"""Test the Yardian config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest +from pyyardian import NetworkException, NotAuthorizedException + +from homeassistant import config_entries +from homeassistant.components.yardian.const import DOMAIN, PRODUCT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == PRODUCT_NAME + assert result2["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + side_effect=NotAuthorizedException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # Should be recoverable after hits error + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == PRODUCT_NAME + assert result3["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + side_effect=NetworkException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Should be recoverable after hits error + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == PRODUCT_NAME + assert result3["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_uncategorized_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle uncategorized error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + # Should be recoverable after hits error + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == PRODUCT_NAME + assert result3["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1