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