Add Goodwe solar inverter integration (#58503)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com> Co-authored-by: starkillerOG <starkiller.og@gmail.com>
This commit is contained in:
parent
841e22258d
commit
f0acbabd48
16 changed files with 725 additions and 0 deletions
|
@ -398,6 +398,11 @@ omit =
|
||||||
homeassistant/components/glances/sensor.py
|
homeassistant/components/glances/sensor.py
|
||||||
homeassistant/components/gntp/notify.py
|
homeassistant/components/gntp/notify.py
|
||||||
homeassistant/components/goalfeed/*
|
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/__init__.py
|
||||||
homeassistant/components/google_cloud/tts.py
|
homeassistant/components/google_cloud/tts.py
|
||||||
homeassistant/components/google_maps/device_tracker.py
|
homeassistant/components/google_maps/device_tracker.py
|
||||||
|
|
|
@ -344,6 +344,8 @@ homeassistant/components/goalzero/* @tkdrob
|
||||||
tests/components/goalzero/* @tkdrob
|
tests/components/goalzero/* @tkdrob
|
||||||
homeassistant/components/gogogate2/* @vangorra @bdraco
|
homeassistant/components/gogogate2/* @vangorra @bdraco
|
||||||
tests/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
|
homeassistant/components/google_assistant/* @home-assistant/cloud
|
||||||
tests/components/google_assistant/* @home-assistant/cloud
|
tests/components/google_assistant/* @home-assistant/cloud
|
||||||
homeassistant/components/google_cloud/* @lufton
|
homeassistant/components/google_cloud/* @lufton
|
||||||
|
|
116
homeassistant/components/goodwe/__init__.py
Normal file
116
homeassistant/components/goodwe/__init__.py
Normal 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)
|
53
homeassistant/components/goodwe/config_flow.py
Normal file
53
homeassistant/components/goodwe/config_flow.py
Normal 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
|
||||||
|
)
|
17
homeassistant/components/goodwe/const.py
Normal file
17
homeassistant/components/goodwe/const.py
Normal 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"
|
12
homeassistant/components/goodwe/manifest.json
Normal file
12
homeassistant/components/goodwe/manifest.json
Normal 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"
|
||||||
|
}
|
109
homeassistant/components/goodwe/number.py
Normal file
109
homeassistant/components/goodwe/number.py
Normal 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()
|
79
homeassistant/components/goodwe/select.py
Normal file
79
homeassistant/components/goodwe/select.py
Normal 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()
|
176
homeassistant/components/goodwe/sensor.py
Normal file
176
homeassistant/components/goodwe/sensor.py
Normal 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
|
20
homeassistant/components/goodwe/strings.json
Normal file
20
homeassistant/components/goodwe/strings.json
Normal 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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
homeassistant/components/goodwe/translations/en.json
Normal file
21
homeassistant/components/goodwe/translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -118,6 +118,7 @@ FLOWS = [
|
||||||
"glances",
|
"glances",
|
||||||
"goalzero",
|
"goalzero",
|
||||||
"gogogate2",
|
"gogogate2",
|
||||||
|
"goodwe",
|
||||||
"google_travel_time",
|
"google_travel_time",
|
||||||
"gpslogger",
|
"gpslogger",
|
||||||
"gree",
|
"gree",
|
||||||
|
|
|
@ -742,6 +742,9 @@ gntp==1.0.3
|
||||||
# homeassistant.components.goalzero
|
# homeassistant.components.goalzero
|
||||||
goalzero==0.2.1
|
goalzero==0.2.1
|
||||||
|
|
||||||
|
# homeassistant.components.goodwe
|
||||||
|
goodwe==0.2.10
|
||||||
|
|
||||||
# homeassistant.components.google
|
# homeassistant.components.google
|
||||||
google-api-python-client==1.6.4
|
google-api-python-client==1.6.4
|
||||||
|
|
||||||
|
|
|
@ -467,6 +467,9 @@ glances_api==0.2.0
|
||||||
# homeassistant.components.goalzero
|
# homeassistant.components.goalzero
|
||||||
goalzero==0.2.1
|
goalzero==0.2.1
|
||||||
|
|
||||||
|
# homeassistant.components.goodwe
|
||||||
|
goodwe==0.2.10
|
||||||
|
|
||||||
# homeassistant.components.google
|
# homeassistant.components.google
|
||||||
google-api-python-client==1.6.4
|
google-api-python-client==1.6.4
|
||||||
|
|
||||||
|
|
1
tests/components/goodwe/__init__.py
Normal file
1
tests/components/goodwe/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the Goodwe integration."""
|
107
tests/components/goodwe/test_config_flow.py
Normal file
107
tests/components/goodwe/test_config_flow.py
Normal 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"}
|
Loading…
Add table
Add a link
Reference in a new issue