diff --git a/.coveragerc b/.coveragerc index e96340dde1c..c30c78ddf65 100644 --- a/.coveragerc +++ b/.coveragerc @@ -576,6 +576,7 @@ omit = homeassistant/components/snmp/* homeassistant/components/sochain/sensor.py homeassistant/components/socialblade/sensor.py + homeassistant/components/solaredge/__init__.py homeassistant/components/solaredge/sensor.py homeassistant/components/solaredge_local/sensor.py homeassistant/components/solax/sensor.py diff --git a/homeassistant/components/solaredge/.translations/en.json b/homeassistant/components/solaredge/.translations/en.json new file mode 100644 index 00000000000..3265e3bb1b0 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "SolarEdge", + "step": { + "user": { + "title": "Define the API parameters for this installation", + "data": { + "name": "The name of this installation", + "site_id": "The SolarEdge site-id", + "api_key": "The API key for this site" + } + } + }, + "error": { + "site_exists": "This site_id is already configured" + }, + "abort": { + "site_exists": "This site_id is already configured" + } + } +} diff --git a/homeassistant/components/solaredge/__init__.py b/homeassistant/components/solaredge/__init__.py index b675126c5fd..8909b970aaf 100644 --- a/homeassistant/components/solaredge/__init__.py +++ b/homeassistant/components/solaredge/__init__.py @@ -1 +1,43 @@ """The solaredge component.""" +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DEFAULT_NAME, DOMAIN, CONF_SITE_ID + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_SITE_ID): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Platform setup, do nothing.""" + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config[DOMAIN]) + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Load the saved entities.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + return True diff --git a/homeassistant/components/solaredge/config_flow.py b/homeassistant/components/solaredge/config_flow.py new file mode 100644 index 00000000000..67f05d83aa0 --- /dev/null +++ b/homeassistant/components/solaredge/config_flow.py @@ -0,0 +1,98 @@ +"""Config flow for the SolarEdge platform.""" +import solaredge +import voluptuous as vol +from requests.exceptions import HTTPError, ConnectTimeout + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.util import slugify + +from .const import DOMAIN, DEFAULT_NAME, CONF_SITE_ID + + +@callback +def solaredge_entries(hass: HomeAssistant): + """Return the site_ids for the domain.""" + return set( + (entry.data[CONF_SITE_ID]) + for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +class SolarEdgeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors = {} + + def _site_in_configuration_exists(self, site_id) -> bool: + """Return True if site_id exists in configuration.""" + if site_id in solaredge_entries(self.hass): + return True + return False + + def _check_site(self, site_id, api_key) -> bool: + """Check if we can connect to the soleredge api service.""" + api = solaredge.Solaredge(api_key) + try: + response = api.get_details(site_id) + except (ConnectTimeout, HTTPError): + self._errors[CONF_SITE_ID] = "could_not_connect" + return False + try: + if response["details"]["status"].lower() != "active": + self._errors[CONF_SITE_ID] = "site_not_active" + return False + except KeyError: + self._errors[CONF_SITE_ID] = "api_failure" + return False + return True + + async def async_step_user(self, user_input=None): + """Step when user intializes a integration.""" + self._errors = {} + if user_input is not None: + name = slugify(user_input.get(CONF_NAME, DEFAULT_NAME)) + if self._site_in_configuration_exists(user_input[CONF_SITE_ID]): + self._errors[CONF_SITE_ID] = "site_exists" + else: + site = user_input[CONF_SITE_ID] + api = user_input[CONF_API_KEY] + can_connect = await self.hass.async_add_executor_job( + self._check_site, site, api + ) + if can_connect: + return self.async_create_entry( + title=name, data={CONF_SITE_ID: site, CONF_API_KEY: api} + ) + + else: + user_input = {} + user_input[CONF_NAME] = DEFAULT_NAME + user_input[CONF_SITE_ID] = "" + user_input[CONF_API_KEY] = "" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) + ): str, + vol.Required(CONF_SITE_ID, default=user_input[CONF_SITE_ID]): str, + vol.Required(CONF_API_KEY, default=user_input[CONF_API_KEY]): str, + } + ), + errors=self._errors, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + if self._site_in_configuration_exists(user_input[CONF_SITE_ID]): + return self.async_abort(reason="site_exists") + return await self.async_step_user(user_input) diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py new file mode 100644 index 00000000000..0d3d1a0cb5f --- /dev/null +++ b/homeassistant/components/solaredge/const.py @@ -0,0 +1,68 @@ +"""Constants for the SolarEdge Monitoring API.""" +from datetime import timedelta + +from homeassistant.const import POWER_WATT, ENERGY_WATT_HOUR + +DOMAIN = "solaredge" + +# Config for solaredge monitoring api requests. +CONF_SITE_ID = "site_id" + +DEFAULT_NAME = "SolarEdge" + +OVERVIEW_UPDATE_DELAY = timedelta(minutes=10) +DETAILS_UPDATE_DELAY = timedelta(hours=12) +INVENTORY_UPDATE_DELAY = timedelta(hours=12) +POWER_FLOW_UPDATE_DELAY = timedelta(minutes=10) + +SCAN_INTERVAL = timedelta(minutes=10) + +# Supported overview sensor types: +# Key: ['json_key', 'name', unit, icon, default] +SENSOR_TYPES = { + "lifetime_energy": [ + "lifeTimeData", + "Lifetime energy", + ENERGY_WATT_HOUR, + "mdi:solar-power", + False, + ], + "energy_this_year": [ + "lastYearData", + "Energy this year", + ENERGY_WATT_HOUR, + "mdi:solar-power", + False, + ], + "energy_this_month": [ + "lastMonthData", + "Energy this month", + ENERGY_WATT_HOUR, + "mdi:solar-power", + False, + ], + "energy_today": [ + "lastDayData", + "Energy today", + ENERGY_WATT_HOUR, + "mdi:solar-power", + False, + ], + "current_power": [ + "currentPower", + "Current Power", + POWER_WATT, + "mdi:solar-power", + True, + ], + "site_details": [None, "Site details", None, None, False], + "meters": ["meters", "Meters", None, None, False], + "sensors": ["sensors", "Sensors", None, None, False], + "gateways": ["gateways", "Gateways", None, None, False], + "batteries": ["batteries", "Batteries", None, None, False], + "inverters": ["inverters", "Inverters", None, None, False], + "power_consumption": ["LOAD", "Power Consumption", None, "mdi:flash", False], + "solar_power": ["PV", "Solar Power", None, "mdi:solar-power", False], + "grid_power": ["GRID", "Grid Power", None, "mdi:power-plug", False], + "storage_power": ["STORAGE", "Storage Power", None, "mdi:car-battery", False], +} diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json index b2707a0a937..7452790cd60 100644 --- a/homeassistant/components/solaredge/manifest.json +++ b/homeassistant/components/solaredge/manifest.json @@ -6,6 +6,7 @@ "solaredge==0.0.2", "stringcase==1.2.0" ], + "config_flow": true, "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index cad81c3c338..896596a2a34 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -1,102 +1,39 @@ """Support for SolarEdge Monitoring API.""" - -from datetime import timedelta import logging - -import voluptuous as vol +import solaredge from requests.exceptions import HTTPError, ConnectTimeout -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_API_KEY, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - POWER_WATT, - ENERGY_WATT_HOUR, -) -import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_API_KEY from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -# Config for solaredge monitoring api requests. -CONF_SITE_ID = "site_id" - -OVERVIEW_UPDATE_DELAY = timedelta(minutes=10) -DETAILS_UPDATE_DELAY = timedelta(hours=12) -INVENTORY_UPDATE_DELAY = timedelta(hours=12) -POWER_FLOW_UPDATE_DELAY = timedelta(minutes=10) - -SCAN_INTERVAL = timedelta(minutes=10) - -# Supported overview sensor types: -# Key: ['json_key', 'name', unit, icon] -SENSOR_TYPES = { - "lifetime_energy": [ - "lifeTimeData", - "Lifetime energy", - ENERGY_WATT_HOUR, - "mdi:solar-power", - ], - "energy_this_year": [ - "lastYearData", - "Energy this year", - ENERGY_WATT_HOUR, - "mdi:solar-power", - ], - "energy_this_month": [ - "lastMonthData", - "Energy this month", - ENERGY_WATT_HOUR, - "mdi:solar-power", - ], - "energy_today": [ - "lastDayData", - "Energy today", - ENERGY_WATT_HOUR, - "mdi:solar-power", - ], - "current_power": ["currentPower", "Current Power", POWER_WATT, "mdi:solar-power"], - "site_details": [None, "Site details", None, None], - "meters": ["meters", "Meters", None, None], - "sensors": ["sensors", "Sensors", None, None], - "gateways": ["gateways", "Gateways", None, None], - "batteries": ["batteries", "Batteries", None, None], - "inverters": ["inverters", "Inverters", None, None], - "power_consumption": ["LOAD", "Power Consumption", None, "mdi:flash"], - "solar_power": ["PV", "Solar Power", None, "mdi:solar-power"], - "grid_power": ["GRID", "Grid Power", None, "mdi:power-plug"], - "storage_power": ["STORAGE", "Storage Power", None, "mdi:car-battery"], -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_SITE_ID): cv.string, - vol.Optional(CONF_NAME, default="SolarEdge"): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=["current_power"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - } +from .const import ( + CONF_SITE_ID, + OVERVIEW_UPDATE_DELAY, + DETAILS_UPDATE_DELAY, + INVENTORY_UPDATE_DELAY, + POWER_FLOW_UPDATE_DELAY, + SENSOR_TYPES, ) _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Create the SolarEdge Monitoring API sensor.""" - import solaredge +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old configuration.""" + pass - api_key = config[CONF_API_KEY] - site_id = config[CONF_SITE_ID] - platform_name = config[CONF_NAME] - # Create new SolarEdge object to retrieve data - api = solaredge.Solaredge(api_key) +async def async_setup_entry(hass, entry, async_add_entities): + """Add an solarEdge entry.""" + # Add the needed sensors to hass + api = solaredge.Solaredge(entry.data[CONF_API_KEY]) # Check if api can be reached and site is active try: - response = api.get_details(site_id) - + response = await hass.async_add_executor_job( + api.get_details, entry.data[CONF_SITE_ID] + ) if response["details"]["status"].lower() != "active": _LOGGER.error("SolarEdge site is not active") return @@ -108,17 +45,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Could not retrieve details from SolarEdge API") return - # Create sensor factory that will create sensors based on sensor_key. - sensor_factory = SolarEdgeSensorFactory(platform_name, site_id, api) - - # Create a new sensor for each sensor type. + sensor_factory = SolarEdgeSensorFactory(entry.title, entry.data[CONF_SITE_ID], api) entities = [] - for sensor_key in config[CONF_MONITORED_CONDITIONS]: + for sensor_key in SENSOR_TYPES: sensor = sensor_factory.create_sensor(sensor_key) if sensor is not None: entities.append(sensor) - - add_entities(entities, True) + async_add_entities(entities) class SolarEdgeSensorFactory: diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json new file mode 100644 index 00000000000..3265e3bb1b0 --- /dev/null +++ b/homeassistant/components/solaredge/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "SolarEdge", + "step": { + "user": { + "title": "Define the API parameters for this installation", + "data": { + "name": "The name of this installation", + "site_id": "The SolarEdge site-id", + "api_key": "The API key for this site" + } + } + }, + "error": { + "site_exists": "This site_id is already configured" + }, + "abort": { + "site_exists": "This site_id is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1dffe2d8e6b..a2f9c26949a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -50,6 +50,7 @@ FLOWS = [ "simplisafe", "smartthings", "smhi", + "solaredge", "somfy", "sonos", "tellduslive", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db81eee7e18..8582683d5f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -386,6 +386,9 @@ sleepyq==0.7 # homeassistant.components.smhi smhi-pkg==1.0.10 +# homeassistant.components.solaredge +solaredge==0.0.2 + # homeassistant.components.honeywell somecomfort==0.5.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7c49055131b..ad6507b4e9e 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -157,6 +157,7 @@ TEST_REQUIREMENTS = ( "simplisafe-python", "sleepyq", "smhi-pkg", + "solaredge", "somecomfort", "sqlalchemy", "srpenergy", diff --git a/tests/components/solaredge/__init__.py b/tests/components/solaredge/__init__.py new file mode 100644 index 00000000000..c2a54cfafb6 --- /dev/null +++ b/tests/components/solaredge/__init__.py @@ -0,0 +1 @@ +"""Tests for the SolarEdge component.""" diff --git a/tests/components/solaredge/test_config_flow.py b/tests/components/solaredge/test_config_flow.py new file mode 100644 index 00000000000..c1183147bac --- /dev/null +++ b/tests/components/solaredge/test_config_flow.py @@ -0,0 +1,132 @@ +"""Tests for the SolarEdge config flow.""" +import pytest +from requests.exceptions import HTTPError, ConnectTimeout +from unittest.mock import patch, Mock + +from homeassistant import data_entry_flow +from homeassistant.components.solaredge import config_flow +from homeassistant.components.solaredge.const import CONF_SITE_ID, DEFAULT_NAME +from homeassistant.const import CONF_NAME, CONF_API_KEY + +from tests.common import MockConfigEntry + +NAME = "solaredge site 1 2 3" +SITE_ID = "1a2b3c4d5e6f7g8h" +API_KEY = "a1b2c3d4e5f6g7h8" + + +@pytest.fixture(name="test_api") +def mock_controller(): + """Mock a successfull Solaredge API.""" + api = Mock() + api.get_details.return_value = {"details": {"status": "active"}} + with patch("solaredge.Solaredge", return_value=api): + yield api + + +def init_config_flow(hass): + """Init a configuration flow.""" + flow = config_flow.SolarEdgeConfigFlow() + flow.hass = hass + return flow + + +async def test_user(hass, test_api): + """Test user config.""" + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # tets with all provided + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solaredge_site_1_2_3" + assert result["data"][CONF_SITE_ID] == SITE_ID + assert result["data"][CONF_API_KEY] == API_KEY + + +async def test_import(hass, test_api): + """Test import step.""" + flow = init_config_flow(hass) + + # import with site_id and api_key + result = await flow.async_step_import( + {CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solaredge" + assert result["data"][CONF_SITE_ID] == SITE_ID + assert result["data"][CONF_API_KEY] == API_KEY + + # import with all + result = await flow.async_step_import( + {CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID, CONF_NAME: NAME} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solaredge_site_1_2_3" + assert result["data"][CONF_SITE_ID] == SITE_ID + assert result["data"][CONF_API_KEY] == API_KEY + + +async def test_abort_if_already_setup(hass, test_api): + """Test we abort if the site_id is already setup.""" + flow = init_config_flow(hass) + MockConfigEntry( + domain="solaredge", + data={CONF_NAME: DEFAULT_NAME, CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, + ).add_to_hass(hass) + + # import: Should fail, same SITE_ID + result = await flow.async_step_import( + {CONF_NAME: DEFAULT_NAME, CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "site_exists" + + # user: Should fail, same SITE_ID + result = await flow.async_step_user( + {CONF_NAME: "test", CONF_SITE_ID: SITE_ID, CONF_API_KEY: "test"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_SITE_ID: "site_exists"} + + +async def test_asserts(hass, test_api): + """Test the _site_in_configuration_exists method.""" + flow = init_config_flow(hass) + + # test with inactive site + test_api.get_details.return_value = {"details": {"status": "NOK"}} + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_SITE_ID: "site_not_active"} + + # test with api_failure + test_api.get_details.return_value = {} + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_SITE_ID: "api_failure"} + + # test with ConnectionTimeout + test_api.get_details.side_effect = ConnectTimeout() + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_SITE_ID: "could_not_connect"} + + # test with HTTPError + test_api.get_details.side_effect = HTTPError() + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_SITE_ID: "could_not_connect"}