1
0
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:
Artem Khvastunov
2026-04-01 17:42:17 +02:00
committed by GitHub
parent 1b4286381d
commit b056723b98
12 changed files with 908 additions and 149 deletions

View File

@@ -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:

View File

@@ -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],
},
),
)

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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."
}

View File

@@ -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,
),
],
)

View File

@@ -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',
}),
})

View File

@@ -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,
})
# ---

View File

@@ -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"

View File

@@ -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

View File

@@ -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