Replace Solarlog unmaintained library (#117484)

Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
dontinelli
2024-06-18 09:06:22 +02:00
committed by GitHub
parent faa55de538
commit 2555827030
12 changed files with 320 additions and 91 deletions

View File

@ -1305,8 +1305,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/solaredge/ @frenck @bdraco
/tests/components/solaredge/ @frenck @bdraco
/homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79
/tests/components/solarlog/ @Ernst79
/homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli
/homeassistant/components/solax/ @squishykid
/tests/components/solax/ @squishykid
/homeassistant/components/soma/ @ratsept @sebfortier2288

View File

@ -1,12 +1,17 @@
"""Solar-Log integration."""
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .const import DOMAIN
from .coordinator import SolarlogData
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
@ -22,3 +27,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version < 2:
# migrate old entity unique id
entity_reg = er.async_get(hass)
entities: list[er.RegistryEntry] = er.async_entries_for_config_entry(
entity_reg, config_entry.entry_id
)
for entity in entities:
if "time" in entity.unique_id:
new_uid = entity.unique_id.replace("time", "last_updated")
_LOGGER.debug(
"migrate unique id '%s' to '%s'", entity.unique_id, new_uid
)
entity_reg.async_update_entity(
entity.entity_id, new_unique_id=new_uid
)
# migrate config_entry
new = {**config_entry.data}
new["extended_data"] = False
hass.config_entries.async_update_entry(
config_entry, data=new, minor_version=2, version=1
)
_LOGGER.debug(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True

View File

@ -1,13 +1,14 @@
"""Config flow for solarlog integration."""
import logging
from typing import Any
from urllib.parse import ParseResult, urlparse
from requests.exceptions import HTTPError, Timeout
from sunwatcher.solarlog.solarlog import SolarLog
from solarlog_cli.solarlog_connector import SolarLogConnector
from solarlog_cli.solarlog_exceptions import SolarLogConnectionError, SolarLogError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify
@ -29,6 +30,7 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for solarlog."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
@ -40,37 +42,44 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
return True
return False
def _parse_url(self, host: str) -> str:
"""Return parsed host url."""
url = urlparse(host, "http")
netloc = url.netloc or url.path
path = url.path if url.netloc else ""
url = ParseResult("http", netloc, path, *url[3:])
return url.geturl()
async def _test_connection(self, host):
"""Check if we can connect to the Solar-Log device."""
solarlog = SolarLogConnector(host)
try:
await self.hass.async_add_executor_job(SolarLog, host)
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,
)
await solarlog.test_connection()
except SolarLogConnectionError:
self._errors = {CONF_HOST: "cannot_connect"}
return False
except SolarLogError: # pylint: disable=broad-except
self._errors = {CONF_HOST: "unknown"}
return False
finally:
solarlog.client.close()
return True
async def async_step_user(self, user_input=None):
async def async_step_user(self, user_input=None) -> ConfigFlowResult:
"""Step when user initializes 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)
user_input[CONF_NAME] = slugify(user_input[CONF_NAME])
user_input[CONF_HOST] = self._parse_url(user_input[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()
if self._host_in_configuration_exists(host):
if self._host_in_configuration_exists(user_input[CONF_HOST]):
self._errors[CONF_HOST] = "already_configured"
elif await self._test_connection(host):
return self.async_create_entry(title=name, data={CONF_HOST: host})
elif await self._test_connection(user_input[CONF_HOST]):
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
)
else:
user_input = {}
user_input[CONF_NAME] = DEFAULT_NAME
@ -86,21 +95,25 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Required(
CONF_HOST, default=user_input.get(CONF_HOST, DEFAULT_HOST)
): str,
vol.Required("extended_data", default=False): bool,
}
),
errors=self._errors,
)
async def async_step_import(self, user_input=None):
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""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()
user_input = {
CONF_HOST: DEFAULT_HOST,
CONF_NAME: DEFAULT_NAME,
"extended_data": False,
**user_input,
}
if self._host_in_configuration_exists(host):
user_input[CONF_HOST] = self._parse_url(user_input[CONF_HOST])
if self._host_in_configuration_exists(user_input[CONF_HOST]):
return self.async_abort(reason="already_configured")
return await self.async_step_user(user_input)

View File

@ -4,12 +4,16 @@ from datetime import timedelta
import logging
from urllib.parse import ParseResult, urlparse
from requests.exceptions import HTTPError, Timeout
from sunwatcher.solarlog.solarlog import SolarLog
from solarlog_cli.solarlog_connector import SolarLogConnector
from solarlog_cli.solarlog_exceptions import (
SolarLogConnectionError,
SolarLogUpdateError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import update_coordinator
_LOGGER = logging.getLogger(__name__)
@ -34,24 +38,23 @@ class SolarlogData(update_coordinator.DataUpdateCoordinator):
self.name = entry.title
self.host = url.geturl()
async def _async_update_data(self):
"""Update the data from the SolarLog device."""
try:
data = await self.hass.async_add_executor_job(SolarLog, self.host)
except (OSError, Timeout, HTTPError) as err:
raise update_coordinator.UpdateFailed(err) from err
extended_data = entry.data["extended_data"]
if data.time.year == 1999:
raise update_coordinator.UpdateFailed(
"Invalid data returned (can happen after Solarlog restart)."
)
self.logger.debug(
(
"Connection to Solarlog successful. Retrieving latest Solarlog update"
" of %s"
),
data.time,
self.solarlog = SolarLogConnector(
self.host, extended_data, hass.config.time_zone
)
async def _async_update_data(self):
"""Update the data from the SolarLog device."""
_LOGGER.debug("Start data update")
try:
data = await self.solarlog.update_data()
except SolarLogConnectionError as err:
raise ConfigEntryNotReady(err) from err
except SolarLogUpdateError as err:
raise update_coordinator.UpdateFailed(err) from err
_LOGGER.debug("Data successfully updated")
return data

View File

@ -1,10 +1,10 @@
{
"domain": "solarlog",
"name": "Solar-Log",
"codeowners": ["@Ernst79"],
"codeowners": ["@Ernst79", "@dontinelli"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/solarlog",
"iot_class": "local_polling",
"loggers": ["sunwatcher"],
"requirements": ["sunwatcher==0.2.1"]
"loggers": ["solarlog_cli"],
"requirements": ["solarlog_cli==0.1.5"]
}

View File

@ -21,7 +21,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import as_local
from . import SolarlogData
from .const import DOMAIN
@ -36,10 +35,9 @@ class SolarLogSensorEntityDescription(SensorEntityDescription):
SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = (
SolarLogSensorEntityDescription(
key="time",
key="last_updated",
translation_key="last_update",
device_class=SensorDeviceClass.TIMESTAMP,
value=as_local,
),
SolarLogSensorEntityDescription(
key="power_ac",
@ -231,7 +229,8 @@ class SolarlogSensor(CoordinatorEntity[SolarlogData], SensorEntity):
@property
def native_value(self):
"""Return the native sensor value."""
raw_attr = getattr(self.coordinator.data, self.entity_description.key)
raw_attr = self.coordinator.data.get(self.entity_description.key)
if self.entity_description.value:
return self.entity_description.value(raw_attr)
return raw_attr

View File

@ -5,7 +5,8 @@
"title": "Define your Solar-Log connection",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"name": "The prefix to be used for your Solar-Log sensors"
"name": "The prefix to be used for your Solar-Log sensors",
"extended_data": "Get additional data from Solar-Log. Extended data is only accessible, if no password is set for the Solar-Log. Use at your own risk!"
},
"data_description": {
"host": "The hostname or IP address of your Solar-Log device."
@ -14,7 +15,8 @@
},
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"

View File

@ -2601,6 +2601,9 @@ soco==0.30.4
# homeassistant.components.solaredge_local
solaredge-local==0.2.3
# homeassistant.components.solarlog
solarlog_cli==0.1.5
# homeassistant.components.solax
solax==3.1.1
@ -2661,9 +2664,6 @@ stringcase==1.2.0
# homeassistant.components.subaru
subarulink==0.7.11
# homeassistant.components.solarlog
sunwatcher==0.2.1
# homeassistant.components.sunweg
sunweg==3.0.1

View File

@ -2020,6 +2020,9 @@ snapcast==2.3.6
# homeassistant.components.sonos
soco==0.30.4
# homeassistant.components.solarlog
solarlog_cli==0.1.5
# homeassistant.components.solax
solax==3.1.1
@ -2077,9 +2080,6 @@ stringcase==1.2.0
# homeassistant.components.subaru
subarulink==0.7.11
# homeassistant.components.solarlog
sunwatcher==0.2.1
# homeassistant.components.sunweg
sunweg==3.0.1

View File

@ -0,0 +1,54 @@
"""Test helpers."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.core import HomeAssistant
from tests.common import mock_device_registry, mock_registry
@pytest.fixture
def mock_solarlog():
"""Build a fixture for the SolarLog API that connects successfully and returns one device."""
mock_solarlog_api = AsyncMock()
with patch(
"homeassistant.components.solarlog.config_flow.SolarLogConnector",
return_value=mock_solarlog_api,
) as mock_solarlog_api:
mock_solarlog_api.return_value.test_connection.return_value = True
yield mock_solarlog_api
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.solarlog.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture(name="test_connect")
def mock_test_connection():
"""Mock a successful _test_connection."""
with patch(
"homeassistant.components.solarlog.config_flow.SolarLogConfigFlow._test_connection",
return_value=True,
):
yield
@pytest.fixture(name="device_reg")
def device_reg_fixture(hass: HomeAssistant):
"""Return an empty, loaded, registry."""
return mock_device_registry(hass)
@pytest.fixture(name="entity_reg")
def entity_reg_fixture(hass: HomeAssistant):
"""Return an empty, loaded, registry."""
return mock_registry(hass)

View File

@ -1,8 +1,9 @@
"""Test the solarlog config flow."""
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
from solarlog_cli.solarlog_exceptions import SolarLogConnectionError, SolarLogError
from homeassistant import config_entries
from homeassistant.components.solarlog import config_flow
@ -17,7 +18,7 @@ NAME = "Solarlog test 1 2 3"
HOST = "http://1.1.1.1"
async def test_form(hass: HomeAssistant) -> None:
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
@ -29,34 +30,22 @@ async def test_form(hass: HomeAssistant) -> None:
with (
patch(
"homeassistant.components.solarlog.config_flow.SolarLogConfigFlow._test_connection",
return_value={"title": "solarlog test 1 2 3"},
),
patch(
"homeassistant.components.solarlog.async_setup_entry",
return_value=True,
) as mock_setup_entry,
),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": HOST, "name": NAME}
result["flow_id"],
{CONF_HOST: HOST, CONF_NAME: NAME, "extended_data": False},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "solarlog_test_1_2_3"
assert result2["data"] == {"host": "http://1.1.1.1"}
assert result2["data"][CONF_HOST] == "http://1.1.1.1"
assert result2["data"]["extended_data"] is False
assert len(mock_setup_entry.mock_calls) == 1
@pytest.fixture(name="test_connect")
def mock_controller():
"""Mock a successful _host_in_configuration_exists."""
with patch(
"homeassistant.components.solarlog.config_flow.SolarLogConfigFlow._test_connection",
return_value=True,
):
yield
def init_config_flow(hass):
"""Init a configuration flow."""
flow = config_flow.SolarLogConfigFlow()
@ -64,19 +53,75 @@ def init_config_flow(hass):
return flow
async def test_user(hass: HomeAssistant, test_connect) -> None:
@pytest.mark.usefixtures("test_connect")
async def test_user(
hass: HomeAssistant,
mock_solarlog: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test user config."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
# tests with all provided
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: HOST, CONF_NAME: NAME, "extended_data": False}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "solarlog_test_1_2_3"
assert result["data"][CONF_HOST] == HOST
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(SolarLogConnectionError, {CONF_HOST: "cannot_connect"}),
(SolarLogError, {CONF_HOST: "unknown"}),
],
)
async def test_form_exceptions(
hass: HomeAssistant,
exception: Exception,
error: dict[str, str],
mock_solarlog: AsyncMock,
) -> None:
"""Test we can handle Form exceptions."""
flow = init_config_flow(hass)
result = await flow.async_step_user()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
# tets with all provided
result = await flow.async_step_user({CONF_NAME: NAME, CONF_HOST: HOST})
mock_solarlog.return_value.test_connection.side_effect = exception
# tests with connection error
result = await flow.async_step_user(
{CONF_NAME: NAME, CONF_HOST: HOST, "extended_data": False}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == error
mock_solarlog.return_value.test_connection.side_effect = None
# tests with all provided
result = await flow.async_step_user(
{CONF_NAME: NAME, CONF_HOST: HOST, "extended_data": False}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "solarlog_test_1_2_3"
assert result["data"][CONF_HOST] == HOST
assert result["data"]["extended_data"] is False
async def test_import(hass: HomeAssistant, test_connect) -> None:
@ -85,18 +130,24 @@ async def test_import(hass: HomeAssistant, test_connect) -> None:
# import with only host
result = await flow.async_step_import({CONF_HOST: HOST})
await hass.async_block_till_done()
assert result["type"] is FlowResultType.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})
await hass.async_block_till_done()
assert result["type"] is FlowResultType.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})
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "solarlog_test_1_2_3"
assert result["data"][CONF_HOST] == HOST
@ -111,7 +162,7 @@ async def test_abort_if_already_setup(hass: HomeAssistant, test_connect) -> None
# Should fail, same HOST different NAME (default)
result = await flow.async_step_import(
{CONF_HOST: HOST, CONF_NAME: "solarlog_test_7_8_9"}
{CONF_HOST: HOST, CONF_NAME: "solarlog_test_7_8_9", "extended_data": False}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@ -123,7 +174,7 @@ async def test_abort_if_already_setup(hass: HomeAssistant, test_connect) -> None
# 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"}
{CONF_HOST: "2.2.2.2", CONF_NAME: "solarlog_test_7_8_9", "extended_data": False}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "solarlog_test_7_8_9"
@ -131,8 +182,10 @@ async def test_abort_if_already_setup(hass: HomeAssistant, test_connect) -> None
# SHOULD pass, diff HOST, same NAME
result = await flow.async_step_import(
{CONF_HOST: "http://2.2.2.2", CONF_NAME: NAME}
{CONF_HOST: "http://2.2.2.2", CONF_NAME: NAME, "extended_data": False}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "solarlog_test_1_2_3"
assert result["data"][CONF_HOST] == "http://2.2.2.2"

View File

@ -0,0 +1,57 @@
"""Test the initialization."""
from homeassistant.components.solarlog.const import DOMAIN
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceRegistry
from homeassistant.helpers.entity_registry import EntityRegistry
from .test_config_flow import HOST, NAME
from tests.common import MockConfigEntry
async def test_migrate_config_entry(
hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry
) -> None:
"""Test successful migration of entry data."""
entry = MockConfigEntry(
domain=DOMAIN,
title=NAME,
data={
CONF_HOST: HOST,
},
version=1,
minor_version=1,
)
entry.add_to_hass(hass)
device = device_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Solar-Log",
name="solarlog",
)
sensor_entity = entity_reg.async_get_or_create(
config_entry=entry,
platform=DOMAIN,
domain=Platform.SENSOR,
unique_id=f"{entry.entry_id}_time",
device_id=device.id,
)
assert entry.version == 1
assert entry.minor_version == 1
assert sensor_entity.unique_id == f"{entry.entry_id}_time"
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity_migrated = entity_reg.async_get(sensor_entity.entity_id)
assert entity_migrated
assert entity_migrated.unique_id == f"{entry.entry_id}_last_updated"
assert entry.version == 1
assert entry.minor_version == 2
assert entry.data[CONF_HOST] == HOST
assert entry.data["extended_data"] is False