Add Goodwe solar inverter integration (#58503)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: starkillerOG <starkiller.og@gmail.com>
pull/63411/head
mletenay 2022-01-04 22:19:31 +01:00 committed by GitHub
parent 841e22258d
commit f0acbabd48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 725 additions and 0 deletions

View File

@ -398,6 +398,11 @@ omit =
homeassistant/components/glances/sensor.py
homeassistant/components/gntp/notify.py
homeassistant/components/goalfeed/*
homeassistant/components/goodwe/__init__.py
homeassistant/components/goodwe/const.py
homeassistant/components/goodwe/number.py
homeassistant/components/goodwe/select.py
homeassistant/components/goodwe/sensor.py
homeassistant/components/google/__init__.py
homeassistant/components/google_cloud/tts.py
homeassistant/components/google_maps/device_tracker.py

View File

@ -344,6 +344,8 @@ homeassistant/components/goalzero/* @tkdrob
tests/components/goalzero/* @tkdrob
homeassistant/components/gogogate2/* @vangorra @bdraco
tests/components/gogogate2/* @vangorra @bdraco
homeassistant/components/goodwe/* @mletenay @starkillerOG
tests/components/goodwe/* @mletenay @starkillerOG
homeassistant/components/google_assistant/* @home-assistant/cloud
tests/components/google_assistant/* @home-assistant/cloud
homeassistant/components/google_cloud/* @lufton

View File

@ -0,0 +1,116 @@
"""The Goodwe inverter component."""
import logging
from goodwe import InverterError, RequestFailedException, connect
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.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONF_MODEL_FAMILY,
DOMAIN,
KEY_COORDINATOR,
KEY_DEVICE_INFO,
KEY_INVERTER,
PLATFORMS,
SCAN_INTERVAL,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the Goodwe components from a config entry."""
hass.data.setdefault(DOMAIN, {})
name = entry.title
host = entry.data[CONF_HOST]
model_family = entry.data[CONF_MODEL_FAMILY]
# Connect to Goodwe inverter
try:
inverter = await connect(
host=host,
family=model_family,
retries=10,
)
except InverterError as err:
raise ConfigEntryNotReady from err
device_info = DeviceInfo(
configuration_url="https://www.semsportal.com",
identifiers={(DOMAIN, inverter.serial_number)},
name=entry.title,
manufacturer="GoodWe",
model=inverter.model_name,
sw_version=f"{inverter.software_version} ({inverter.arm_version})",
)
async def async_update_data():
"""Fetch data from the inverter."""
try:
return await inverter.read_runtime_data()
except RequestFailedException as ex:
# UDP communication with inverter is by definition unreliable.
# It is rather normal in many environments to fail to receive
# proper response in usual time, so we intentionally ignore isolated
# failures and report problem with availability only after
# consecutive streak of 3 of failed requests.
if ex.consecutive_failures_count < 3:
_LOGGER.debug(
"No response received (streak of %d)", ex.consecutive_failures_count
)
# return empty dictionary, sensors will keep their previous values
return {}
# Inverter does not respond anymore (e.g. it went to sleep mode)
_LOGGER.debug(
"Inverter not responding (streak of %d)", ex.consecutive_failures_count
)
raise UpdateFailed(ex) from ex
except InverterError as ex:
raise UpdateFailed(ex) from ex
# Create update coordinator
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=name,
update_method=async_update_data,
# Polling interval. Will only be polled if there are subscribers.
update_interval=SCAN_INTERVAL,
)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = {
KEY_INVERTER: inverter,
KEY_COORDINATOR: coordinator,
KEY_DEVICE_INFO: device_info,
}
entry.async_on_unload(entry.add_update_listener(update_listener))
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
if unload_ok:
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)

View File

@ -0,0 +1,53 @@
"""Config flow to configure Goodwe inverters using their local API."""
from __future__ import annotations
import logging
from goodwe import InverterError, connect
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST
from homeassistant.data_entry_flow import FlowResult
from .const import CONF_MODEL_FAMILY, DEFAULT_NAME, DOMAIN
CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
}
)
_LOGGER = logging.getLogger(__name__)
class GoodweFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Goodwe config flow."""
VERSION = 1
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
host = user_input[CONF_HOST]
try:
inverter = await connect(host=host, retries=10)
except InverterError:
errors[CONF_HOST] = "connection_error"
else:
await self.async_set_unique_id(inverter.serial_number)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=DEFAULT_NAME,
data={
CONF_HOST: host,
CONF_MODEL_FAMILY: type(inverter).__name__,
},
)
return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
)

View File

@ -0,0 +1,17 @@
"""Constants for the Goodwe component."""
from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "goodwe"
PLATFORMS = [Platform.NUMBER, Platform.SELECT, Platform.SENSOR]
DEFAULT_NAME = "GoodWe"
SCAN_INTERVAL = timedelta(seconds=10)
CONF_MODEL_FAMILY = "model_family"
KEY_INVERTER = "inverter"
KEY_COORDINATOR = "coordinator"
KEY_DEVICE_INFO = "device_info"

View File

@ -0,0 +1,12 @@
{
"domain": "goodwe",
"name": "GoodWe Inverter",
"documentation": "https://www.home-assistant.io/integrations/goodwe",
"codeowners": [
"@mletenay",
"@starkillerOG"
],
"requirements": ["goodwe==0.2.10"],
"config_flow": true,
"iot_class": "local_polling"
}

View File

@ -0,0 +1,109 @@
"""GoodWe PV inverter numeric settings entities."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from goodwe import Inverter, InverterError
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import ENTITY_CATEGORY_CONFIG, PERCENTAGE, POWER_WATT
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER
_LOGGER = logging.getLogger(__name__)
@dataclass
class GoodweNumberEntityDescriptionBase:
"""Required values when describing Goodwe number entities."""
getter: Callable[[Inverter], Awaitable[int]]
setter: Callable[[Inverter, int], Awaitable[None]]
@dataclass
class GoodweNumberEntityDescription(
NumberEntityDescription, GoodweNumberEntityDescriptionBase
):
"""Class describing Goodwe number entities."""
NUMBERS = (
GoodweNumberEntityDescription(
key="grid_export_limit",
name="Grid export limit",
icon="mdi:transmission-tower",
entity_category=ENTITY_CATEGORY_CONFIG,
unit_of_measurement=POWER_WATT,
getter=lambda inv: inv.get_grid_export_limit(),
setter=lambda inv, val: inv.set_grid_export_limit(val),
step=100,
min_value=0,
max_value=10000,
),
GoodweNumberEntityDescription(
key="battery_discharge_depth",
name="Depth of discharge (on-grid)",
icon="mdi:battery-arrow-down",
entity_category=ENTITY_CATEGORY_CONFIG,
unit_of_measurement=PERCENTAGE,
getter=lambda inv: inv.get_ongrid_battery_dod(),
setter=lambda inv, val: inv.set_ongrid_battery_dod(val),
step=1,
min_value=0,
max_value=99,
),
)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the inverter select entities from a config entry."""
inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER]
device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO]
entities = []
for description in NUMBERS:
try:
current_value = await description.getter(inverter)
except InverterError:
# Inverter model does not support this setting
_LOGGER.debug("Could not read inverter setting %s", description.key)
continue
entities.append(
InverterNumberEntity(device_info, description, inverter, current_value),
)
async_add_entities(entities)
class InverterNumberEntity(NumberEntity):
"""Inverter numeric setting entity."""
_attr_should_poll = False
entity_description: GoodweNumberEntityDescription
def __init__(
self,
device_info: DeviceInfo,
description: GoodweNumberEntityDescription,
inverter: Inverter,
current_value: int,
) -> None:
"""Initialize the number inverter setting entity."""
self.entity_description = description
self._attr_unique_id = f"{DOMAIN}-{description.key}-{inverter.serial_number}"
self._attr_device_info = device_info
self._attr_value = float(current_value)
self._inverter: Inverter = inverter
async def async_set_value(self, value: float) -> None:
"""Set new value."""
if self.entity_description.setter:
await self.entity_description.setter(self._inverter, int(value))
self._attr_value = value
self.async_write_ha_state()

View File

@ -0,0 +1,79 @@
"""GoodWe PV inverter selection settings entities."""
import logging
from goodwe import Inverter, InverterError
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import ENTITY_CATEGORY_CONFIG
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER
_LOGGER = logging.getLogger(__name__)
INVERTER_OPERATION_MODES = [
"General mode",
"Off grid mode",
"Backup mode",
"Eco mode",
]
OPERATION_MODE = SelectEntityDescription(
key="operation_mode",
name="Inverter operation mode",
icon="mdi:solar-power",
entity_category=ENTITY_CATEGORY_CONFIG,
)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the inverter select entities from a config entry."""
inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER]
device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO]
# read current operating mode from the inverter
try:
active_mode = await inverter.get_operation_mode()
except InverterError:
# Inverter model does not support this setting
_LOGGER.debug("Could not read inverter operation mode")
else:
if 0 <= active_mode < len(INVERTER_OPERATION_MODES):
async_add_entities(
[
InverterOperationModeEntity(
device_info,
OPERATION_MODE,
inverter,
INVERTER_OPERATION_MODES[active_mode],
)
]
)
class InverterOperationModeEntity(SelectEntity):
"""Entity representing the inverter operation mode."""
_attr_should_poll = False
def __init__(
self,
device_info: DeviceInfo,
description: SelectEntityDescription,
inverter: Inverter,
current_mode: str,
) -> None:
"""Initialize the inverter operation mode setting entity."""
self.entity_description = description
self._attr_unique_id = f"{DOMAIN}-{description.key}-{inverter.serial_number}"
self._attr_device_info = device_info
self._attr_options = INVERTER_OPERATION_MODES
self._attr_current_option = current_mode
self._inverter: Inverter = inverter
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self._inverter.set_operation_mode(INVERTER_OPERATION_MODES.index(option))
self._attr_current_option = option
self.async_write_ha_state()

View File

@ -0,0 +1,176 @@
"""Support for GoodWe inverter via UDP."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from goodwe import Inverter, Sensor, SensorKind
from homeassistant.components.sensor import (
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ELECTRIC_CURRENT_AMPERE,
ELECTRIC_POTENTIAL_VOLT,
ENERGY_KILO_WATT_HOUR,
ENTITY_CATEGORY_DIAGNOSTIC,
FREQUENCY_HERTZ,
PERCENTAGE,
POWER_WATT,
TEMP_CELSIUS,
)
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER
# Sensor name of battery SoC
BATTERY_SOC = "battery_soc"
_MAIN_SENSORS = (
"ppv",
"house_consumption",
"active_power",
"battery_soc",
"e_day",
"e_total",
"meter_e_total_exp",
"meter_e_total_imp",
"e_bat_charge_total",
"e_bat_discharge_total",
)
_ICONS = {
SensorKind.PV: "mdi:solar-power",
SensorKind.AC: "mdi:power-plug-outline",
SensorKind.UPS: "mdi:power-plug-off-outline",
SensorKind.BAT: "mdi:battery-high",
SensorKind.GRID: "mdi:transmission-tower",
}
@dataclass
class GoodweSensorEntityDescription(SensorEntityDescription):
"""Class describing Goodwe sensor entities."""
value: Callable[[str, Any, Any], Any] = lambda sensor, prev, val: val
_DESCRIPTIONS = {
"A": GoodweSensorEntityDescription(
key="A",
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE,
),
"V": GoodweSensorEntityDescription(
key="V",
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
),
"W": GoodweSensorEntityDescription(
key="W",
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
native_unit_of_measurement=POWER_WATT,
),
"kWh": GoodweSensorEntityDescription(
key="kWh",
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
value=lambda sensor, prev, val: prev if "total" in sensor and not val else val,
),
"C": GoodweSensorEntityDescription(
key="C",
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
native_unit_of_measurement=TEMP_CELSIUS,
),
"Hz": GoodweSensorEntityDescription(
key="Hz",
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
native_unit_of_measurement=FREQUENCY_HERTZ,
),
"%": GoodweSensorEntityDescription(
key="%",
state_class=STATE_CLASS_MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
),
}
DIAG_SENSOR = GoodweSensorEntityDescription(
key="_",
state_class=STATE_CLASS_MEASUREMENT,
)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the GoodWe inverter from a config entry."""
entities = []
inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER]
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO]
# Individual inverter sensors entities
entities.extend(
InverterSensor(coordinator, device_info, inverter, sensor)
for sensor in inverter.sensors()
if not sensor.id_.startswith("xx")
)
async_add_entities(entities)
class InverterSensor(CoordinatorEntity, SensorEntity):
"""Entity representing individual inverter sensor."""
def __init__(
self,
coordinator: DataUpdateCoordinator,
device_info: DeviceInfo,
inverter: Inverter,
sensor: Sensor,
) -> None:
"""Initialize an inverter sensor."""
super().__init__(coordinator)
self._attr_name = sensor.name.strip()
self._attr_unique_id = f"{DOMAIN}-{sensor.id_}-{inverter.serial_number}"
self._attr_device_info = device_info
self._attr_entity_category = (
ENTITY_CATEGORY_DIAGNOSTIC if sensor.id_ not in _MAIN_SENSORS else None
)
self.entity_description = _DESCRIPTIONS.get(sensor.unit, DIAG_SENSOR)
if not self.entity_description.native_unit_of_measurement:
self._attr_native_unit_of_measurement = sensor.unit
self._attr_icon = _ICONS.get(sensor.kind)
# Set the inverter SoC as main device battery sensor
if sensor.id_ == BATTERY_SOC:
self._attr_device_class = DEVICE_CLASS_BATTERY
self._sensor = sensor
self._previous_value = None
@property
def native_value(self):
"""Return the value reported by the sensor."""
value = self.entity_description.value(
self._sensor.id_,
self._previous_value,
self.coordinator.data.get(self._sensor.id_, self._previous_value),
)
self._previous_value = value
return value

View File

@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"title": "GoodWe inverter",
"description": "Connect to inverter",
"data": {
"host": "[%key:common::config_flow::data::ip%]"
}
}
},
"error": {
"connection_error": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
}
}
}

View File

@ -0,0 +1,21 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured",
"already_in_progress": "Configuration flow is already in progress"
},
"error": {
"connection_error": "Failed to connect"
},
"flow_title": "GoodWe",
"step": {
"user": {
"data": {
"host": "IP Address"
},
"description": "Connect to inverter",
"title": "GoodWe inverter"
}
}
}
}

View File

@ -118,6 +118,7 @@ FLOWS = [
"glances",
"goalzero",
"gogogate2",
"goodwe",
"google_travel_time",
"gpslogger",
"gree",

View File

@ -742,6 +742,9 @@ gntp==1.0.3
# homeassistant.components.goalzero
goalzero==0.2.1
# homeassistant.components.goodwe
goodwe==0.2.10
# homeassistant.components.google
google-api-python-client==1.6.4

View File

@ -467,6 +467,9 @@ glances_api==0.2.0
# homeassistant.components.goalzero
goalzero==0.2.1
# homeassistant.components.goodwe
goodwe==0.2.10
# homeassistant.components.google
google-api-python-client==1.6.4

View File

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

View File

@ -0,0 +1,107 @@
"""Test the Goodwe config flow."""
from unittest.mock import AsyncMock, patch
from goodwe import InverterError
from homeassistant.components.goodwe.const import (
CONF_MODEL_FAMILY,
DEFAULT_NAME,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
from tests.common import MockConfigEntry
TEST_HOST = "1.2.3.4"
TEST_SERIAL = "123456789"
def mock_inverter():
"""Get a mock object of the inverter."""
goodwe_inverter = AsyncMock()
goodwe_inverter.serial_number = TEST_SERIAL
return goodwe_inverter
async def test_manual_setup(hass: HomeAssistant):
"""Test manually setting up."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert not result["errors"]
with patch(
"homeassistant.components.goodwe.config_flow.connect",
return_value=mock_inverter(),
), patch(
"homeassistant.components.goodwe.async_setup_entry", return_value=True
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: TEST_HOST}
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == DEFAULT_NAME
assert result["data"] == {
CONF_HOST: TEST_HOST,
CONF_MODEL_FAMILY: "AsyncMock",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_manual_setup_already_exists(hass: HomeAssistant):
"""Test manually setting up and the device already exists."""
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: TEST_HOST}, unique_id=TEST_SERIAL
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert not result["errors"]
with patch(
"homeassistant.components.goodwe.config_flow.connect",
return_value=mock_inverter(),
), patch("homeassistant.components.goodwe.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: TEST_HOST}
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_manual_setup_device_offline(hass: HomeAssistant):
"""Test manually setting up, device offline."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert not result["errors"]
with patch(
"homeassistant.components.goodwe.config_flow.connect",
side_effect=InverterError,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: TEST_HOST}
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {CONF_HOST: "connection_error"}