From e22aaea5b2bcd8766535cdda6c61ea98298ee722 Mon Sep 17 00:00:00 2001
From: Dave T <17680170+davet2001@users.noreply.github.com>
Date: Tue, 26 Oct 2021 02:04:42 +0100
Subject: [PATCH] Aurora abb (solar) configflow (#36300)

Co-authored-by: J. Nick Koston <nick@koston.org>
---
 .coveragerc                                   |   1 -
 .../aurora_abb_powerone/__init__.py           |  48 +++++
 .../aurora_abb_powerone/aurora_device.py      |  51 +++++
 .../aurora_abb_powerone/config_flow.py        | 147 ++++++++++++++
 .../components/aurora_abb_powerone/const.py   |  22 +++
 .../aurora_abb_powerone/manifest.json         |   9 +-
 .../components/aurora_abb_powerone/sensor.py  |  96 ++++++---
 .../aurora_abb_powerone/strings.json          |  23 +++
 .../aurora_abb_powerone/translations/en.json  |  23 +++
 homeassistant/generated/config_flows.py       |   1 +
 requirements_test_all.txt                     |   3 +
 .../aurora_abb_powerone/__init__.py           |   1 +
 .../aurora_abb_powerone/test_config_flow.py   | 165 ++++++++++++++++
 .../aurora_abb_powerone/test_init.py          |  37 ++++
 .../aurora_abb_powerone/test_sensor.py        | 185 ++++++++++++++++++
 15 files changed, 784 insertions(+), 28 deletions(-)
 create mode 100644 homeassistant/components/aurora_abb_powerone/aurora_device.py
 create mode 100644 homeassistant/components/aurora_abb_powerone/config_flow.py
 create mode 100644 homeassistant/components/aurora_abb_powerone/const.py
 create mode 100644 homeassistant/components/aurora_abb_powerone/strings.json
 create mode 100644 homeassistant/components/aurora_abb_powerone/translations/en.json
 create mode 100644 tests/components/aurora_abb_powerone/__init__.py
 create mode 100644 tests/components/aurora_abb_powerone/test_config_flow.py
 create mode 100644 tests/components/aurora_abb_powerone/test_init.py
 create mode 100644 tests/components/aurora_abb_powerone/test_sensor.py

diff --git a/.coveragerc b/.coveragerc
index bb84820783b..74da8acf8b8 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -83,7 +83,6 @@ omit =
     homeassistant/components/aurora/binary_sensor.py
     homeassistant/components/aurora/const.py
     homeassistant/components/aurora/sensor.py
-    homeassistant/components/aurora_abb_powerone/sensor.py
     homeassistant/components/avea/light.py
     homeassistant/components/avion/light.py
     homeassistant/components/azure_devops/__init__.py
diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py
index 087172d1bb5..ff26c3770f0 100644
--- a/homeassistant/components/aurora_abb_powerone/__init__.py
+++ b/homeassistant/components/aurora_abb_powerone/__init__.py
@@ -1 +1,49 @@
 """The Aurora ABB Powerone PV inverter sensor integration."""
+
+# Reference info:
+# https://s1.solacity.com/docs/PVI-3.0-3.6-4.2-OUTD-US%20Manual.pdf
+# http://www.drhack.it/images/PDF/AuroraCommunicationProtocol_4_2.pdf
+#
+# Developer note:
+# vscode devcontainer: use the following to access USB device:
+# "runArgs": ["-e", "GIT_EDITOR=code --wait", "--device=/dev/ttyUSB0"],
+
+import logging
+
+from aurorapy.client import AuroraSerialClient
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_ADDRESS, CONF_PORT
+from homeassistant.core import HomeAssistant
+
+from .const import DOMAIN
+
+PLATFORMS = ["sensor"]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+    """Set up Aurora ABB PowerOne from a config entry."""
+
+    comport = entry.data[CONF_PORT]
+    address = entry.data[CONF_ADDRESS]
+    serclient = AuroraSerialClient(address, comport, parity="N", timeout=1)
+
+    hass.data.setdefault(DOMAIN, {})[entry.unique_id] = serclient
+
+    hass.config_entries.async_setup_platforms(entry, PLATFORMS)
+
+    return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+    """Unload a config entry."""
+
+    unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+    # It should not be necessary to close the serial port because we close
+    # it after every use in sensor.py, i.e. no need to do entry["client"].close()
+    if unload_ok:
+        hass.data[DOMAIN].pop(entry.unique_id)
+
+    return unload_ok
diff --git a/homeassistant/components/aurora_abb_powerone/aurora_device.py b/homeassistant/components/aurora_abb_powerone/aurora_device.py
new file mode 100644
index 00000000000..0a7aab4a921
--- /dev/null
+++ b/homeassistant/components/aurora_abb_powerone/aurora_device.py
@@ -0,0 +1,51 @@
+"""Top level class for AuroraABBPowerOneSolarPV inverters and sensors."""
+import logging
+
+from aurorapy.client import AuroraSerialClient
+
+from homeassistant.helpers.entity import Entity
+
+from .const import (
+    ATTR_DEVICE_NAME,
+    ATTR_FIRMWARE,
+    ATTR_MODEL,
+    ATTR_SERIAL_NUMBER,
+    DEFAULT_DEVICE_NAME,
+    DOMAIN,
+    MANUFACTURER,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class AuroraDevice(Entity):
+    """Representation of an Aurora ABB PowerOne device."""
+
+    def __init__(self, client: AuroraSerialClient, data) -> None:
+        """Initialise the basic device."""
+        self._data = data
+        self.type = "device"
+        self.client = client
+        self._available = True
+
+    @property
+    def unique_id(self) -> str:
+        """Return the unique id for this device."""
+        serial = self._data[ATTR_SERIAL_NUMBER]
+        return f"{serial}_{self.type}"
+
+    @property
+    def available(self) -> bool:
+        """Return True if entity is available."""
+        return self._available
+
+    @property
+    def device_info(self):
+        """Return device specific attributes."""
+        return {
+            "identifiers": {(DOMAIN, self._data[ATTR_SERIAL_NUMBER])},
+            "manufacturer": MANUFACTURER,
+            "model": self._data[ATTR_MODEL],
+            "name": self._data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME),
+            "sw_version": self._data[ATTR_FIRMWARE],
+        }
diff --git a/homeassistant/components/aurora_abb_powerone/config_flow.py b/homeassistant/components/aurora_abb_powerone/config_flow.py
new file mode 100644
index 00000000000..c0c87e9e103
--- /dev/null
+++ b/homeassistant/components/aurora_abb_powerone/config_flow.py
@@ -0,0 +1,147 @@
+"""Config flow for Aurora ABB PowerOne integration."""
+import logging
+
+from aurorapy.client import AuroraError, AuroraSerialClient
+import serial.tools.list_ports
+import voluptuous as vol
+
+from homeassistant import config_entries, core
+from homeassistant.const import CONF_ADDRESS, CONF_PORT
+
+from .const import (
+    ATTR_FIRMWARE,
+    ATTR_MODEL,
+    ATTR_SERIAL_NUMBER,
+    DEFAULT_ADDRESS,
+    DEFAULT_INTEGRATION_TITLE,
+    DOMAIN,
+    MAX_ADDRESS,
+    MIN_ADDRESS,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def validate_and_connect(hass: core.HomeAssistant, data):
+    """Validate the user input allows us to connect.
+
+    Data has the keys from DATA_SCHEMA with values provided by the user.
+    """
+    comport = data[CONF_PORT]
+    address = data[CONF_ADDRESS]
+    _LOGGER.debug("Intitialising com port=%s", comport)
+    ret = {}
+    ret["title"] = DEFAULT_INTEGRATION_TITLE
+    try:
+        client = AuroraSerialClient(address, comport, parity="N", timeout=1)
+        client.connect()
+        ret[ATTR_SERIAL_NUMBER] = client.serial_number()
+        ret[ATTR_MODEL] = f"{client.version()} ({client.pn()})"
+        ret[ATTR_FIRMWARE] = client.firmware(1)
+        _LOGGER.info("Returning device info=%s", ret)
+    except AuroraError as err:
+        _LOGGER.warning("Could not connect to device=%s", comport)
+        raise err
+    finally:
+        if client.serline.isOpen():
+            client.close()
+
+    # Return info we want to store in the config entry.
+    return ret
+
+
+def scan_comports():
+    """Find and store available com ports for the GUI dropdown."""
+    comports = serial.tools.list_ports.comports(include_links=True)
+    comportslist = []
+    for port in comports:
+        comportslist.append(port.device)
+        _LOGGER.debug("COM port option: %s", port.device)
+    if len(comportslist) > 0:
+        return comportslist, comportslist[0]
+    _LOGGER.warning("No com ports found.  Need a valid RS485 device to communicate")
+    return None, None
+
+
+class AuroraABBConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Aurora ABB PowerOne."""
+
+    VERSION = 1
+    CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+    def __init__(self):
+        """Initialise the config flow."""
+        self.config = None
+        self._comportslist = None
+        self._defaultcomport = None
+
+    async def async_step_import(self, config: dict):
+        """Import a configuration from config.yaml."""
+        if self.hass.config_entries.async_entries(DOMAIN):
+            return self.async_abort(reason="already_setup")
+
+        conf = {}
+        conf[ATTR_SERIAL_NUMBER] = "sn_unknown_yaml"
+        conf[ATTR_MODEL] = "model_unknown_yaml"
+        conf[ATTR_FIRMWARE] = "fw_unknown_yaml"
+        conf[CONF_PORT] = config["device"]
+        conf[CONF_ADDRESS] = config["address"]
+        # config["name"] from yaml is ignored.
+
+        await self.async_set_unique_id(self.flow_id)
+        self._abort_if_unique_id_configured()
+
+        return self.async_create_entry(title=DEFAULT_INTEGRATION_TITLE, data=conf)
+
+    async def async_step_user(self, user_input=None):
+        """Handle a flow initialised by the user."""
+
+        errors = {}
+        if self._comportslist is None:
+            result = await self.hass.async_add_executor_job(scan_comports)
+            self._comportslist, self._defaultcomport = result
+            if self._defaultcomport is None:
+                return self.async_abort(reason="no_serial_ports")
+
+        # Handle the initial step.
+        if user_input is not None:
+            try:
+                info = await self.hass.async_add_executor_job(
+                    validate_and_connect, self.hass, user_input
+                )
+                info.update(user_input)
+                # Bomb out early if someone has already set up this device.
+                device_unique_id = info["serial_number"]
+                await self.async_set_unique_id(device_unique_id)
+                self._abort_if_unique_id_configured()
+
+                return self.async_create_entry(title=info["title"], data=info)
+
+            except OSError as error:
+                if error.errno == 19:  # No such device.
+                    errors["base"] = "invalid_serial_port"
+            except AuroraError as error:
+                if "could not open port" in str(error):
+                    errors["base"] = "cannot_open_serial_port"
+                elif "No response after" in str(error):
+                    errors["base"] = "cannot_connect"  # could be dark
+                else:
+                    _LOGGER.error(
+                        "Unable to communicate with Aurora ABB Inverter at %s: %s %s",
+                        user_input[CONF_PORT],
+                        type(error),
+                        error,
+                    )
+                    errors["base"] = "cannot_connect"
+        # If no user input, must be first pass through the config.  Show  initial form.
+        config_options = {
+            vol.Required(CONF_PORT, default=self._defaultcomport): vol.In(
+                self._comportslist
+            ),
+            vol.Required(CONF_ADDRESS, default=DEFAULT_ADDRESS): vol.In(
+                range(MIN_ADDRESS, MAX_ADDRESS + 1)
+            ),
+        }
+        schema = vol.Schema(config_options)
+
+        return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
diff --git a/homeassistant/components/aurora_abb_powerone/const.py b/homeassistant/components/aurora_abb_powerone/const.py
new file mode 100644
index 00000000000..3711dd6d800
--- /dev/null
+++ b/homeassistant/components/aurora_abb_powerone/const.py
@@ -0,0 +1,22 @@
+"""Constants for the Aurora ABB PowerOne integration."""
+
+DOMAIN = "aurora_abb_powerone"
+
+# Min max addresses and default according to here:
+# https://library.e.abb.com/public/e57212c407344a16b4644cee73492b39/PVI-3.0_3.6_4.2-TL-OUTD-Product%20manual%20EN-RevB(M000016BG).pdf
+
+MIN_ADDRESS = 2
+MAX_ADDRESS = 63
+DEFAULT_ADDRESS = 2
+
+DEFAULT_INTEGRATION_TITLE = "PhotoVoltaic Inverters"
+DEFAULT_DEVICE_NAME = "Solar Inverter"
+
+DEVICES = "devices"
+MANUFACTURER = "ABB"
+
+ATTR_DEVICE_NAME = "device_name"
+ATTR_DEVICE_ID = "device_id"
+ATTR_SERIAL_NUMBER = "serial_number"
+ATTR_MODEL = "model"
+ATTR_FIRMWARE = "firmware"
diff --git a/homeassistant/components/aurora_abb_powerone/manifest.json b/homeassistant/components/aurora_abb_powerone/manifest.json
index 69798ce4906..9849c0d84ee 100644
--- a/homeassistant/components/aurora_abb_powerone/manifest.json
+++ b/homeassistant/components/aurora_abb_powerone/manifest.json
@@ -1,8 +1,11 @@
 {
   "domain": "aurora_abb_powerone",
-  "name": "Aurora ABB Solar PV",
-  "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone/",
-  "codeowners": ["@davet2001"],
+  "name": "Aurora ABB PowerOne Solar PV",
+  "config_flow": true,
+  "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone",
   "requirements": ["aurorapy==0.2.6"],
+  "codeowners": [
+    "@davet2001"
+  ],
   "iot_class": "local_polling"
 }
diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py
index b1bcec18796..bbbd026bb2e 100644
--- a/homeassistant/components/aurora_abb_powerone/sensor.py
+++ b/homeassistant/components/aurora_abb_powerone/sensor.py
@@ -10,54 +10,85 @@ from homeassistant.components.sensor import (
     STATE_CLASS_MEASUREMENT,
     SensorEntity,
 )
+from homeassistant.config_entries import SOURCE_IMPORT
 from homeassistant.const import (
     CONF_ADDRESS,
     CONF_DEVICE,
     CONF_NAME,
     DEVICE_CLASS_POWER,
+    DEVICE_CLASS_TEMPERATURE,
     POWER_WATT,
+    TEMP_CELSIUS,
 )
+from homeassistant.exceptions import InvalidStateError
 import homeassistant.helpers.config_validation as cv
 
+from .aurora_device import AuroraDevice
+from .const import DEFAULT_ADDRESS, DOMAIN
+
 _LOGGER = logging.getLogger(__name__)
 
-DEFAULT_ADDRESS = 2
-DEFAULT_NAME = "Solar PV"
 
 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
     {
         vol.Required(CONF_DEVICE): cv.string,
         vol.Optional(CONF_ADDRESS, default=DEFAULT_ADDRESS): cv.positive_int,
-        vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+        vol.Optional(CONF_NAME, default="Solar PV"): cv.string,
     }
 )
 
 
-def setup_platform(hass, config, add_entities, discovery_info=None):
-    """Set up the Aurora ABB PowerOne device."""
-    devices = []
-    comport = config[CONF_DEVICE]
-    address = config[CONF_ADDRESS]
-    name = config[CONF_NAME]
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+    """Set up based on configuration.yaml (DEPRECATED)."""
+    _LOGGER.warning(
+        "Loading aurora_abb_powerone via platform config is deprecated; The configuration"
+        " has been migrated to a config entry and can be safely removed from configuration.yaml"
+    )
+    hass.async_create_task(
+        hass.config_entries.flow.async_init(
+            DOMAIN, context={"source": SOURCE_IMPORT}, data=config
+        )
+    )
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities) -> None:
+    """Set up aurora_abb_powerone sensor based on a config entry."""
+    entities = []
+
+    sensortypes = [
+        {"parameter": "instantaneouspower", "name": "Power Output"},
+        {"parameter": "temperature", "name": "Temperature"},
+    ]
+    client = hass.data[DOMAIN][config_entry.unique_id]
+    data = config_entry.data
 
-    _LOGGER.debug("Intitialising com port=%s address=%s", comport, address)
-    client = AuroraSerialClient(address, comport, parity="N", timeout=1)
+    for sens in sensortypes:
+        entities.append(AuroraSensor(client, data, sens["name"], sens["parameter"]))
 
-    devices.append(AuroraABBSolarPVMonitorSensor(client, name, "Power"))
-    add_entities(devices, True)
+    _LOGGER.debug("async_setup_entry adding %d entities", len(entities))
+    async_add_entities(entities, True)
 
 
-class AuroraABBSolarPVMonitorSensor(SensorEntity):
-    """Representation of a Sensor."""
+class AuroraSensor(AuroraDevice, SensorEntity):
+    """Representation of a Sensor on a Aurora ABB PowerOne Solar inverter."""
 
     _attr_state_class = STATE_CLASS_MEASUREMENT
-    _attr_native_unit_of_measurement = POWER_WATT
-    _attr_device_class = DEVICE_CLASS_POWER
 
-    def __init__(self, client, name, typename):
+    def __init__(self, client: AuroraSerialClient, data, name, typename):
         """Initialize the sensor."""
-        self._attr_name = f"{name} {typename}"
-        self.client = client
+        super().__init__(client, data)
+        if typename == "instantaneouspower":
+            self.type = typename
+            self._attr_unit_of_measurement = POWER_WATT
+            self._attr_device_class = DEVICE_CLASS_POWER
+        elif typename == "temperature":
+            self.type = typename
+            self._attr_native_unit_of_measurement = TEMP_CELSIUS
+            self._attr_device_class = DEVICE_CLASS_TEMPERATURE
+        else:
+            raise InvalidStateError(f"Unrecognised typename '{typename}'")
+        self._attr_name = f"{name}"
+        self.availableprev = True
 
     def update(self):
         """Fetch new state data for the sensor.
@@ -65,11 +96,21 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity):
         This is the only method that should fetch new data for Home Assistant.
         """
         try:
+            self.availableprev = self._attr_available
             self.client.connect()
-            # read ADC channel 3 (grid power output)
-            power_watts = self.client.measure(3, True)
-            self._attr_native_value = round(power_watts, 1)
+            if self.type == "instantaneouspower":
+                # read ADC channel 3 (grid power output)
+                power_watts = self.client.measure(3, True)
+                self._attr_state = round(power_watts, 1)
+            elif self.type == "temperature":
+                temperature_c = self.client.measure(21)
+                self._attr_native_value = round(temperature_c, 1)
+            self._attr_available = True
+
         except AuroraError as error:
+            self._attr_state = None
+            self._attr_native_value = None
+            self._attr_available = False
             # aurorapy does not have different exceptions (yet) for dealing
             # with timeout vs other comms errors.
             # This means the (normal) situation of no response during darkness
@@ -82,7 +123,14 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity):
                 _LOGGER.debug("No response from inverter (could be dark)")
             else:
                 raise error
-            self._attr_native_value = None
         finally:
+            if self._attr_available != self.availableprev:
+                if self._attr_available:
+                    _LOGGER.info("Communication with %s back online", self.name)
+                else:
+                    _LOGGER.warning(
+                        "Communication with %s lost",
+                        self.name,
+                    )
             if self.client.serline.isOpen():
                 self.client.close()
diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json
new file mode 100644
index 00000000000..b705c5f69a5
--- /dev/null
+++ b/homeassistant/components/aurora_abb_powerone/strings.json
@@ -0,0 +1,23 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel",
+        "data": {
+          "port": "RS485 or USB-RS485 Adaptor Port",
+          "address": "Inverter Address"
+        }
+      }
+    },
+    "error": {
+      "cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)",
+      "invalid_serial_port": "Serial port is not a valid device or could not be openned",
+      "cannot_open_serial_port": "Cannot open serial port, please check and try again",
+      "unknown": "[%key:common::config_flow::error::unknown%]"
+    },
+    "abort": {
+      "already_configured": "Device is already configured",
+      "no_serial_ports": "No com ports found.  Need a valid RS485 device to communicate."
+    }
+  }
+}
diff --git a/homeassistant/components/aurora_abb_powerone/translations/en.json b/homeassistant/components/aurora_abb_powerone/translations/en.json
new file mode 100644
index 00000000000..97e0fc50908
--- /dev/null
+++ b/homeassistant/components/aurora_abb_powerone/translations/en.json
@@ -0,0 +1,23 @@
+{
+    "config": {
+        "abort": {
+            "already_configured": "Device is already configured",
+            "no_serial_ports": "No com ports found.  Need a valid RS485 device to communicate."
+        },
+        "error": {
+            "cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)",
+            "cannot_open_serial_port": "Cannot open serial port, please check and try again",
+            "invalid_serial_port": "Serial port is not a valid device or could not be openned",
+            "unknown": "Unexpected error"
+        },
+        "step": {
+            "user": {
+                "data": {
+                    "address": "Inverter Address",
+                    "port": "RS485 or USB-RS485 Adaptor Port"
+                },
+                "description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel"
+            }
+        }
+    }
+}
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index dba466e181c..aef37105170 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -31,6 +31,7 @@ FLOWS = [
     "atag",
     "august",
     "aurora",
+    "aurora_abb_powerone",
     "awair",
     "axis",
     "azure_devops",
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 2eb5501a527..eefe731f51a 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -235,6 +235,9 @@ async-upnp-client==0.22.10
 # homeassistant.components.aurora
 auroranoaa==0.0.2
 
+# homeassistant.components.aurora_abb_powerone
+aurorapy==0.2.6
+
 # homeassistant.components.stream
 av==8.0.3
 
diff --git a/tests/components/aurora_abb_powerone/__init__.py b/tests/components/aurora_abb_powerone/__init__.py
new file mode 100644
index 00000000000..960412f6d97
--- /dev/null
+++ b/tests/components/aurora_abb_powerone/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Aurora ABB PowerOne Solar PV integration."""
diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py
new file mode 100644
index 00000000000..d385d33ddd9
--- /dev/null
+++ b/tests/components/aurora_abb_powerone/test_config_flow.py
@@ -0,0 +1,165 @@
+"""Test the Aurora ABB PowerOne Solar PV config flow."""
+from logging import INFO
+from unittest.mock import patch
+
+from aurorapy.client import AuroraError
+from serial.tools import list_ports_common
+
+from homeassistant import config_entries, data_entry_flow, setup
+from homeassistant.components.aurora_abb_powerone.const import (
+    ATTR_FIRMWARE,
+    ATTR_MODEL,
+    ATTR_SERIAL_NUMBER,
+    DOMAIN,
+)
+from homeassistant.const import CONF_ADDRESS, CONF_PORT
+
+
+async def test_form(hass):
+    """Test we get the form."""
+    await setup.async_setup_component(hass, "persistent_notification", {})
+
+    fakecomports = []
+    fakecomports.append(list_ports_common.ListPortInfo("/dev/ttyUSB7"))
+    with patch(
+        "serial.tools.list_ports.comports",
+        return_value=fakecomports,
+    ):
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN, context={"source": config_entries.SOURCE_USER}
+        )
+    assert result["type"] == "form"
+    assert result["errors"] == {}
+
+    with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None,), patch(
+        "aurorapy.client.AuroraSerialClient.serial_number",
+        return_value="9876543",
+    ), patch(
+        "aurorapy.client.AuroraSerialClient.version",
+        return_value="9.8.7.6",
+    ), patch(
+        "aurorapy.client.AuroraSerialClient.pn",
+        return_value="A.B.C",
+    ), patch(
+        "aurorapy.client.AuroraSerialClient.firmware",
+        return_value="1.234",
+    ), patch(
+        "homeassistant.components.aurora_abb_powerone.config_flow._LOGGER.getEffectiveLevel",
+        return_value=INFO,
+    ) as mock_setup, patch(
+        "homeassistant.components.aurora_abb_powerone.async_setup_entry",
+        return_value=True,
+    ) as mock_setup_entry:
+
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7},
+        )
+
+    assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+    assert result2["data"] == {
+        CONF_PORT: "/dev/ttyUSB7",
+        CONF_ADDRESS: 7,
+        ATTR_FIRMWARE: "1.234",
+        ATTR_MODEL: "9.8.7.6 (A.B.C)",
+        ATTR_SERIAL_NUMBER: "9876543",
+        "title": "PhotoVoltaic Inverters",
+    }
+    await hass.async_block_till_done()
+    assert len(mock_setup.mock_calls) == 1
+    assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_no_comports(hass):
+    """Test we display correct info when there are no com ports.."""
+
+    fakecomports = []
+    with patch(
+        "serial.tools.list_ports.comports",
+        return_value=fakecomports,
+    ):
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN, context={"source": config_entries.SOURCE_USER}
+        )
+    assert result["type"] == "abort"
+    assert result["reason"] == "no_serial_ports"
+
+
+async def test_form_invalid_com_ports(hass):
+    """Test we display correct info when the comport is invalid.."""
+
+    fakecomports = []
+    fakecomports.append(list_ports_common.ListPortInfo("/dev/ttyUSB7"))
+    with patch(
+        "serial.tools.list_ports.comports",
+        return_value=fakecomports,
+    ):
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN, context={"source": config_entries.SOURCE_USER}
+        )
+    assert result["type"] == "form"
+    assert result["errors"] == {}
+
+    with patch(
+        "aurorapy.client.AuroraSerialClient.connect",
+        side_effect=OSError(19, "...no such device..."),
+        return_value=None,
+    ):
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7},
+        )
+    assert result2["errors"] == {"base": "invalid_serial_port"}
+
+    with patch(
+        "aurorapy.client.AuroraSerialClient.connect",
+        side_effect=AuroraError("..could not open port..."),
+        return_value=None,
+    ):
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7},
+        )
+    assert result2["errors"] == {"base": "cannot_open_serial_port"}
+
+    with patch(
+        "aurorapy.client.AuroraSerialClient.connect",
+        side_effect=AuroraError("...No response after..."),
+        return_value=None,
+    ):
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7},
+        )
+    assert result2["errors"] == {"base": "cannot_connect"}
+
+    with patch(
+        "aurorapy.client.AuroraSerialClient.connect",
+        side_effect=AuroraError("...Some other message!!!123..."),
+        return_value=None,
+    ), patch("serial.Serial.isOpen", return_value=True,), patch(
+        "aurorapy.client.AuroraSerialClient.close",
+    ) as mock_clientclose:
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7},
+        )
+    assert result2["errors"] == {"base": "cannot_connect"}
+    assert len(mock_clientclose.mock_calls) == 1
+
+
+# Tests below can be deleted after deprecation period is finished.
+async def test_import(hass):
+    """Test configuration.yaml import used during migration."""
+    TESTDATA = {"device": "/dev/ttyUSB7", "address": 3, "name": "MyAuroraPV"}
+    with patch(
+        "homeassistant.components.generic.camera.GenericCamera.async_camera_image",
+        return_value=None,
+    ):
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA
+        )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+    assert result["data"][CONF_PORT] == "/dev/ttyUSB7"
+    assert result["data"][CONF_ADDRESS] == 3
diff --git a/tests/components/aurora_abb_powerone/test_init.py b/tests/components/aurora_abb_powerone/test_init.py
new file mode 100644
index 00000000000..bd0f1c727cd
--- /dev/null
+++ b/tests/components/aurora_abb_powerone/test_init.py
@@ -0,0 +1,37 @@
+"""Pytest modules for Aurora ABB Powerone PV inverter sensor integration."""
+from unittest.mock import patch
+
+from homeassistant.components.aurora_abb_powerone.const import (
+    ATTR_FIRMWARE,
+    ATTR_MODEL,
+    ATTR_SERIAL_NUMBER,
+    DOMAIN,
+)
+from homeassistant.const import CONF_ADDRESS, CONF_PORT
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry
+
+
+async def test_unload_entry(hass):
+    """Test unloading the aurora_abb_powerone entry."""
+
+    with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
+        "homeassistant.components.aurora_abb_powerone.sensor.AuroraSensor.update",
+        return_value=None,
+    ):
+        mock_entry = MockConfigEntry(
+            domain=DOMAIN,
+            data={
+                CONF_PORT: "/dev/ttyUSB7",
+                CONF_ADDRESS: 7,
+                ATTR_MODEL: "model123",
+                ATTR_SERIAL_NUMBER: "876",
+                ATTR_FIRMWARE: "1.2.3.4",
+            },
+        )
+        mock_entry.add_to_hass(hass)
+        assert await async_setup_component(hass, DOMAIN, {})
+        await hass.async_block_till_done()
+        assert await hass.config_entries.async_unload(mock_entry.entry_id)
+        await hass.async_block_till_done()
diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py
new file mode 100644
index 00000000000..26486c6a116
--- /dev/null
+++ b/tests/components/aurora_abb_powerone/test_sensor.py
@@ -0,0 +1,185 @@
+"""Test the Aurora ABB PowerOne Solar PV sensors."""
+from datetime import timedelta
+from unittest.mock import patch
+
+from aurorapy.client import AuroraError
+import pytest
+
+from homeassistant.components.aurora_abb_powerone.const import (
+    ATTR_DEVICE_NAME,
+    ATTR_FIRMWARE,
+    ATTR_MODEL,
+    ATTR_SERIAL_NUMBER,
+    DEFAULT_INTEGRATION_TITLE,
+    DOMAIN,
+)
+from homeassistant.components.aurora_abb_powerone.sensor import AuroraSensor
+from homeassistant.config_entries import SOURCE_IMPORT
+from homeassistant.const import CONF_ADDRESS, CONF_PORT
+from homeassistant.exceptions import InvalidStateError
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.common import (
+    MockConfigEntry,
+    assert_setup_component,
+    async_fire_time_changed,
+)
+
+TEST_CONFIG = {
+    "sensor": {
+        "platform": "aurora_abb_powerone",
+        "device": "/dev/fakedevice0",
+        "address": 2,
+    }
+}
+
+
+def _simulated_returns(index, global_measure=None):
+    returns = {
+        3: 45.678,  # power
+        21: 9.876,  # temperature
+    }
+    return returns[index]
+
+
+def _mock_config_entry():
+    return MockConfigEntry(
+        version=1,
+        domain=DOMAIN,
+        title=DEFAULT_INTEGRATION_TITLE,
+        data={
+            CONF_PORT: "/dev/usb999",
+            CONF_ADDRESS: 3,
+            ATTR_DEVICE_NAME: "mydevicename",
+            ATTR_MODEL: "mymodel",
+            ATTR_SERIAL_NUMBER: "123456",
+            ATTR_FIRMWARE: "1.2.3.4",
+        },
+        source="dummysource",
+        entry_id="13579",
+    )
+
+
+async def test_setup_platform_valid_config(hass):
+    """Test that (deprecated) yaml import still works."""
+    with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
+        "aurorapy.client.AuroraSerialClient.measure",
+        side_effect=_simulated_returns,
+    ), assert_setup_component(1, "sensor"):
+        assert await async_setup_component(hass, "sensor", TEST_CONFIG)
+        await hass.async_block_till_done()
+    power = hass.states.get("sensor.power_output")
+    assert power
+    assert power.state == "45.7"
+
+    # try to set up a second time - should abort.
+
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        data=TEST_CONFIG,
+        context={"source": SOURCE_IMPORT},
+    )
+    assert result["type"] == "abort"
+    assert result["reason"] == "already_setup"
+
+
+async def test_sensors(hass):
+    """Test data coming back from inverter."""
+    mock_entry = _mock_config_entry()
+
+    with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
+        "aurorapy.client.AuroraSerialClient.measure",
+        side_effect=_simulated_returns,
+    ):
+        mock_entry.add_to_hass(hass)
+        await hass.config_entries.async_setup(mock_entry.entry_id)
+        await hass.async_block_till_done()
+
+        power = hass.states.get("sensor.power_output")
+        assert power
+        assert power.state == "45.7"
+
+        temperature = hass.states.get("sensor.temperature")
+        assert temperature
+        assert temperature.state == "9.9"
+
+
+async def test_sensor_invalid_type(hass):
+    """Test invalid sensor type during setup."""
+    entities = []
+    mock_entry = _mock_config_entry()
+
+    with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
+        "aurorapy.client.AuroraSerialClient.measure",
+        side_effect=_simulated_returns,
+    ):
+        mock_entry.add_to_hass(hass)
+        await hass.config_entries.async_setup(mock_entry.entry_id)
+        await hass.async_block_till_done()
+
+        client = hass.data[DOMAIN][mock_entry.unique_id]
+        data = mock_entry.data
+    with pytest.raises(InvalidStateError):
+        entities.append(AuroraSensor(client, data, "WrongSensor", "wrongparameter"))
+
+
+async def test_sensor_dark(hass):
+    """Test that darkness (no comms) is handled correctly."""
+    mock_entry = _mock_config_entry()
+
+    utcnow = dt_util.utcnow()
+    # sun is up
+    with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
+        "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns
+    ):
+        mock_entry.add_to_hass(hass)
+        await hass.config_entries.async_setup(mock_entry.entry_id)
+        await hass.async_block_till_done()
+
+        power = hass.states.get("sensor.power_output")
+        assert power is not None
+        assert power.state == "45.7"
+
+    # sunset
+    with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
+        "aurorapy.client.AuroraSerialClient.measure",
+        side_effect=AuroraError("No response after 10 seconds"),
+    ):
+        async_fire_time_changed(hass, utcnow + timedelta(seconds=60))
+        await hass.async_block_till_done()
+        power = hass.states.get("sensor.power_output")
+        assert power.state == "unknown"
+    # sun rose again
+    with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
+        "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns
+    ):
+        async_fire_time_changed(hass, utcnow + timedelta(seconds=60))
+        await hass.async_block_till_done()
+        power = hass.states.get("sensor.power_output")
+        assert power is not None
+        assert power.state == "45.7"
+    # sunset
+    with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
+        "aurorapy.client.AuroraSerialClient.measure",
+        side_effect=AuroraError("No response after 10 seconds"),
+    ):
+        async_fire_time_changed(hass, utcnow + timedelta(seconds=60))
+        await hass.async_block_till_done()
+        power = hass.states.get("sensor.power_output")
+        assert power.state == "unknown"  # should this be 'available'?
+
+
+async def test_sensor_unknown_error(hass):
+    """Test other comms error is handled correctly."""
+    mock_entry = _mock_config_entry()
+
+    with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
+        "aurorapy.client.AuroraSerialClient.measure",
+        side_effect=AuroraError("another error"),
+    ):
+        mock_entry.add_to_hass(hass)
+        await hass.config_entries.async_setup(mock_entry.entry_id)
+        await hass.async_block_till_done()
+        power = hass.states.get("sensor.power_output")
+        assert power is None
-- 
GitLab