From 85f758380a1f6766f4e268b67724a417dd978399 Mon Sep 17 00:00:00 2001 From: indykoning <15870933+indykoning@users.noreply.github.com> Date: Mon, 10 May 2021 22:46:50 +0200 Subject: [PATCH] Add Growatt Server Config flow (#41303) * Growatt Server Config flow * Use reference strings Co-authored-by: SNoof85 <snoof85@gmail.com> * Remove configuration.yaml import logic * Removed import test * Re-added PLATFORM_SCHEMA validation * Import yaml from old yaml configuration * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Feedback * Use Executor for IO only * Fix imports * update requirements * Fix flake8 * Run every section of fetching devices in single executor * Config flow feedback * Clean up * Fix plan step * Fix config flow test * Remove duplicate test * Test import step * Test already configured entry * Clean up tests * Add asserts * Mock out entry setup * Add warning if set up via yaml Co-authored-by: SNoof85 <snoof85@gmail.com> Co-authored-by: Martin Hjelmare <marhje52@gmail.com> --- .coveragerc | 1 + .../components/growatt_server/__init__.py | 18 ++ .../components/growatt_server/config_flow.py | 78 ++++++++ .../components/growatt_server/const.py | 10 + .../components/growatt_server/manifest.json | 1 + .../components/growatt_server/sensor.py | 47 +++-- .../components/growatt_server/strings.json | 27 +++ .../growatt_server/translations/en.json | 27 +++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/growatt_server/__init__.py | 1 + .../growatt_server/test_config_flow.py | 188 ++++++++++++++++++ 12 files changed, 390 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/growatt_server/config_flow.py create mode 100644 homeassistant/components/growatt_server/const.py create mode 100644 homeassistant/components/growatt_server/strings.json create mode 100644 homeassistant/components/growatt_server/translations/en.json create mode 100644 tests/components/growatt_server/__init__.py create mode 100644 tests/components/growatt_server/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 923642733d4..02cd35bcd60 100644 --- a/.coveragerc +++ b/.coveragerc @@ -374,6 +374,7 @@ omit = homeassistant/components/greenwave/light.py homeassistant/components/group/notify.py homeassistant/components/growatt_server/sensor.py + homeassistant/components/growatt_server/__init__.py homeassistant/components/gstreamer/media_player.py homeassistant/components/gtfs/sensor.py homeassistant/components/guardian/__init__.py diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 14205e8d9ba..8fcc7c3f34d 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -1 +1,19 @@ """The Growatt server PV inverter sensor integration.""" +from homeassistant import config_entries +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Load the saved entities.""" + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py new file mode 100644 index 00000000000..300c96746e7 --- /dev/null +++ b/homeassistant/components/growatt_server/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow for growatt server integration.""" +import growattServer +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback + +from .const import CONF_PLANT_ID, DOMAIN + + +class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow class.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialise growatt server flow.""" + self.api = growattServer.GrowattApi() + self.user_id = None + self.data = {} + + @callback + def _async_show_user_form(self, errors=None): + """Show the form to the user.""" + data_schema = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return self._async_show_user_form() + + login_response = await self.hass.async_add_executor_job( + self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + + if not login_response["success"] and login_response["errCode"] == "102": + return self._async_show_user_form({"base": "invalid_auth"}) + self.user_id = login_response["userId"] + + self.data = user_input + return await self.async_step_plant() + + async def async_step_plant(self, user_input=None): + """Handle adding a "plant" to Home Assistant.""" + plant_info = await self.hass.async_add_executor_job( + self.api.plant_list, self.user_id + ) + + if not plant_info["data"]: + return self.async_abort(reason="no_plants") + + plants = {plant["plantId"]: plant["plantName"] for plant in plant_info["data"]} + + if user_input is None and len(plant_info["data"]) > 1: + data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) + + return self.async_show_form(step_id="plant", data_schema=data_schema) + + if user_input is None and len(plant_info["data"]) == 1: + user_input = {CONF_PLANT_ID: plant_info["data"][0]["plantId"]} + + user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] + await self.async_set_unique_id(user_input[CONF_PLANT_ID]) + self._abort_if_unique_id_configured() + self.data.update(user_input) + return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) + + async def async_step_import(self, import_data): + """Migrate old yaml config to config flow.""" + return await self.async_step_user(import_data) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py new file mode 100644 index 00000000000..4dc09988e6f --- /dev/null +++ b/homeassistant/components/growatt_server/const.py @@ -0,0 +1,10 @@ +"""Define constants for the Growatt Server component.""" +CONF_PLANT_ID = "plant_id" + +DEFAULT_PLANT_ID = "0" + +DEFAULT_NAME = "Growatt" + +DOMAIN = "growatt_server" + +PLATFORMS = ["sensor"] diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index f3376ba4ae2..94fc293b8d7 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -1,6 +1,7 @@ { "domain": "growatt_server", "name": "Growatt", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server/", "requirements": ["growattServer==1.0.0"], "codeowners": ["@indykoning", "@muppet3000"], diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 6464dee6729..0ccdc9425f6 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -8,6 +8,7 @@ import growattServer import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -32,11 +33,10 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle, dt +from .const import CONF_PLANT_ID, DEFAULT_NAME, DEFAULT_PLANT_ID, DOMAIN + _LOGGER = logging.getLogger(__name__) -CONF_PLANT_ID = "plant_id" -DEFAULT_PLANT_ID = "0" -DEFAULT_NAME = "Growatt" SCAN_INTERVAL = datetime.timedelta(minutes=1) # Sensor type order is: Sensor name, Unit of measurement, api data name, additional options @@ -558,17 +558,26 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Growatt sensor.""" - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - plant_id = config[CONF_PLANT_ID] - name = config[CONF_NAME] +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up growatt server from yaml.""" + if not hass.config_entries.async_entries(DOMAIN): + _LOGGER.warning( + "Loading Growatt via platform setup is deprecated." + "Please remove it from your configuration" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) - api = growattServer.GrowattApi() + +def get_device_list(api, config): + """Retrieve the device list for the selected plant.""" + plant_id = config[CONF_PLANT_ID] # Log in to api and fetch first plant if no plant id is defined. - login_response = api.login(username, password) + login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) if not login_response["success"] and login_response["errCode"] == "102": _LOGGER.error("Username or Password may be incorrect!") return @@ -579,6 +588,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Get a list of devices for specified plant to add sensors for. devices = api.device_list(plant_id) + return [devices, plant_id] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Growatt sensor.""" + config = config_entry.data + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + name = config[CONF_NAME] + + api = growattServer.GrowattApi() + + devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) + entities = [] probe = GrowattData(api, username, password, plant_id, "total") for sensor in TOTAL_SENSOR_TYPES: @@ -616,7 +639,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ) ) - add_entities(entities, True) + async_add_entities(entities, True) class GrowattInverter(SensorEntity): diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json new file mode 100644 index 00000000000..903ba400a6f --- /dev/null +++ b/homeassistant/components/growatt_server/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "no_plants": "No plants have been found on this account" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plant" + }, + "title": "Select your plant" + }, + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "password": "[%key:common::config_flow::data::name%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "title": "Enter your Growatt information" + } + } + }, + "title": "Growatt Server" +} diff --git a/homeassistant/components/growatt_server/translations/en.json b/homeassistant/components/growatt_server/translations/en.json new file mode 100644 index 00000000000..11bb6a64b32 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "no_plants": "No plants have been found on this account" + }, + "error": { + "invalid_auth": "Invalid authentication" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plant" + }, + "title": "Select your plant" + }, + "user": { + "data": { + "name": "Name", + "password": "Password", + "username": "Username" + }, + "title": "Enter your Growatt information" + } + } + }, + "title": "Growatt Server" +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bb346ff5b0f..fe62725cc82 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -92,6 +92,7 @@ FLOWS = [ "google_travel_time", "gpslogger", "gree", + "growatt_server", "guardian", "habitica", "hangouts", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f91b249c477..c7f63ab3692 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -386,6 +386,9 @@ googlemaps==2.5.1 # homeassistant.components.gree greeclimate==0.11.4 +# homeassistant.components.growatt_server +growattServer==1.0.0 + # homeassistant.components.profiler guppy3==3.1.0 diff --git a/tests/components/growatt_server/__init__.py b/tests/components/growatt_server/__init__.py new file mode 100644 index 00000000000..999e1782a9f --- /dev/null +++ b/tests/components/growatt_server/__init__.py @@ -0,0 +1 @@ +"""Tests for the growatt_server component.""" diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py new file mode 100644 index 00000000000..cc11c2f8bf2 --- /dev/null +++ b/tests/components/growatt_server/test_config_flow.py @@ -0,0 +1,188 @@ +"""Tests for the Growatt server config flow.""" +from copy import deepcopy +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.growatt_server.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +FIXTURE_USER_INPUT = {CONF_USERNAME: "username", CONF_PASSWORD: "password"} + +GROWATT_PLANT_LIST_RESPONSE = { + "data": [ + { + "plantMoneyText": "474.9 (€)", + "plantName": "Plant name", + "plantId": "123456", + "isHaveStorage": "false", + "todayEnergy": "2.6 kWh", + "totalEnergy": "2.37 MWh", + "currentPower": "628.8 W", + } + ], + "totalData": { + "currentPowerSum": "628.8 W", + "CO2Sum": "2.37 KT", + "isHaveStorage": "false", + "eTotalMoneyText": "474.9 (€)", + "todayEnergySum": "2.6 kWh", + "totalEnergySum": "2.37 MWh", + }, + "success": True, +} +GROWATT_LOGIN_RESPONSE = {"userId": 123456, "userLevel": 1, "success": True} + + +async def test_show_authenticate_form(hass): + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_incorrect_username(hass): + """Test that it shows the appropriate error when an incorrect username is entered.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "growattServer.GrowattApi.login", + return_value={"errCode": "102", "success": False}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_no_plants_on_account(hass): + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + plant_list = deepcopy(GROWATT_PLANT_LIST_RESPONSE) + plant_list["data"] = [] + + with patch( + "growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE + ), patch("growattServer.GrowattApi.plant_list", return_value=plant_list): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_plants" + + +async def test_multiple_plant_ids(hass): + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + plant_list = deepcopy(GROWATT_PLANT_LIST_RESPONSE) + plant_list["data"].append(plant_list["data"][0]) + + with patch( + "growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE + ), patch("growattServer.GrowattApi.plant_list", return_value=plant_list), patch( + "homeassistant.components.growatt_server.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "plant" + + user_input = {CONF_PLANT_ID: "123456"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == "123456" + + +async def test_one_plant_on_account(hass): + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch( + "growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE + ), patch( + "growattServer.GrowattApi.plant_list", + return_value=GROWATT_PLANT_LIST_RESPONSE, + ), patch( + "homeassistant.components.growatt_server.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == "123456" + + +async def test_import_one_plant(hass): + """Test import step with a single plant.""" + import_data = FIXTURE_USER_INPUT.copy() + + with patch( + "growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE + ), patch( + "growattServer.GrowattApi.plant_list", + return_value=GROWATT_PLANT_LIST_RESPONSE, + ), patch( + "homeassistant.components.growatt_server.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=import_data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == "123456" + + +async def test_existing_plant_configured(hass): + """Test entering an existing plant_id.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="123456") + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch( + "growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE + ), patch( + "growattServer.GrowattApi.plant_list", + return_value=GROWATT_PLANT_LIST_RESPONSE, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" -- GitLab