diff --git a/.coveragerc b/.coveragerc index f40b0c30342..f34b0baac17 100644 --- a/.coveragerc +++ b/.coveragerc @@ -620,6 +620,7 @@ omit = homeassistant/components/solaredge/__init__.py homeassistant/components/solaredge/sensor.py homeassistant/components/solaredge_local/sensor.py + homeassistant/components/solarlog/* homeassistant/components/solax/sensor.py homeassistant/components/soma/cover.py homeassistant/components/soma/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index e1ff7b36ff1..a5ad222323b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -265,6 +265,7 @@ homeassistant/components/smartthings/* @andrewsayre homeassistant/components/smarty/* @z0mbieprocess homeassistant/components/smtp/* @fabaff homeassistant/components/solaredge_local/* @drobtravels @scheric +homeassistant/components/solarlog/* @Ernst79 homeassistant/components/solax/* @squishykid homeassistant/components/soma/* @ratsept homeassistant/components/somfy/* @tetienne diff --git a/homeassistant/components/solarlog/.translations/en.json b/homeassistant/components/solarlog/.translations/en.json new file mode 100644 index 00000000000..5399d5176c9 --- /dev/null +++ b/homeassistant/components/solarlog/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "Solar-Log", + "step": { + "user": { + "title": "Define your Solar-Log connection", + "data": { + "host": "The hostname or ip-address of your Solar-Log device", + "name": "The prefix to be used for your Solar-Log sensors" + } + } + }, + "error": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect, please verify host address" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py new file mode 100644 index 00000000000..c8035e1f7e6 --- /dev/null +++ b/homeassistant/components/solarlog/__init__.py @@ -0,0 +1,21 @@ +"""Solar-Log integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + + +async def async_setup(hass, config): + """Component setup, do nothing.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up a config entry for solarlog.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.config_entries.async_forward_entry_unload(entry, "sensor") diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py new file mode 100644 index 00000000000..5cb2d5deec1 --- /dev/null +++ b/homeassistant/components/solarlog/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for solarlog integration.""" +import logging +from urllib.parse import ParseResult, urlparse + +from requests.exceptions import HTTPError, Timeout +from sunwatcher.solarlog.solarlog import SolarLog +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.util import slugify + +from .const import DEFAULT_HOST, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@callback +def solarlog_entries(hass: HomeAssistant): + """Return the hosts already configured.""" + return set( + entry.data[CONF_HOST] for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +class SolarLogConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for solarlog.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors = {} + + def _host_in_configuration_exists(self, host) -> bool: + """Return True if host exists in configuration.""" + if host in solarlog_entries(self.hass): + return True + return False + + async def _test_connection(self, host): + """Check if we can connect to the Solar-Log device.""" + try: + await self.hass.async_add_executor_job(SolarLog, host) + return True + except (OSError, HTTPError, Timeout): + self._errors[CONF_HOST] = "cannot_connect" + _LOGGER.error( + "Could not connect to Solar-Log device at %s, check host ip address", + host, + ) + return False + + async def async_step_user(self, user_input=None): + """Step when user intializes a integration.""" + self._errors = {} + if user_input is not None: + # set some defaults in case we need to return to the form + name = slugify(user_input.get(CONF_NAME, DEFAULT_NAME)) + host_entry = user_input.get(CONF_HOST, DEFAULT_HOST) + + url = urlparse(host_entry, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + host = url.geturl() + + if self._host_in_configuration_exists(host): + self._errors[CONF_HOST] = "already_configured" + else: + if await self._test_connection(host): + return self.async_create_entry(title=name, data={CONF_HOST: host}) + else: + user_input = {} + user_input[CONF_NAME] = DEFAULT_NAME + user_input[CONF_HOST] = DEFAULT_HOST + + 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_HOST, default=user_input.get(CONF_HOST, DEFAULT_HOST) + ): str, + } + ), + errors=self._errors, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + host_entry = user_input.get(CONF_HOST, DEFAULT_HOST) + + url = urlparse(host_entry, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + host = url.geturl() + + if self._host_in_configuration_exists(host): + return self.async_abort(reason="already_configured") + return await self.async_step_user(user_input) diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py new file mode 100644 index 00000000000..67eb8006cec --- /dev/null +++ b/homeassistant/components/solarlog/const.py @@ -0,0 +1,89 @@ +"""Constants for the Solar-Log integration.""" +from datetime import timedelta + +from homeassistant.const import POWER_WATT, ENERGY_KILO_WATT_HOUR + +DOMAIN = "solarlog" + +"""Default config for solarlog.""" +DEFAULT_HOST = "http://solar-log" +DEFAULT_NAME = "solarlog" + +"""Fixed constants.""" +SCAN_INTERVAL = timedelta(seconds=60) + +"""Supported sensor types.""" +SENSOR_TYPES = { + "time": ["TIME", "last update", None, "mdi:calendar-clock"], + "power_ac": ["powerAC", "power AC", POWER_WATT, "mdi:solar-power"], + "power_dc": ["powerDC", "power DC", POWER_WATT, "mdi:solar-power"], + "voltage_ac": ["voltageAC", "voltage AC", "V", "mdi:flash"], + "voltage_dc": ["voltageDC", "voltage DC", "V", "mdi:flash"], + "yield_day": ["yieldDAY", "yield day", ENERGY_KILO_WATT_HOUR, "mdi:solar-power"], + "yield_yesterday": [ + "yieldYESTERDAY", + "yield yesterday", + ENERGY_KILO_WATT_HOUR, + "mdi:solar-power", + ], + "yield_month": [ + "yieldMONTH", + "yield month", + ENERGY_KILO_WATT_HOUR, + "mdi:solar-power", + ], + "yield_year": ["yieldYEAR", "yield year", ENERGY_KILO_WATT_HOUR, "mdi:solar-power"], + "yield_total": [ + "yieldTOTAL", + "yield total", + ENERGY_KILO_WATT_HOUR, + "mdi:solar-power", + ], + "consumption_ac": ["consumptionAC", "consumption AC", POWER_WATT, "mdi:power-plug"], + "consumption_day": [ + "consumptionDAY", + "consumption day", + ENERGY_KILO_WATT_HOUR, + "mdi:power-plug", + ], + "consumption_yesterday": [ + "consumptionYESTERDAY", + "consumption yesterday", + ENERGY_KILO_WATT_HOUR, + "mdi:power-plug", + ], + "consumption_month": [ + "consumptionMONTH", + "consumption month", + ENERGY_KILO_WATT_HOUR, + "mdi:power-plug", + ], + "consumption_year": [ + "consumptionYEAR", + "consumption year", + ENERGY_KILO_WATT_HOUR, + "mdi:power-plug", + ], + "consumption_total": [ + "consumptionTOTAL", + "consumption total", + ENERGY_KILO_WATT_HOUR, + "mdi:power-plug", + ], + "total_power": ["totalPOWER", "total power", "Wp", "mdi:solar-power"], + "alternator_loss": [ + "alternatorLOSS", + "alternator loss", + POWER_WATT, + "mdi:solar-power", + ], + "capacity": ["CAPACITY", "capacity", "%", "mdi:solar-power"], + "efficiency": ["EFFICIENCY", "efficiency", "% W/Wp", "mdi:solar-power"], + "power_available": [ + "powerAVAILABLE", + "power available", + POWER_WATT, + "mdi:solar-power", + ], + "usage": ["USAGE", "usage", None, "mdi:solar-power"], +} diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json new file mode 100644 index 00000000000..9331628e027 --- /dev/null +++ b/homeassistant/components/solarlog/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "solarlog", + "name": "Solar-Log", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integration/solarlog", + "dependencies": [], + "codeowners": ["@Ernst79"], + "requirements": ["sunwatcher==0.2.1"] +} diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py new file mode 100644 index 00000000000..583529ffe87 --- /dev/null +++ b/homeassistant/components/solarlog/sensor.py @@ -0,0 +1,159 @@ +"""Platform for solarlog sensors.""" +import logging +from urllib.parse import ParseResult, urlparse + +from requests.exceptions import HTTPError, Timeout +from sunwatcher.solarlog.solarlog import SolarLog +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +from .const import DOMAIN, DEFAULT_HOST, DEFAULT_NAME, SCAN_INTERVAL, SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Import YAML configuration when available.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config) + ) + ) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Add solarlog entry.""" + host_entry = entry.data[CONF_HOST] + + url = urlparse(host_entry, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + host = url.geturl() + + platform_name = entry.title + + try: + api = await hass.async_add_executor_job(SolarLog, host) + _LOGGER.debug("Connected to Solar-Log device, setting up entries") + except (OSError, HTTPError, Timeout): + _LOGGER.error( + "Could not connect to Solar-Log device at %s, check host ip address", host + ) + return + + # Create solarlog data service which will retrieve and update the data. + data = await hass.async_add_executor_job(SolarlogData, hass, api, host) + + # Create a new sensor for each sensor type. + entities = [] + for sensor_key in SENSOR_TYPES: + sensor = SolarlogSensor(platform_name, sensor_key, data) + entities.append(sensor) + + async_add_entities(entities, True) + return True + + +class SolarlogSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, platform_name, sensor_key, data): + """Initialize the sensor.""" + self.platform_name = platform_name + self.sensor_key = sensor_key + self.data = data + self._state = None + + self._json_key = SENSOR_TYPES[self.sensor_key][0] + self._unit_of_measurement = SENSOR_TYPES[self.sensor_key][2] + + @property + def name(self): + """Return the name of the sensor.""" + return "{} ({})".format(self.platform_name, SENSOR_TYPES[self.sensor_key][1]) + + @property + def unit_of_measurement(self): + """Return the state of the sensor.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the sensor icon.""" + return SENSOR_TYPES[self.sensor_key][3] + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Get the latest data from the sensor and update the state.""" + self.data.update() + self._state = self.data.data[self._json_key] + + +class SolarlogData: + """Get and update the latest data.""" + + def __init__(self, hass, api, host): + """Initialize the data object.""" + self.api = api + self.hass = hass + self.host = host + self.update = Throttle(SCAN_INTERVAL)(self._update) + self.data = {} + + def _update(self): + """Update the data from the SolarLog device.""" + try: + self.api = SolarLog(self.host) + response = self.api.time + _LOGGER.debug( + "Connection to Solarlog successful. Retrieving latest Solarlog update of %s", + response, + ) + except (OSError, Timeout, HTTPError): + _LOGGER.error("Connection error, Could not retrieve data, skipping update") + return + + try: + self.data["TIME"] = self.api.time + self.data["powerAC"] = self.api.power_ac + self.data["powerDC"] = self.api.power_dc + self.data["voltageAC"] = self.api.voltage_ac + self.data["voltageDC"] = self.api.voltage_dc + self.data["yieldDAY"] = self.api.yield_day / 1000 + self.data["yieldYESTERDAY"] = self.api.yield_yesterday / 1000 + self.data["yieldMONTH"] = self.api.yield_month / 1000 + self.data["yieldYEAR"] = self.api.yield_year / 1000 + self.data["yieldTOTAL"] = self.api.yield_total / 1000 + self.data["consumptionAC"] = self.api.consumption_ac + self.data["consumptionDAY"] = self.api.consumption_day / 1000 + self.data["consumptionYESTERDAY"] = self.api.consumption_yesterday / 1000 + self.data["consumptionMONTH"] = self.api.consumption_month / 1000 + self.data["consumptionYEAR"] = self.api.consumption_year / 1000 + self.data["consumptionTOTAL"] = self.api.consumption_total / 1000 + self.data["totalPOWER"] = self.api.total_power + self.data["alternatorLOSS"] = self.api.alternator_loss + self.data["CAPACITY"] = round(self.api.capacity * 100, 0) + self.data["EFFICIENCY"] = round(self.api.efficiency * 100, 0) + self.data["powerAVAILABLE"] = self.api.power_available + self.data["USAGE"] = self.api.usage + _LOGGER.debug("Updated Solarlog overview data: %s", self.data) + except AttributeError: + _LOGGER.error("Missing details data in Solarlog response") diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json new file mode 100644 index 00000000000..5399d5176c9 --- /dev/null +++ b/homeassistant/components/solarlog/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "Solar-Log", + "step": { + "user": { + "title": "Define your Solar-Log connection", + "data": { + "host": "The hostname or ip-address of your Solar-Log device", + "name": "The prefix to be used for your Solar-Log sensors" + } + } + }, + "error": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect, please verify host address" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 60aa610ec07..bf63869bc9b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -60,6 +60,7 @@ FLOWS = [ "smartthings", "smhi", "solaredge", + "solarlog", "soma", "somfy", "sonos", diff --git a/requirements_all.txt b/requirements_all.txt index 884155f3b4c..868855ed851 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1842,6 +1842,9 @@ stringcase==1.2.0 # homeassistant.components.ecovacs sucks==0.9.4 +# homeassistant.components.solarlog +sunwatcher==0.2.1 + # homeassistant.components.swiss_hydrological_data swisshydrodata==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71cbac4de0f..2fd91e21d96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -583,6 +583,9 @@ statsd==3.2.1 # homeassistant.components.traccar stringcase==1.2.0 +# homeassistant.components.solarlog +sunwatcher==0.2.1 + # homeassistant.components.tellduslive tellduslive==0.10.10 diff --git a/tests/components/solarlog/__init__.py b/tests/components/solarlog/__init__.py new file mode 100644 index 00000000000..9074cab8416 --- /dev/null +++ b/tests/components/solarlog/__init__.py @@ -0,0 +1 @@ +"""Tests for the solarlog integration.""" diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py new file mode 100644 index 00000000000..86f3b05d975 --- /dev/null +++ b/tests/components/solarlog/test_config_flow.py @@ -0,0 +1,135 @@ +"""Test the solarlog config flow.""" +from unittest.mock import patch +import pytest + +from homeassistant import data_entry_flow +from homeassistant import config_entries, setup +from homeassistant.components.solarlog import config_flow +from homeassistant.components.solarlog.const import DEFAULT_HOST, DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME + +from tests.common import MockConfigEntry, mock_coro + +NAME = "Solarlog test 1 2 3" +HOST = "http://1.1.1.1" + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.solarlog.config_flow.SolarLogConfigFlow._test_connection", + return_value=mock_coro({"title": "solarlog test 1 2 3"}), + ), patch( + "homeassistant.components.solarlog.async_setup", return_value=mock_coro(True) + ) as mock_setup, patch( + "homeassistant.components.solarlog.async_setup_entry", + return_value=mock_coro(True), + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": HOST, "name": NAME} + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "solarlog_test_1_2_3" + assert result2["data"] == {"host": "http://1.1.1.1"} + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.fixture(name="test_connect") +def mock_controller(): + """Mock a successfull _host_in_configuration_exists.""" + with patch( + "homeassistant.components.solarlog.config_flow.SolarLogConfigFlow._test_connection", + side_effect=lambda *_: mock_coro(True), + ): + yield + + +def init_config_flow(hass): + """Init a configuration flow.""" + flow = config_flow.SolarLogConfigFlow() + flow.hass = hass + return flow + + +async def test_user(hass, test_connect): + """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_HOST: HOST}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solarlog_test_1_2_3" + assert result["data"][CONF_HOST] == HOST + + +async def test_import(hass, test_connect): + """Test import step.""" + flow = init_config_flow(hass) + + # import with only host + result = await flow.async_step_import({CONF_HOST: HOST}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solarlog" + assert result["data"][CONF_HOST] == HOST + + # import with only name + result = await flow.async_step_import({CONF_NAME: NAME}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solarlog_test_1_2_3" + assert result["data"][CONF_HOST] == DEFAULT_HOST + + # import with host and name + result = await flow.async_step_import({CONF_HOST: HOST, CONF_NAME: NAME}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solarlog_test_1_2_3" + assert result["data"][CONF_HOST] == HOST + + +async def test_abort_if_already_setup(hass, test_connect): + """Test we abort if the device is already setup.""" + flow = init_config_flow(hass) + MockConfigEntry( + domain="solarlog", data={CONF_NAME: NAME, CONF_HOST: HOST} + ).add_to_hass(hass) + + # Should fail, same HOST different NAME (default) + result = await flow.async_step_import( + {CONF_HOST: HOST, CONF_NAME: "solarlog_test_7_8_9"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Should fail, same HOST and NAME + result = await flow.async_step_user({CONF_HOST: HOST, CONF_NAME: NAME}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "already_configured"} + + # SHOULD pass, diff HOST (without http://), different NAME + result = await flow.async_step_import( + {CONF_HOST: "2.2.2.2", CONF_NAME: "solarlog_test_7_8_9"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solarlog_test_7_8_9" + assert result["data"][CONF_HOST] == "http://2.2.2.2" + + # SHOULD pass, diff HOST, same NAME + result = await flow.async_step_import( + {CONF_HOST: "http://2.2.2.2", CONF_NAME: NAME} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solarlog_test_1_2_3" + assert result["data"][CONF_HOST] == "http://2.2.2.2"