From 2f30fdb9b8203f711597d91cbdc32bb4db6f41ae Mon Sep 17 00:00:00 2001 From: tokenize47 Date: Wed, 26 Jan 2022 09:58:06 +0000 Subject: [PATCH] Add solax config flow (#56620) --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/solax/__init__.py | 37 +++++ homeassistant/components/solax/config_flow.py | 76 ++++++++++ homeassistant/components/solax/const.py | 4 + homeassistant/components/solax/manifest.json | 3 +- homeassistant/components/solax/sensor.py | 46 ++++-- homeassistant/components/solax/strings.json | 17 +++ .../components/solax/translations/en.json | 17 +++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/solax/__init__.py | 1 + tests/components/solax/test_config_flow.py | 131 ++++++++++++++++++ 13 files changed, 327 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/solax/config_flow.py create mode 100644 homeassistant/components/solax/const.py create mode 100644 homeassistant/components/solax/strings.json create mode 100644 homeassistant/components/solax/translations/en.json create mode 100644 tests/components/solax/__init__.py create mode 100644 tests/components/solax/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index d2bc1f2d2b4..b18d18cf83f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1033,6 +1033,7 @@ omit = homeassistant/components/solaredge/sensor.py homeassistant/components/solaredge_local/sensor.py homeassistant/components/solarlog/* + homeassistant/components/solax/__init__.py homeassistant/components/solax/sensor.py homeassistant/components/soma/__init__.py homeassistant/components/soma/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index 065597da388..7e43aaba2f9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -867,6 +867,7 @@ homeassistant/components/solaredge_local/* @drobtravels @scheric homeassistant/components/solarlog/* @Ernst79 tests/components/solarlog/* @Ernst79 homeassistant/components/solax/* @squishykid +tests/components/solax/* @squishykid homeassistant/components/soma/* @ratsept @sebfortier2288 tests/components/soma/* @ratsept @sebfortier2288 homeassistant/components/somfy/* @tetienne diff --git a/homeassistant/components/solax/__init__.py b/homeassistant/components/solax/__init__.py index 3995ab10ac9..2f9d4509dd2 100644 --- a/homeassistant/components/solax/__init__.py +++ b/homeassistant/components/solax/__init__.py @@ -1 +1,38 @@ """The solax component.""" +from solax import real_time_api + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the sensors from a ConfigEntry.""" + + try: + api = await real_time_api( + entry.data[CONF_IP_ADDRESS], + entry.data[CONF_PORT], + entry.data[CONF_PASSWORD], + ) + await api.get_data() + except Exception as err: # pylint: disable=broad-except + raise ConfigEntryNotReady from err + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api + hass.config_entries.async_setup_platforms(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[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/solax/config_flow.py b/homeassistant/components/solax/config_flow.py new file mode 100644 index 00000000000..5c4ef05da4b --- /dev/null +++ b/homeassistant/components/solax/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for solax integration.""" +import logging +from typing import Any + +from solax import real_time_api +from solax.inverter import DiscoveryError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 80 +DEFAULT_PASSWORD = "" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + } +) + + +async def validate_api(data) -> str: + """Validate the credentials.""" + + api = await real_time_api( + data[CONF_IP_ADDRESS], data[CONF_PORT], data[CONF_PASSWORD] + ) + response = await api.get_data() + return response.serial_number + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Solax.""" + + async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, Any] = {} + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + try: + serial_number = await validate_api(user_input) + except (ConnectionError, DiscoveryError): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=serial_number, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle import of solax config from YAML.""" + + import_data = { + CONF_IP_ADDRESS: config[CONF_IP_ADDRESS], + CONF_PORT: config[CONF_PORT], + CONF_PASSWORD: DEFAULT_PASSWORD, + } + + return await self.async_step_user(user_input=import_data) diff --git a/homeassistant/components/solax/const.py b/homeassistant/components/solax/const.py new file mode 100644 index 00000000000..bf8abe19af1 --- /dev/null +++ b/homeassistant/components/solax/const.py @@ -0,0 +1,4 @@ +"""Constants for the solax integration.""" + + +DOMAIN = "solax" diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 311bff3f66e..e8a905ca8bc 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/solax", "requirements": ["solax==0.2.9"], "codeowners": ["@squishykid"], - "iot_class": "local_polling" + "iot_class": "local_polling", + "config_flow": true } diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index 7d03fdaa930..83c1ace569d 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio from datetime import timedelta +import logging -from solax import real_time_api from solax.inverter import InverterError import voluptuous as vol @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady @@ -22,29 +23,30 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -DEFAULT_PORT = 80 +from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 80 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_IP_ADDRESS): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } + }, ) - SCAN_INTERVAL = timedelta(seconds=30) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Platform setup.""" - api = await real_time_api(config[CONF_IP_ADDRESS], config[CONF_PORT]) - endpoint = RealTimeDataEndpoint(hass, api) + """Entry setup.""" + api = hass.data[DOMAIN][entry.entry_id] resp = await api.get_data() serial = resp.serial_number + endpoint = RealTimeDataEndpoint(hass, api) hass.async_add_job(endpoint.async_refresh) async_track_time_interval(hass, endpoint.async_refresh, SCAN_INTERVAL) devices = [] @@ -75,6 +77,30 @@ async def async_setup_platform( async_add_entities(devices) +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Platform setup.""" + + _LOGGER.warning( + "Configuration of the SolaX Power platform in YAML is deprecated and " + "will be removed in Home Assistant 2022.4; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + class RealTimeDataEndpoint: """Representation of a Sensor.""" diff --git a/homeassistant/components/solax/strings.json b/homeassistant/components/solax/strings.json new file mode 100644 index 00000000000..cad2575cbce --- /dev/null +++ b/homeassistant/components/solax/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/solax/translations/en.json b/homeassistant/components/solax/translations/en.json new file mode 100644 index 00000000000..ec20dc22d44 --- /dev/null +++ b/homeassistant/components/solax/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "ip_address": "IP Address", + "password": "Password", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 06225860376..bf64bc4a51c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -293,6 +293,7 @@ FLOWS = [ "sms", "solaredge", "solarlog", + "solax", "soma", "somfy", "somfy_mylink", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3c6f8d8d0d..e9667af7321 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1360,6 +1360,9 @@ soco==0.26.0 # homeassistant.components.solaredge solaredge==0.0.2 +# homeassistant.components.solax +solax==0.2.9 + # homeassistant.components.honeywell somecomfort==0.8.0 diff --git a/tests/components/solax/__init__.py b/tests/components/solax/__init__.py new file mode 100644 index 00000000000..09d2a78299e --- /dev/null +++ b/tests/components/solax/__init__.py @@ -0,0 +1 @@ +"""Tests for the solax integration.""" diff --git a/tests/components/solax/test_config_flow.py b/tests/components/solax/test_config_flow.py new file mode 100644 index 00000000000..56db8d3f6cb --- /dev/null +++ b/tests/components/solax/test_config_flow.py @@ -0,0 +1,131 @@ +"""Tests for the solax config flow.""" +from unittest.mock import patch + +from solax import RealTimeAPI, inverter +from solax.inverter import InverterResponse + +from homeassistant import config_entries +from homeassistant.components.solax.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT + + +def __mock_real_time_api_success(): + return RealTimeAPI(inverter.X1MiniV34) + + +def __mock_get_data(): + return InverterResponse( + data=None, serial_number="ABCDEFGHIJ", version="2.034.06", type=4 + ) + + +async def test_form_success(hass): + """Test successful form.""" + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert flow["type"] == "form" + assert flow["errors"] == {} + + with patch( + "homeassistant.components.solax.config_flow.real_time_api", + return_value=__mock_real_time_api_success(), + ), patch("solax.RealTimeAPI.get_data", return_value=__mock_get_data()), patch( + "homeassistant.components.solax.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + entry_result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + {CONF_IP_ADDRESS: "192.168.1.87", CONF_PORT: 80, CONF_PASSWORD: "password"}, + ) + await hass.async_block_till_done() + + assert entry_result["type"] == "create_entry" + assert entry_result["title"] == "ABCDEFGHIJ" + assert entry_result["data"] == { + CONF_IP_ADDRESS: "192.168.1.87", + CONF_PORT: 80, + CONF_PASSWORD: "password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_connect_error(hass): + """Test cannot connect form.""" + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert flow["type"] == "form" + assert flow["errors"] == {} + + with patch( + "homeassistant.components.solax.config_flow.real_time_api", + side_effect=ConnectionError, + ): + entry_result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + {CONF_IP_ADDRESS: "192.168.1.87", CONF_PORT: 80, CONF_PASSWORD: "password"}, + ) + + assert entry_result["type"] == "form" + assert entry_result["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass): + """Test unknown error form.""" + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert flow["type"] == "form" + assert flow["errors"] == {} + + with patch( + "homeassistant.components.solax.config_flow.real_time_api", + side_effect=Exception, + ): + entry_result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + {CONF_IP_ADDRESS: "192.168.1.87", CONF_PORT: 80, CONF_PASSWORD: "password"}, + ) + + assert entry_result["type"] == "form" + assert entry_result["errors"] == {"base": "unknown"} + + +async def test_import_success(hass): + """Test import success.""" + conf = {CONF_IP_ADDRESS: "192.168.1.87", CONF_PORT: 80} + with patch( + "homeassistant.components.solax.config_flow.real_time_api", + return_value=__mock_real_time_api_success(), + ), patch("solax.RealTimeAPI.get_data", return_value=__mock_get_data()), patch( + "homeassistant.components.solax.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + entry_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + + assert entry_result["type"] == "create_entry" + assert entry_result["title"] == "ABCDEFGHIJ" + assert entry_result["data"] == { + CONF_IP_ADDRESS: "192.168.1.87", + CONF_PORT: 80, + CONF_PASSWORD: "", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_error(hass): + """Test import success.""" + conf = {CONF_IP_ADDRESS: "192.168.1.87", CONF_PORT: 80} + with patch( + "homeassistant.components.solax.config_flow.real_time_api", + side_effect=ConnectionError, + ): + entry_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + + assert entry_result["type"] == "form" + assert entry_result["errors"] == {"base": "cannot_connect"}