From 6349760a2c191421dbd9999d8a1b31edf22f2bcd Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen <8818390+kvanzuijlen@users.noreply.github.com> Date: Tue, 3 Jan 2023 20:14:08 +0100 Subject: [PATCH] Zeversolar integration (#84887) Co-authored-by: Franck Nijhof --- .coveragerc | 5 + CODEOWNERS | 2 + .../components/zeversolar/__init__.py | 25 ++++ .../components/zeversolar/config_flow.py | 61 ++++++++ homeassistant/components/zeversolar/const.py | 9 ++ .../components/zeversolar/coordinator.py | 34 +++++ homeassistant/components/zeversolar/entity.py | 29 ++++ .../components/zeversolar/manifest.json | 10 ++ homeassistant/components/zeversolar/sensor.py | 96 +++++++++++++ .../components/zeversolar/strings.json | 20 +++ .../zeversolar/translations/en.json | 19 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/zeversolar/__init__.py | 1 + .../components/zeversolar/test_config_flow.py | 135 ++++++++++++++++++ 17 files changed, 459 insertions(+) create mode 100644 homeassistant/components/zeversolar/__init__.py create mode 100644 homeassistant/components/zeversolar/config_flow.py create mode 100644 homeassistant/components/zeversolar/const.py create mode 100644 homeassistant/components/zeversolar/coordinator.py create mode 100644 homeassistant/components/zeversolar/entity.py create mode 100644 homeassistant/components/zeversolar/manifest.json create mode 100644 homeassistant/components/zeversolar/sensor.py create mode 100644 homeassistant/components/zeversolar/strings.json create mode 100644 homeassistant/components/zeversolar/translations/en.json create mode 100644 tests/components/zeversolar/__init__.py create mode 100644 tests/components/zeversolar/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 21b860bc602..facb27c893c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1592,6 +1592,11 @@ omit = homeassistant/components/zerproc/__init__.py homeassistant/components/zerproc/const.py homeassistant/components/zestimate/sensor.py + homeassistant/components/zeversolar/__init__.py + homeassistant/components/zeversolar/const.py + homeassistant/components/zeversolar/coordinator.py + homeassistant/components/zeversolar/entity.py + homeassistant/components/zeversolar/sensor.py homeassistant/components/zha/api.py homeassistant/components/zha/core/channels/* homeassistant/components/zha/core/const.py diff --git a/CODEOWNERS b/CODEOWNERS index b90694ccafd..cb3100fdba3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1357,6 +1357,8 @@ build.json @home-assistant/supervisor /tests/components/zeroconf/ @bdraco /homeassistant/components/zerproc/ @emlove /tests/components/zerproc/ @emlove +/homeassistant/components/zeversolar/ @kvanzuijlen +/tests/components/zeversolar/ @kvanzuijlen /homeassistant/components/zha/ @dmulcahey @adminiuga @puddly /tests/components/zha/ @dmulcahey @adminiuga @puddly /homeassistant/components/zodiac/ @JulienTant diff --git a/homeassistant/components/zeversolar/__init__.py b/homeassistant/components/zeversolar/__init__.py new file mode 100644 index 00000000000..cff5cf413e5 --- /dev/null +++ b/homeassistant/components/zeversolar/__init__.py @@ -0,0 +1,25 @@ +"""The Zeversolar integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, PLATFORMS +from .coordinator import ZeversolarCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Zeversolar from a config entry.""" + coordinator = ZeversolarCoordinator(hass=hass, entry=entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/zeversolar/config_flow.py b/homeassistant/components/zeversolar/config_flow.py new file mode 100644 index 00000000000..f749b9d471c --- /dev/null +++ b/homeassistant/components/zeversolar/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for zeversolar integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +import zeversolar + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + }, +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for zeversolar.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + client = zeversolar.ZeverSolarClient(host=user_input[CONF_HOST]) + try: + data = await self.hass.async_add_executor_job(client.get_data) + except zeversolar.ZeverSolarHTTPNotFound: + errors["base"] = "invalid_host" + except zeversolar.ZeverSolarHTTPError: + errors["base"] = "cannot_connect" + except zeversolar.ZeverSolarTimeout: + errors["base"] = "timeout_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(data.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Zeversolar", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/zeversolar/const.py b/homeassistant/components/zeversolar/const.py new file mode 100644 index 00000000000..e3622fefe33 --- /dev/null +++ b/homeassistant/components/zeversolar/const.py @@ -0,0 +1,9 @@ +"""Constants for the zeversolar integration.""" + +from homeassistant.const import Platform + +DOMAIN = "zeversolar" + +PLATFORMS = [ + Platform.SENSOR, +] diff --git a/homeassistant/components/zeversolar/coordinator.py b/homeassistant/components/zeversolar/coordinator.py new file mode 100644 index 00000000000..554fe195eab --- /dev/null +++ b/homeassistant/components/zeversolar/coordinator.py @@ -0,0 +1,34 @@ +"""Zeversolar coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import zeversolar + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]): + """Data update coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=1), + ) + self._client = zeversolar.ZeverSolarClient(host=entry.data[CONF_HOST]) + + async def _async_update_data(self) -> zeversolar.ZeverSolarData: + """Fetch the latest data from the source.""" + return await self.hass.async_add_executor_job(self._client.get_data) diff --git a/homeassistant/components/zeversolar/entity.py b/homeassistant/components/zeversolar/entity.py new file mode 100644 index 00000000000..ccda0add910 --- /dev/null +++ b/homeassistant/components/zeversolar/entity.py @@ -0,0 +1,29 @@ +"""Base Entity for Zeversolar sensors.""" +from __future__ import annotations + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ZeversolarCoordinator + + +class ZeversolarEntity( + CoordinatorEntity[ZeversolarCoordinator], +): + """Defines a base Zeversolar entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + *, + coordinator: ZeversolarCoordinator, + ) -> None: + """Initialize the Zeversolar entity.""" + super().__init__(coordinator=coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.serial_number)}, + name="Zeversolar Sensor", + manufacturer="Zeversolar", + ) diff --git a/homeassistant/components/zeversolar/manifest.json b/homeassistant/components/zeversolar/manifest.json new file mode 100644 index 00000000000..0d67022920d --- /dev/null +++ b/homeassistant/components/zeversolar/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "zeversolar", + "name": "Zeversolar", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/zeversolar", + "requirements": ["zeversolar==0.2.0"], + "codeowners": ["@kvanzuijlen"], + "iot_class": "local_polling", + "integration_type": "device" +} diff --git a/homeassistant/components/zeversolar/sensor.py b/homeassistant/components/zeversolar/sensor.py new file mode 100644 index 00000000000..746434faeeb --- /dev/null +++ b/homeassistant/components/zeversolar/sensor.py @@ -0,0 +1,96 @@ +"""Support for the Zeversolar platform.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +import zeversolar + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import ZeversolarCoordinator +from .entity import ZeversolarEntity + + +@dataclass +class ZeversolarEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[zeversolar.ZeverSolarData], zeversolar.kWh | zeversolar.Watt] + + +@dataclass +class ZeversolarEntityDescription( + SensorEntityDescription, ZeversolarEntityDescriptionMixin +): + """Describes Zeversolar sensor entity.""" + + +SENSOR_TYPES = ( + ZeversolarEntityDescription( + key="pac", + name="Current power", + icon="mdi:solar-power-variant", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.POWER, + value_fn=lambda data: data.pac, + ), + ZeversolarEntityDescription( + key="energy_today", + name="Energy today", + icon="mdi:home-battery", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda data: data.energy_today, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Zeversolar sensor.""" + coordinator: ZeversolarCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + ZeversolarSensor( + description=description, + coordinator=coordinator, + ) + for description in SENSOR_TYPES + ) + + +class ZeversolarSensor(ZeversolarEntity, SensorEntity): + """Implementation of the Zeversolar sensor.""" + + entity_description: ZeversolarEntityDescription + + def __init__( + self, + *, + description: ZeversolarEntityDescription, + coordinator: ZeversolarCoordinator, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(coordinator=coordinator) + self._attr_unique_id = f"{coordinator.data.serial_number}_{description.key}" + + @property + def native_value(self) -> int | float: + """Return sensor state.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/zeversolar/strings.json b/homeassistant/components/zeversolar/strings.json new file mode 100644 index 00000000000..a4f52dc6aa3 --- /dev/null +++ b/homeassistant/components/zeversolar/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/zeversolar/translations/en.json b/homeassistant/components/zeversolar/translations/en.json new file mode 100644 index 00000000000..b5e3f28da6d --- /dev/null +++ b/homeassistant/components/zeversolar/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d67b2a3aaac..ef052af02f5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -487,6 +487,7 @@ FLOWS = { "youless", "zamg", "zerproc", + "zeversolar", "zha", "zwave_js", "zwave_me", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0384b494566..18122a89452 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6288,6 +6288,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "zeversolar": { + "name": "Zeversolar", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "zha": { "name": "Zigbee Home Automation", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 00ecac68b38..4f3140fb4c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2649,6 +2649,9 @@ zengge==0.2 # homeassistant.components.zeroconf zeroconf==0.47.1 +# homeassistant.components.zeversolar +zeversolar==0.2.0 + # homeassistant.components.zha zha-quirks==0.0.90 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce743482454..aea97d5b87e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1856,6 +1856,9 @@ zamg==0.2.2 # homeassistant.components.zeroconf zeroconf==0.47.1 +# homeassistant.components.zeversolar +zeversolar==0.2.0 + # homeassistant.components.zha zha-quirks==0.0.90 diff --git a/tests/components/zeversolar/__init__.py b/tests/components/zeversolar/__init__.py new file mode 100644 index 00000000000..c7e65bc62fd --- /dev/null +++ b/tests/components/zeversolar/__init__.py @@ -0,0 +1 @@ +"""Tests for the Zeversolar integration.""" diff --git a/tests/components/zeversolar/test_config_flow.py b/tests/components/zeversolar/test_config_flow.py new file mode 100644 index 00000000000..f4b0c6b5389 --- /dev/null +++ b/tests/components/zeversolar/test_config_flow.py @@ -0,0 +1,135 @@ +"""Test the Zeversolar config flow.""" +from unittest.mock import MagicMock, patch + +import pytest +from zeversolar.exceptions import ( + ZeverSolarHTTPError, + ZeverSolarHTTPNotFound, + ZeverSolarTimeout, +) + +from homeassistant import config_entries +from homeassistant.components.zeversolar.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + await _set_up_zeversolar(hass=hass, flow_id=result["flow_id"]) + + +@pytest.mark.parametrize( + "side_effect,errors", + ( + ( + ZeverSolarHTTPNotFound, + {"base": "invalid_host"}, + ), + ( + ZeverSolarHTTPError, + {"base": "cannot_connect"}, + ), + ( + ZeverSolarTimeout, + {"base": "timeout_connect"}, + ), + ( + RuntimeError, + {"base": "unknown"}, + ), + ), +) +async def test_form_errors( + hass: HomeAssistant, + side_effect: Exception, + errors: dict, +) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "zeversolar.ZeverSolarClient.get_data", + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={ + CONF_HOST: "test_ip", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == errors + + await _set_up_zeversolar(hass=hass, flow_id=result["flow_id"]) + + +async def test_abort_already_configured(hass: HomeAssistant) -> None: + """Test we abort when the device is already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Zeversolar", + data={CONF_HOST: "test_ip"}, + unique_id="test_serial", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("errors") is None + assert "flow_id" in result + + mock_data = MagicMock() + mock_data.serial_number = "test_serial" + with patch("zeversolar.ZeverSolarClient.get_data", return_value=mock_data), patch( + "homeassistant.components.zeversolar.async_setup_entry", + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={ + CONF_HOST: "test_ip", + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def _set_up_zeversolar(hass: HomeAssistant, flow_id: str) -> None: + """Reusable successful setup of Zeversolar sensor.""" + mock_data = MagicMock() + mock_data.serial_number = "test_serial" + with patch("zeversolar.ZeverSolarClient.get_data", return_value=mock_data), patch( + "homeassistant.components.zeversolar.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flow_id=flow_id, + user_input={ + CONF_HOST: "test_ip", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Zeversolar" + assert result2["data"] == { + CONF_HOST: "test_ip", + } + assert len(mock_setup_entry.mock_calls) == 1