mirror of
https://github.com/home-assistant/core.git
synced 2026-04-02 00:20:30 +01:00
Add multi-plane support for Forecast.Solar integration (#160058)
Co-authored-by: Junie <noreply@jb.gg> Co-authored-by: Junie <junie@jetbrains.com> Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -2,14 +2,26 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from types import MappingProxyType
|
||||
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
|
||||
from .const import (
|
||||
CONF_AZIMUTH,
|
||||
CONF_DAMPING,
|
||||
CONF_DAMPING_EVENING,
|
||||
CONF_DAMPING_MORNING,
|
||||
CONF_DECLINATION,
|
||||
CONF_MODULES_POWER,
|
||||
DEFAULT_AZIMUTH,
|
||||
DEFAULT_DAMPING,
|
||||
DEFAULT_DECLINATION,
|
||||
DEFAULT_MODULES_POWER,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_PLANE,
|
||||
)
|
||||
from .coordinator import ForecastSolarConfigEntry, ForecastSolarDataUpdateCoordinator
|
||||
|
||||
@@ -25,14 +37,41 @@ async def async_migrate_entry(
|
||||
new_options = entry.options.copy()
|
||||
new_options |= {
|
||||
CONF_MODULES_POWER: new_options.pop("modules power"),
|
||||
CONF_DAMPING_MORNING: new_options.get(CONF_DAMPING, 0.0),
|
||||
CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, 0.0),
|
||||
CONF_DAMPING_MORNING: new_options.get(CONF_DAMPING, DEFAULT_DAMPING),
|
||||
CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, DEFAULT_DAMPING),
|
||||
}
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data=entry.data, options=new_options, version=2
|
||||
)
|
||||
|
||||
if entry.version == 2:
|
||||
# Migrate the main plane from options to a subentry
|
||||
declination = entry.options.get(CONF_DECLINATION, DEFAULT_DECLINATION)
|
||||
azimuth = entry.options.get(CONF_AZIMUTH, DEFAULT_AZIMUTH)
|
||||
modules_power = entry.options.get(CONF_MODULES_POWER, DEFAULT_MODULES_POWER)
|
||||
|
||||
subentry = ConfigSubentry(
|
||||
data=MappingProxyType(
|
||||
{
|
||||
CONF_DECLINATION: declination,
|
||||
CONF_AZIMUTH: azimuth,
|
||||
CONF_MODULES_POWER: modules_power,
|
||||
}
|
||||
),
|
||||
subentry_type=SUBENTRY_TYPE_PLANE,
|
||||
title=f"{declination}° / {azimuth}° / {modules_power}W",
|
||||
unique_id=None,
|
||||
)
|
||||
hass.config_entries.async_add_subentry(entry, subentry)
|
||||
|
||||
new_options = dict(entry.options)
|
||||
new_options.pop(CONF_DECLINATION, None)
|
||||
new_options.pop(CONF_AZIMUTH, None)
|
||||
new_options.pop(CONF_MODULES_POWER, None)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, options=new_options, version=3)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -40,6 +79,19 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ForecastSolarConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Forecast.Solar from a config entry."""
|
||||
plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
|
||||
if not plane_subentries:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_plane",
|
||||
)
|
||||
|
||||
if len(plane_subentries) > 1 and not entry.options.get(CONF_API_KEY):
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_key_required",
|
||||
)
|
||||
|
||||
coordinator = ForecastSolarDataUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -47,9 +99,18 @@ async def async_setup_entry(
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: ForecastSolarConfigEntry
|
||||
) -> None:
|
||||
"""Handle config entry updates (options or subentry changes)."""
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: ForecastSolarConfigEntry
|
||||
) -> bool:
|
||||
|
||||
@@ -11,11 +11,13 @@ from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
ConfigSubentryFlow,
|
||||
OptionsFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
|
||||
from .const import (
|
||||
CONF_AZIMUTH,
|
||||
@@ -24,16 +26,51 @@ from .const import (
|
||||
CONF_DECLINATION,
|
||||
CONF_INVERTER_SIZE,
|
||||
CONF_MODULES_POWER,
|
||||
DEFAULT_AZIMUTH,
|
||||
DEFAULT_DAMPING,
|
||||
DEFAULT_DECLINATION,
|
||||
DEFAULT_MODULES_POWER,
|
||||
DOMAIN,
|
||||
MAX_PLANES,
|
||||
SUBENTRY_TYPE_PLANE,
|
||||
)
|
||||
|
||||
RE_API_KEY = re.compile(r"^[a-zA-Z0-9]{16}$")
|
||||
|
||||
PLANE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DECLINATION): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=90, step=1, mode=selector.NumberSelectorMode.BOX
|
||||
),
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(CONF_AZIMUTH): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=360, step=1, mode=selector.NumberSelectorMode.BOX
|
||||
),
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(CONF_MODULES_POWER): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1, step=1, mode=selector.NumberSelectorMode.BOX
|
||||
),
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Forecast.Solar."""
|
||||
|
||||
VERSION = 2
|
||||
VERSION = 3
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -43,6 +80,14 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return ForecastSolarOptionFlowHandler()
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this handler."""
|
||||
return {SUBENTRY_TYPE_PLANE: PlaneSubentryFlowHandler}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -54,94 +99,112 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_LATITUDE: user_input[CONF_LATITUDE],
|
||||
CONF_LONGITUDE: user_input[CONF_LONGITUDE],
|
||||
},
|
||||
options={
|
||||
CONF_AZIMUTH: user_input[CONF_AZIMUTH],
|
||||
CONF_DECLINATION: user_input[CONF_DECLINATION],
|
||||
CONF_MODULES_POWER: user_input[CONF_MODULES_POWER],
|
||||
},
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": SUBENTRY_TYPE_PLANE,
|
||||
"data": {
|
||||
CONF_DECLINATION: user_input[CONF_DECLINATION],
|
||||
CONF_AZIMUTH: user_input[CONF_AZIMUTH],
|
||||
CONF_MODULES_POWER: user_input[CONF_MODULES_POWER],
|
||||
},
|
||||
"title": f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W",
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): str,
|
||||
vol.Required(CONF_LATITUDE): cv.latitude,
|
||||
vol.Required(CONF_LONGITUDE): cv.longitude,
|
||||
}
|
||||
).extend(PLANE_SCHEMA.schema),
|
||||
{
|
||||
vol.Required(
|
||||
CONF_NAME, default=self.hass.config.location_name
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_LATITUDE, default=self.hass.config.latitude
|
||||
): cv.latitude,
|
||||
vol.Required(
|
||||
CONF_LONGITUDE, default=self.hass.config.longitude
|
||||
): cv.longitude,
|
||||
vol.Required(CONF_DECLINATION, default=25): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=90)
|
||||
),
|
||||
vol.Required(CONF_AZIMUTH, default=180): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=360)
|
||||
),
|
||||
vol.Required(CONF_MODULES_POWER): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
CONF_NAME: self.hass.config.location_name,
|
||||
CONF_LATITUDE: self.hass.config.latitude,
|
||||
CONF_LONGITUDE: self.hass.config.longitude,
|
||||
CONF_DECLINATION: DEFAULT_DECLINATION,
|
||||
CONF_AZIMUTH: DEFAULT_AZIMUTH,
|
||||
CONF_MODULES_POWER: DEFAULT_MODULES_POWER,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ForecastSolarOptionFlowHandler(OptionsFlowWithReload):
|
||||
class ForecastSolarOptionFlowHandler(OptionsFlow):
|
||||
"""Handle options."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
planes_count = len(
|
||||
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
if (api_key := user_input.get(CONF_API_KEY)) and RE_API_KEY.match(
|
||||
api_key
|
||||
) is None:
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
if planes_count > 1 and not api_key:
|
||||
errors[CONF_API_KEY] = "api_key_required"
|
||||
elif api_key and RE_API_KEY.match(api_key) is None:
|
||||
errors[CONF_API_KEY] = "invalid_api_key"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="", data=user_input | {CONF_API_KEY: api_key or None}
|
||||
)
|
||||
|
||||
suggested_api_key = self.config_entry.options.get(CONF_API_KEY, "")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
vol.Required(
|
||||
CONF_API_KEY,
|
||||
description={
|
||||
"suggested_value": self.config_entry.options.get(
|
||||
CONF_API_KEY, ""
|
||||
)
|
||||
},
|
||||
default=suggested_api_key,
|
||||
)
|
||||
if planes_count > 1
|
||||
else vol.Optional(
|
||||
CONF_API_KEY,
|
||||
description={"suggested_value": suggested_api_key},
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_DECLINATION,
|
||||
default=self.config_entry.options[CONF_DECLINATION],
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=0, max=90)),
|
||||
vol.Required(
|
||||
CONF_AZIMUTH,
|
||||
default=self.config_entry.options.get(CONF_AZIMUTH),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=-0, max=360)),
|
||||
vol.Required(
|
||||
CONF_MODULES_POWER,
|
||||
default=self.config_entry.options[CONF_MODULES_POWER],
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
vol.Optional(
|
||||
CONF_DAMPING_MORNING,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_DAMPING_MORNING, 0.0
|
||||
CONF_DAMPING_MORNING, DEFAULT_DAMPING
|
||||
),
|
||||
): vol.Coerce(float),
|
||||
): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0,
|
||||
max=1,
|
||||
step=0.01,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
),
|
||||
),
|
||||
vol.Coerce(float),
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_DAMPING_EVENING,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_DAMPING_EVENING, 0.0
|
||||
CONF_DAMPING_EVENING, DEFAULT_DAMPING
|
||||
),
|
||||
): vol.Coerce(float),
|
||||
): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0,
|
||||
max=1,
|
||||
step=0.01,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
),
|
||||
),
|
||||
vol.Coerce(float),
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_INVERTER_SIZE,
|
||||
description={
|
||||
@@ -149,8 +212,89 @@ class ForecastSolarOptionFlowHandler(OptionsFlowWithReload):
|
||||
CONF_INVERTER_SIZE
|
||||
)
|
||||
},
|
||||
): vol.Coerce(int),
|
||||
): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
step=1,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
),
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class PlaneSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle a subentry flow for adding/editing a plane."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the user step to add a new plane."""
|
||||
entry = self._get_entry()
|
||||
planes_count = len(entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE))
|
||||
if planes_count >= MAX_PLANES:
|
||||
return self.async_abort(reason="max_planes")
|
||||
if planes_count >= 1 and not entry.options.get(CONF_API_KEY):
|
||||
return self.async_abort(reason="api_key_required")
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W",
|
||||
data={
|
||||
CONF_DECLINATION: user_input[CONF_DECLINATION],
|
||||
CONF_AZIMUTH: user_input[CONF_AZIMUTH],
|
||||
CONF_MODULES_POWER: user_input[CONF_MODULES_POWER],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
PLANE_SCHEMA,
|
||||
{
|
||||
CONF_DECLINATION: DEFAULT_DECLINATION,
|
||||
CONF_AZIMUTH: DEFAULT_AZIMUTH,
|
||||
CONF_MODULES_POWER: DEFAULT_MODULES_POWER,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle reconfiguration of an existing plane."""
|
||||
subentry = self._get_reconfigure_subentry()
|
||||
|
||||
if user_input is not None:
|
||||
entry = self._get_entry()
|
||||
if self._async_update(
|
||||
entry,
|
||||
subentry,
|
||||
data={
|
||||
CONF_DECLINATION: user_input[CONF_DECLINATION],
|
||||
CONF_AZIMUTH: user_input[CONF_AZIMUTH],
|
||||
CONF_MODULES_POWER: user_input[CONF_MODULES_POWER],
|
||||
},
|
||||
title=f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W",
|
||||
):
|
||||
if not entry.update_listeners:
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
return self.async_abort(reason="reconfigure_successful")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
PLANE_SCHEMA,
|
||||
{
|
||||
CONF_DECLINATION: subentry.data[CONF_DECLINATION],
|
||||
CONF_AZIMUTH: subentry.data[CONF_AZIMUTH],
|
||||
CONF_MODULES_POWER: subentry.data[CONF_MODULES_POWER],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,3 +14,9 @@ CONF_DAMPING = "damping"
|
||||
CONF_DAMPING_MORNING = "damping_morning"
|
||||
CONF_DAMPING_EVENING = "damping_evening"
|
||||
CONF_INVERTER_SIZE = "inverter_size"
|
||||
DEFAULT_DECLINATION = 25
|
||||
DEFAULT_AZIMUTH = 180
|
||||
DEFAULT_MODULES_POWER = 10000
|
||||
DEFAULT_DAMPING = 0.0
|
||||
MAX_PLANES = 4
|
||||
SUBENTRY_TYPE_PLANE = "plane"
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from forecast_solar import Estimate, ForecastSolar, ForecastSolarConnectionError
|
||||
from forecast_solar import Estimate, ForecastSolar, ForecastSolarConnectionError, Plane
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
|
||||
@@ -19,8 +19,10 @@ from .const import (
|
||||
CONF_DECLINATION,
|
||||
CONF_INVERTER_SIZE,
|
||||
CONF_MODULES_POWER,
|
||||
DEFAULT_DAMPING,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
SUBENTRY_TYPE_PLANE,
|
||||
)
|
||||
|
||||
type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator]
|
||||
@@ -30,6 +32,7 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]):
|
||||
"""The Forecast.Solar Data Update Coordinator."""
|
||||
|
||||
config_entry: ForecastSolarConfigEntry
|
||||
forecast: ForecastSolar
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ForecastSolarConfigEntry) -> None:
|
||||
"""Initialize the Forecast.Solar coordinator."""
|
||||
@@ -43,17 +46,34 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]):
|
||||
) is not None and inverter_size > 0:
|
||||
inverter_size = inverter_size / 1000
|
||||
|
||||
# Build the list of planes from subentries.
|
||||
plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
|
||||
|
||||
# The first plane subentry is the main plane
|
||||
main_plane = plane_subentries[0]
|
||||
|
||||
# Additional planes
|
||||
planes: list[Plane] = [
|
||||
Plane(
|
||||
declination=subentry.data[CONF_DECLINATION],
|
||||
azimuth=(subentry.data[CONF_AZIMUTH] - 180),
|
||||
kwp=(subentry.data[CONF_MODULES_POWER] / 1000),
|
||||
)
|
||||
for subentry in plane_subentries[1:]
|
||||
]
|
||||
|
||||
self.forecast = ForecastSolar(
|
||||
api_key=api_key,
|
||||
session=async_get_clientsession(hass),
|
||||
latitude=entry.data[CONF_LATITUDE],
|
||||
longitude=entry.data[CONF_LONGITUDE],
|
||||
declination=entry.options[CONF_DECLINATION],
|
||||
azimuth=(entry.options[CONF_AZIMUTH] - 180),
|
||||
kwp=(entry.options[CONF_MODULES_POWER] / 1000),
|
||||
damping_morning=entry.options.get(CONF_DAMPING_MORNING, 0.0),
|
||||
damping_evening=entry.options.get(CONF_DAMPING_EVENING, 0.0),
|
||||
declination=main_plane.data[CONF_DECLINATION],
|
||||
azimuth=(main_plane.data[CONF_AZIMUTH] - 180),
|
||||
kwp=(main_plane.data[CONF_MODULES_POWER] / 1000),
|
||||
damping_morning=entry.options.get(CONF_DAMPING_MORNING, DEFAULT_DAMPING),
|
||||
damping_evening=entry.options.get(CONF_DAMPING_EVENING, DEFAULT_DAMPING),
|
||||
inverter=inverter_size,
|
||||
planes=planes,
|
||||
)
|
||||
|
||||
# Free account have a resolution of 1 hour, using that as the default
|
||||
|
||||
@@ -28,6 +28,13 @@ async def async_get_config_entry_diagnostics(
|
||||
"title": entry.title,
|
||||
"data": async_redact_data(entry.data, TO_REDACT),
|
||||
"options": async_redact_data(entry.options, TO_REDACT),
|
||||
"subentries": [
|
||||
{
|
||||
"data": dict(subentry.data),
|
||||
"title": subentry.title,
|
||||
}
|
||||
for subentry in entry.subentries.values()
|
||||
],
|
||||
},
|
||||
"data": {
|
||||
"energy_production_today": coordinator.data.energy_production_today,
|
||||
|
||||
@@ -14,6 +14,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"plane": {
|
||||
"abort": {
|
||||
"api_key_required": "An API key is required to add more than one plane. You can configure it in the integration options.",
|
||||
"max_planes": "You can add a maximum of 4 planes.",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"entry_type": "Plane",
|
||||
"initiate_flow": {
|
||||
"user": "Add plane"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]",
|
||||
"declination": "[%key:component::forecast_solar::config::step::user::data::declination%]",
|
||||
"modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]"
|
||||
},
|
||||
"description": "Edit the solar plane configuration."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]",
|
||||
"declination": "[%key:component::forecast_solar::config::step::user::data::declination%]",
|
||||
"modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]"
|
||||
},
|
||||
"description": "Add a solar plane. Multiple planes are supported with a Forecast.Solar API subscription."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"energy_current_hour": {
|
||||
@@ -51,20 +82,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_key_required": {
|
||||
"message": "An API key is required when more than one plane is configured"
|
||||
},
|
||||
"no_plane": {
|
||||
"message": "No plane configured, cannot set up Forecast.Solar"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {
|
||||
"api_key_required": "An API key is required to add more than one plane. You can configure it in the integration options.",
|
||||
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]",
|
||||
"damping_evening": "Damping factor: adjusts the results in the evening",
|
||||
"damping_morning": "Damping factor: adjusts the results in the morning",
|
||||
"declination": "[%key:component::forecast_solar::config::step::user::data::declination%]",
|
||||
"inverter_size": "Inverter size (Watt)",
|
||||
"modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]"
|
||||
"inverter_size": "Inverter size (Watt)"
|
||||
},
|
||||
"description": "These values allow tweaking the Forecast.Solar result. Please refer to the documentation if a field is unclear."
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from forecast_solar import models
|
||||
@@ -15,7 +16,9 @@ from homeassistant.components.forecast_solar.const import (
|
||||
CONF_INVERTER_SIZE,
|
||||
CONF_MODULES_POWER,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_PLANE,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentryData
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -33,26 +36,44 @@ def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
def api_key_present() -> bool:
|
||||
"""Return whether an API key should be present in the config entry options."""
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(api_key_present: bool) -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
options: dict[str, Any] = {
|
||||
CONF_DAMPING_MORNING: 0.5,
|
||||
CONF_DAMPING_EVENING: 0.5,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
}
|
||||
if api_key_present:
|
||||
options[CONF_API_KEY] = "abcdef1234567890"
|
||||
return MockConfigEntry(
|
||||
title="Green House",
|
||||
unique_id="unique",
|
||||
version=2,
|
||||
version=3,
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_LATITUDE: 52.42,
|
||||
CONF_LONGITUDE: 4.42,
|
||||
},
|
||||
options={
|
||||
CONF_API_KEY: "abcdef12345",
|
||||
CONF_DECLINATION: 30,
|
||||
CONF_AZIMUTH: 190,
|
||||
CONF_MODULES_POWER: 5100,
|
||||
CONF_DAMPING_MORNING: 0.5,
|
||||
CONF_DAMPING_EVENING: 0.5,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
},
|
||||
options=options,
|
||||
subentries_data=[
|
||||
ConfigSubentryData(
|
||||
data={
|
||||
CONF_DECLINATION: 30,
|
||||
CONF_AZIMUTH: 190,
|
||||
CONF_MODULES_POWER: 5100,
|
||||
},
|
||||
subentry_id="mock_plane_id",
|
||||
subentry_type=SUBENTRY_TYPE_PLANE,
|
||||
title="30° / 190° / 5100W",
|
||||
unique_id=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -32,13 +32,20 @@
|
||||
}),
|
||||
'options': dict({
|
||||
'api_key': '**REDACTED**',
|
||||
'azimuth': 190,
|
||||
'damping_evening': 0.5,
|
||||
'damping_morning': 0.5,
|
||||
'declination': 30,
|
||||
'inverter_size': 2000,
|
||||
'modules_power': 5100,
|
||||
}),
|
||||
'subentries': list([
|
||||
dict({
|
||||
'data': dict({
|
||||
'azimuth': 190,
|
||||
'declination': 30,
|
||||
'modules_power': 5100,
|
||||
}),
|
||||
'title': '30° / 190° / 5100W',
|
||||
}),
|
||||
]),
|
||||
'title': 'Green House',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_migration
|
||||
ConfigEntrySnapshot({
|
||||
'data': dict({
|
||||
'latitude': 52.42,
|
||||
'longitude': 4.42,
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'forecast_solar',
|
||||
'entry_id': <ANY>,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
'api_key': 'abcdef12345',
|
||||
'azimuth': 190,
|
||||
'damping_evening': 0.5,
|
||||
'damping_morning': 0.5,
|
||||
'declination': 30,
|
||||
'inverter_size': 2000,
|
||||
'modules_power': 5100,
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'user',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Green House',
|
||||
'unique_id': 'unique',
|
||||
'version': 2,
|
||||
})
|
||||
# ---
|
||||
@@ -12,8 +12,13 @@ from homeassistant.components.forecast_solar.const import (
|
||||
CONF_INVERTER_SIZE,
|
||||
CONF_MODULES_POWER,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_PLANE,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
SOURCE_USER,
|
||||
ConfigSubentryData,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
@@ -51,11 +56,19 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No
|
||||
CONF_LATITUDE: 52.42,
|
||||
CONF_LONGITUDE: 4.42,
|
||||
}
|
||||
assert config_entry.options == {
|
||||
CONF_AZIMUTH: 142,
|
||||
assert config_entry.options == {}
|
||||
|
||||
# Verify a plane subentry was created
|
||||
plane_subentries = config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
|
||||
assert len(plane_subentries) == 1
|
||||
subentry = plane_subentries[0]
|
||||
assert subentry.subentry_type == SUBENTRY_TYPE_PLANE
|
||||
assert subentry.data == {
|
||||
CONF_DECLINATION: 42,
|
||||
CONF_AZIMUTH: 142,
|
||||
CONF_MODULES_POWER: 4242,
|
||||
}
|
||||
assert subentry.title == "42° / 142° / 4242W"
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
@@ -79,15 +92,11 @@ async def test_options_flow_invalid_api(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_API_KEY: "solarPOWER!",
|
||||
CONF_DECLINATION: 21,
|
||||
CONF_AZIMUTH: 22,
|
||||
CONF_MODULES_POWER: 2122,
|
||||
CONF_DAMPING_MORNING: 0.25,
|
||||
CONF_DAMPING_EVENING: 0.25,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {CONF_API_KEY: "invalid_api_key"}
|
||||
@@ -97,22 +106,15 @@ async def test_options_flow_invalid_api(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_API_KEY: "SolarForecast150",
|
||||
CONF_DECLINATION: 21,
|
||||
CONF_AZIMUTH: 22,
|
||||
CONF_MODULES_POWER: 2122,
|
||||
CONF_DAMPING_MORNING: 0.25,
|
||||
CONF_DAMPING_EVENING: 0.25,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_API_KEY: "SolarForecast150",
|
||||
CONF_DECLINATION: 21,
|
||||
CONF_AZIMUTH: 22,
|
||||
CONF_MODULES_POWER: 2122,
|
||||
CONF_DAMPING_MORNING: 0.25,
|
||||
CONF_DAMPING_EVENING: 0.25,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
@@ -139,22 +141,15 @@ async def test_options_flow(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_API_KEY: "SolarForecast150",
|
||||
CONF_DECLINATION: 21,
|
||||
CONF_AZIMUTH: 22,
|
||||
CONF_MODULES_POWER: 2122,
|
||||
CONF_DAMPING_MORNING: 0.25,
|
||||
CONF_DAMPING_EVENING: 0.25,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_API_KEY: "SolarForecast150",
|
||||
CONF_DECLINATION: 21,
|
||||
CONF_AZIMUTH: 22,
|
||||
CONF_MODULES_POWER: 2122,
|
||||
CONF_DAMPING_MORNING: 0.25,
|
||||
CONF_DAMPING_EVENING: 0.25,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
@@ -180,23 +175,293 @@ async def test_options_flow_without_key(
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_DECLINATION: 21,
|
||||
CONF_AZIMUTH: 22,
|
||||
CONF_MODULES_POWER: 2122,
|
||||
CONF_DAMPING_MORNING: 0.25,
|
||||
CONF_DAMPING_EVENING: 0.25,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_API_KEY: None,
|
||||
CONF_DECLINATION: 21,
|
||||
CONF_AZIMUTH: 22,
|
||||
CONF_MODULES_POWER: 2122,
|
||||
CONF_DAMPING_MORNING: 0.25,
|
||||
CONF_DAMPING_EVENING: 0.25,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_options_flow_required_api_key(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test config flow options requires API key when multiple planes are present."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
title="Green House",
|
||||
unique_id="unique",
|
||||
version=3,
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_LATITUDE: 52.42,
|
||||
CONF_LONGITUDE: 4.42,
|
||||
},
|
||||
options={
|
||||
CONF_DAMPING_MORNING: 0.5,
|
||||
CONF_DAMPING_EVENING: 0.5,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
CONF_API_KEY: "abcdef1234567890",
|
||||
},
|
||||
subentries_data=[
|
||||
ConfigSubentryData(
|
||||
data={
|
||||
CONF_DECLINATION: 30,
|
||||
CONF_AZIMUTH: 190,
|
||||
CONF_MODULES_POWER: 5100,
|
||||
},
|
||||
subentry_id="mock_plane_id",
|
||||
subentry_type=SUBENTRY_TYPE_PLANE,
|
||||
title="30° / 190° / 5100W",
|
||||
unique_id=None,
|
||||
),
|
||||
ConfigSubentryData(
|
||||
data={
|
||||
CONF_DECLINATION: 45,
|
||||
CONF_AZIMUTH: 270,
|
||||
CONF_MODULES_POWER: 3000,
|
||||
},
|
||||
subentry_id="second_plane_id",
|
||||
subentry_type=SUBENTRY_TYPE_PLANE,
|
||||
title="45° / 270° / 3000W",
|
||||
unique_id=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
# Try to save with an empty API key
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_API_KEY: "",
|
||||
CONF_DAMPING_MORNING: 0.25,
|
||||
CONF_DAMPING_EVENING: 0.25,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {CONF_API_KEY: "api_key_required"}
|
||||
|
||||
# Now provide an API key
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_API_KEY: "SolarForecast150",
|
||||
CONF_DAMPING_MORNING: 0.25,
|
||||
CONF_DAMPING_EVENING: 0.25,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_API_KEY: "SolarForecast150",
|
||||
CONF_DAMPING_MORNING: 0.25,
|
||||
CONF_DAMPING_EVENING: 0.25,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_subentry_flow_add_plane(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test adding a plane via subentry flow."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_DECLINATION: 45,
|
||||
CONF_AZIMUTH: 270,
|
||||
CONF_MODULES_POWER: 3000,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "45° / 270° / 3000W"
|
||||
assert result["data"] == {
|
||||
CONF_DECLINATION: 45,
|
||||
CONF_AZIMUTH: 270,
|
||||
CONF_MODULES_POWER: 3000,
|
||||
}
|
||||
|
||||
assert len(mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)) == 2
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_forecast_solar")
|
||||
async def test_subentry_flow_reconfigure_plane(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reconfiguring a plane via subentry flow."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Get the existing plane subentry id
|
||||
subentry_id = mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)[
|
||||
0
|
||||
].subentry_id
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE),
|
||||
context={"source": SOURCE_RECONFIGURE, "subentry_id": subentry_id},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_DECLINATION: 50,
|
||||
CONF_AZIMUTH: 200,
|
||||
CONF_MODULES_POWER: 6000,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
plane_subentries = mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
|
||||
assert len(plane_subentries) == 1
|
||||
subentry = plane_subentries[0]
|
||||
assert subentry.data == {
|
||||
CONF_DECLINATION: 50,
|
||||
CONF_AZIMUTH: 200,
|
||||
CONF_MODULES_POWER: 6000,
|
||||
}
|
||||
assert subentry.title == "50° / 200° / 6000W"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_key_present", [False])
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_subentry_flow_no_api_key(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that adding more than one plane without API key is not allowed."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "api_key_required"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_subentry_flow_max_planes(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that adding more than 4 planes is not allowed."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# mock_config_entry already has 1 plane subentry; add 3 more to reach the limit
|
||||
for i in range(3):
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_DECLINATION: 10 * (i + 1),
|
||||
CONF_AZIMUTH: 90 * (i + 1),
|
||||
CONF_MODULES_POWER: 1000 * (i + 1),
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
assert len(mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)) == 4
|
||||
|
||||
# Attempt to add a 5th plane should be aborted
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "max_planes"
|
||||
|
||||
|
||||
async def test_subentry_flow_reconfigure_plane_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reconfiguring a plane via subentry flow when entry is not loaded."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
# Entry is not loaded, so it has no update listeners
|
||||
|
||||
# Get the existing plane subentry id
|
||||
subentry_id = mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)[
|
||||
0
|
||||
].subentry_id
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE),
|
||||
context={"source": SOURCE_RECONFIGURE, "subentry_id": subentry_id},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_DECLINATION: 50,
|
||||
CONF_AZIMUTH: 200,
|
||||
CONF_MODULES_POWER: 6000,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
plane_subentries = mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
|
||||
assert len(plane_subentries) == 1
|
||||
subentry = plane_subentries[0]
|
||||
assert subentry.data == {
|
||||
CONF_DECLINATION: 50,
|
||||
CONF_AZIMUTH: 200,
|
||||
CONF_MODULES_POWER: 6000,
|
||||
}
|
||||
assert subentry.title == "50° / 200° / 6000W"
|
||||
|
||||
@@ -65,3 +65,8 @@ async def test_energy_solar_forecast_filters_midnight_utc_zeros(
|
||||
"2021-06-27T15:00:00+00:00": 292,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_energy_solar_forecast_invalid_id(hass: HomeAssistant) -> None:
|
||||
"""Test the Forecast.Solar energy platform with invalid config entry ID."""
|
||||
assert await energy.async_get_solar_forecast(hass, "invalid_id") is None
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from forecast_solar import ForecastSolarConnectionError
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from forecast_solar import ForecastSolarConnectionError, Plane
|
||||
|
||||
from homeassistant.components.forecast_solar.const import (
|
||||
CONF_AZIMUTH,
|
||||
CONF_DAMPING,
|
||||
CONF_DAMPING_EVENING,
|
||||
CONF_DAMPING_MORNING,
|
||||
CONF_DECLINATION,
|
||||
CONF_INVERTER_SIZE,
|
||||
CONF_MODULES_POWER,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_PLANE,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -55,12 +58,16 @@ async def test_config_entry_not_ready(
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_migration(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
|
||||
"""Test config entry version 1 -> 2 migration."""
|
||||
async def test_migration_from_v1(
|
||||
hass: HomeAssistant,
|
||||
mock_forecast_solar: MagicMock,
|
||||
) -> None:
|
||||
"""Test config entry migration from version 1."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
title="Green House",
|
||||
unique_id="unique",
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
data={
|
||||
CONF_LATITUDE: 52.42,
|
||||
CONF_LONGITUDE: 4.42,
|
||||
@@ -78,4 +85,215 @@ async def test_migration(hass: HomeAssistant, snapshot: SnapshotAssertion) -> No
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) == snapshot
|
||||
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
||||
assert entry.version == 3
|
||||
assert entry.options == {
|
||||
CONF_API_KEY: "abcdef12345",
|
||||
"damping_morning": 0.5,
|
||||
"damping_evening": 0.5,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
}
|
||||
plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
|
||||
assert len(plane_subentries) == 1
|
||||
subentry = plane_subentries[0]
|
||||
assert subentry.subentry_type == SUBENTRY_TYPE_PLANE
|
||||
assert subentry.data == {
|
||||
CONF_DECLINATION: 30,
|
||||
CONF_AZIMUTH: 190,
|
||||
CONF_MODULES_POWER: 5100,
|
||||
}
|
||||
assert subentry.title == "30° / 190° / 5100W"
|
||||
|
||||
|
||||
async def test_migration_from_v2(
|
||||
hass: HomeAssistant,
|
||||
mock_forecast_solar: MagicMock,
|
||||
) -> None:
|
||||
"""Test config entry migration from version 2."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
title="Green House",
|
||||
unique_id="unique",
|
||||
domain=DOMAIN,
|
||||
version=2,
|
||||
data={
|
||||
CONF_LATITUDE: 52.42,
|
||||
CONF_LONGITUDE: 4.42,
|
||||
},
|
||||
options={
|
||||
CONF_API_KEY: "abcdef12345",
|
||||
CONF_DECLINATION: 30,
|
||||
CONF_AZIMUTH: 190,
|
||||
CONF_MODULES_POWER: 5100,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
},
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
||||
assert entry.version == 3
|
||||
assert entry.options == {
|
||||
CONF_API_KEY: "abcdef12345",
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
}
|
||||
plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
|
||||
assert len(plane_subentries) == 1
|
||||
subentry = plane_subentries[0]
|
||||
assert subentry.subentry_type == SUBENTRY_TYPE_PLANE
|
||||
assert subentry.data == {
|
||||
CONF_DECLINATION: 30,
|
||||
CONF_AZIMUTH: 190,
|
||||
CONF_MODULES_POWER: 5100,
|
||||
}
|
||||
assert subentry.title == "30° / 190° / 5100W"
|
||||
|
||||
|
||||
async def test_setup_entry_no_planes(
|
||||
hass: HomeAssistant,
|
||||
mock_forecast_solar: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup fails when all plane subentries have been removed."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
title="Green House",
|
||||
unique_id="unique",
|
||||
version=3,
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_LATITUDE: 52.42,
|
||||
CONF_LONGITUDE: 4.42,
|
||||
},
|
||||
options={
|
||||
CONF_API_KEY: "abcdef1234567890",
|
||||
},
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_setup_entry_multiple_planes_no_api_key(
|
||||
hass: HomeAssistant,
|
||||
mock_forecast_solar: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup fails when multiple planes are configured without an API key."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
title="Green House",
|
||||
unique_id="unique",
|
||||
version=3,
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_LATITUDE: 52.42,
|
||||
CONF_LONGITUDE: 4.42,
|
||||
},
|
||||
options={},
|
||||
subentries_data=[
|
||||
ConfigSubentryData(
|
||||
data={
|
||||
CONF_DECLINATION: 30,
|
||||
CONF_AZIMUTH: 190,
|
||||
CONF_MODULES_POWER: 5100,
|
||||
},
|
||||
subentry_id="plane_1",
|
||||
subentry_type=SUBENTRY_TYPE_PLANE,
|
||||
title="30° / 190° / 5100W",
|
||||
unique_id=None,
|
||||
),
|
||||
ConfigSubentryData(
|
||||
data={
|
||||
CONF_DECLINATION: 45,
|
||||
CONF_AZIMUTH: 90,
|
||||
CONF_MODULES_POWER: 3000,
|
||||
},
|
||||
subentry_id="plane_2",
|
||||
subentry_type=SUBENTRY_TYPE_PLANE,
|
||||
title="45° / 90° / 3000W",
|
||||
unique_id=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_coordinator_multi_plane_initialization(
|
||||
hass: HomeAssistant,
|
||||
mock_forecast_solar: MagicMock,
|
||||
) -> None:
|
||||
"""Test the Forecast.Solar coordinator multi-plane initialization."""
|
||||
options = {
|
||||
CONF_API_KEY: "abcdef1234567890",
|
||||
CONF_DAMPING_MORNING: 0.5,
|
||||
CONF_DAMPING_EVENING: 0.5,
|
||||
CONF_INVERTER_SIZE: 2000,
|
||||
}
|
||||
|
||||
mock_config_entry = MockConfigEntry(
|
||||
title="Green House",
|
||||
unique_id="unique",
|
||||
version=3,
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_LATITUDE: 52.42,
|
||||
CONF_LONGITUDE: 4.42,
|
||||
},
|
||||
options=options,
|
||||
subentries_data=[
|
||||
ConfigSubentryData(
|
||||
data={
|
||||
CONF_DECLINATION: 30,
|
||||
CONF_AZIMUTH: 190,
|
||||
CONF_MODULES_POWER: 5100,
|
||||
},
|
||||
subentry_id="plane_1",
|
||||
subentry_type=SUBENTRY_TYPE_PLANE,
|
||||
title="30° / 190° / 5100W",
|
||||
unique_id=None,
|
||||
),
|
||||
ConfigSubentryData(
|
||||
data={
|
||||
CONF_DECLINATION: 45,
|
||||
CONF_AZIMUTH: 270,
|
||||
CONF_MODULES_POWER: 3000,
|
||||
},
|
||||
subentry_id="plane_2",
|
||||
subentry_type=SUBENTRY_TYPE_PLANE,
|
||||
title="45° / 270° / 3000W",
|
||||
unique_id=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.forecast_solar.coordinator.ForecastSolar",
|
||||
return_value=mock_forecast_solar,
|
||||
) as forecast_solar_mock:
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
forecast_solar_mock.assert_called_once()
|
||||
_, kwargs = forecast_solar_mock.call_args
|
||||
|
||||
assert kwargs["latitude"] == 52.42
|
||||
assert kwargs["longitude"] == 4.42
|
||||
assert kwargs["api_key"] == "abcdef1234567890"
|
||||
|
||||
# Main plane (plane_1)
|
||||
assert kwargs["declination"] == 30
|
||||
assert kwargs["azimuth"] == 10 # 190 - 180
|
||||
assert kwargs["kwp"] == 5.1 # 5100 / 1000
|
||||
|
||||
# Additional planes (plane_2)
|
||||
planes = kwargs["planes"]
|
||||
assert len(planes) == 1
|
||||
assert isinstance(planes[0], Plane)
|
||||
assert planes[0].declination == 45
|
||||
assert planes[0].azimuth == 90 # 270 - 180
|
||||
assert planes[0].kwp == 3.0 # 3000 / 1000
|
||||
|
||||
Reference in New Issue
Block a user