From b056723b986ed9b0220587b5e865b9ea8abbeb7d Mon Sep 17 00:00:00 2001 From: Artem Khvastunov Date: Wed, 1 Apr 2026 17:42:17 +0200 Subject: [PATCH] Add multi-plane support for Forecast.Solar integration (#160058) Co-authored-by: Junie Co-authored-by: Junie Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/forecast_solar/__init__.py | 67 +++- .../components/forecast_solar/config_flow.py | 256 +++++++++++--- .../components/forecast_solar/const.py | 6 + .../components/forecast_solar/coordinator.py | 32 +- .../components/forecast_solar/diagnostics.py | 7 + .../components/forecast_solar/strings.json | 45 ++- tests/components/forecast_solar/conftest.py | 43 ++- .../snapshots/test_diagnostics.ambr | 13 +- .../forecast_solar/snapshots/test_init.ambr | 32 -- .../forecast_solar/test_config_flow.py | 321 ++++++++++++++++-- .../components/forecast_solar/test_energy.py | 5 + tests/components/forecast_solar/test_init.py | 230 ++++++++++++- 12 files changed, 908 insertions(+), 149 deletions(-) delete mode 100644 tests/components/forecast_solar/snapshots/test_init.ambr diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 7b534b80500..a684b766b61 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -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: diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 031764a0d0a..7fb1bde2d5a 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -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], + }, + ), + ) diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index ac80b64b869..22d0794ba7e 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -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" diff --git a/homeassistant/components/forecast_solar/coordinator.py b/homeassistant/components/forecast_solar/coordinator.py index efed954e490..65e699c8f38 100644 --- a/homeassistant/components/forecast_solar/coordinator.py +++ b/homeassistant/components/forecast_solar/coordinator.py @@ -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 diff --git a/homeassistant/components/forecast_solar/diagnostics.py b/homeassistant/components/forecast_solar/diagnostics.py index cb33ac5dc5a..80e412dd1a8 100644 --- a/homeassistant/components/forecast_solar/diagnostics.py +++ b/homeassistant/components/forecast_solar/diagnostics.py @@ -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, diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index b6cc406877f..3ed3f146a11 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -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." } diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 01c1f6d8d32..e8852114f1f 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -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, + ), + ], ) diff --git a/tests/components/forecast_solar/snapshots/test_diagnostics.ambr b/tests/components/forecast_solar/snapshots/test_diagnostics.ambr index 686721a9d4a..5dd7276e68b 100644 --- a/tests/components/forecast_solar/snapshots/test_diagnostics.ambr +++ b/tests/components/forecast_solar/snapshots/test_diagnostics.ambr @@ -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', }), }) diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr deleted file mode 100644 index c0db54c2d4e..00000000000 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ /dev/null @@ -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': , - '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, - }) -# --- diff --git a/tests/components/forecast_solar/test_config_flow.py b/tests/components/forecast_solar/test_config_flow.py index 8fffb5096bc..d560fe0dc16 100644 --- a/tests/components/forecast_solar/test_config_flow.py +++ b/tests/components/forecast_solar/test_config_flow.py @@ -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" diff --git a/tests/components/forecast_solar/test_energy.py b/tests/components/forecast_solar/test_energy.py index dc0b5c57430..813e3f84eeb 100644 --- a/tests/components/forecast_solar/test_energy.py +++ b/tests/components/forecast_solar/test_energy.py @@ -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 diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index 680a30580cb..50f87015ad6 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -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