Add Solar-Log platform (#27036)

* Add Solar-Log sensor

* Codeowners update

* Update homeassistant/components/solarlog/manifest.json

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>

* remove sunwatcher from gen_requirements_all.py

* remove sunwatcher from requirements_test_all.txt

* Remove scan_interval as configuration variable

I've set it to a fixed scan_interval of 1 minute. Removed the configuration option.

* Fix black format

* Config flow added (__init__.py)

* Config flow added (manifest.json)

* Config flow added (const.py)

* Config flow added (config_flow.py)

* Config flow added (strings.json)

* Config flow added (en.json translation)

* Config flow added (sensor.py rewritten)

* Config flow added (sensor.py)

* Config flow added (config_flows.py)

* resolve conflict config_flows.py

* Add tests

* add tests

* add tests

* Update .coverage to include all files for solarlog

* Fix await the unload

* Adjust icons, add http:// to default host

* Change icons

* Add http:// to host if not provided, fix await

* Add http:// to host if not provided, fix await

* Adjust tests for http:// added to host

* remove line

* Remove without http:// requirement

* Remove without http;// requirement
This commit is contained in:
Ernst Klamer 2019-10-23 08:31:43 +02:00 committed by Paulus Schoutsen
parent b27dc5bd39
commit acc3646ef3
14 changed files with 572 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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"
}
}
}

View file

@ -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")

View file

@ -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)

View file

@ -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"],
}

View file

@ -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"]
}

View file

@ -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")

View file

@ -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"
}
}
}

View file

@ -60,6 +60,7 @@ FLOWS = [
"smartthings",
"smhi",
"solaredge",
"solarlog",
"soma",
"somfy",
"sonos",

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
"""Tests for the solarlog integration."""

View file

@ -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"