diff --git a/.strict-typing b/.strict-typing index cde4d314cca..09578153163 100644 --- a/.strict-typing +++ b/.strict-typing @@ -30,6 +30,7 @@ homeassistant.components.dsmr.* homeassistant.components.dunehd.* homeassistant.components.elgato.* homeassistant.components.fitbit.* +homeassistant.components.forecast_solar.* homeassistant.components.fritzbox.* homeassistant.components.frontend.* homeassistant.components.geo_location.* diff --git a/CODEOWNERS b/CODEOWNERS index 592aa5923ba..9a1f44cb1c7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -163,6 +163,7 @@ homeassistant/components/flo/* @dmulcahey homeassistant/components/flock/* @fabaff homeassistant/components/flume/* @ChrisMandich @bdraco homeassistant/components/flunearyou/* @bachya +homeassistant/components/forecast_solar/* @klaasnicolaas @frenck homeassistant/components/forked_daapd/* @uvjustin homeassistant/components/fortios/* @kimfrellsen homeassistant/components/foscam/* @skgsergio diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py new file mode 100644 index 00000000000..b00e5f1c4ce --- /dev/null +++ b/homeassistant/components/forecast_solar/__init__.py @@ -0,0 +1,79 @@ +"""The Forecast.Solar integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from forecast_solar import ForecastSolar + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_AZIMUTH, + CONF_DAMPING, + CONF_DECLINATION, + CONF_MODULES_POWER, + DOMAIN, +) + +PLATFORMS = ["sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Forecast.Solar from a config entry.""" + api_key = entry.options.get(CONF_API_KEY) + # Our option flow may cause it to be an empty string, + # this if statement is here to catch that. + if not api_key: + api_key = None + + forecast = ForecastSolar( + api_key=api_key, + 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=entry.options.get(CONF_DAMPING, 0), + ) + + # Free account have a resolution of 1 hour, using that as the default + # update interval. Using a higher value for accounts with an API key. + update_interval = timedelta(hours=1) + if api_key is not None: + update_interval = timedelta(minutes=30) + + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_method=forecast.estimate, + update_interval=update_interval, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py new file mode 100644 index 00000000000..256534da67a --- /dev/null +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for Forecast.Solar integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_AZIMUTH, + CONF_DAMPING, + CONF_DECLINATION, + CONF_MODULES_POWER, + DOMAIN, +) + + +class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Forecast.Solar.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> ForecastSolarOptionFlowHandler: + """Get the options flow for this handler.""" + return ForecastSolarOptionFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if user_input is not None: + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + 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], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.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.Coerce(int), + } + ), + ) + + +class ForecastSolarOptionFlowHandler(OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_API_KEY, + default=self.config_entry.options.get(CONF_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.Coerce(int), + vol.Optional( + CONF_DAMPING, + default=self.config_entry.options.get(CONF_DAMPING, 0.0), + ): vol.Coerce(float), + } + ), + ) diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py new file mode 100644 index 00000000000..12aa1ee5362 --- /dev/null +++ b/homeassistant/components/forecast_solar/const.py @@ -0,0 +1,89 @@ +"""Constants for the Forecast.Solar integration.""" +from __future__ import annotations + +from typing import Final + +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TIMESTAMP, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) + +from .models import ForecastSolarSensor + +DOMAIN = "forecast_solar" + +CONF_DECLINATION = "declination" +CONF_AZIMUTH = "azimuth" +CONF_MODULES_POWER = "modules power" +CONF_DAMPING = "damping" +ATTR_ENTRY_TYPE: Final = "entry_type" +ENTRY_TYPE_SERVICE: Final = "service" + +SENSORS: list[ForecastSolarSensor] = [ + ForecastSolarSensor( + key="energy_production_today", + name="Estimated Energy Production - Today", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + ForecastSolarSensor( + key="energy_production_tomorrow", + name="Estimated Energy Production - Tomorrow", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + ForecastSolarSensor( + key="power_highest_peak_time_today", + name="Highest Power Peak Time - Today", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + ForecastSolarSensor( + key="power_highest_peak_time_tomorrow", + name="Highest Power Peak Time - Tomorrow", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + ForecastSolarSensor( + key="power_production_now", + name="Estimated Power Production - Now", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=POWER_WATT, + ), + ForecastSolarSensor( + key="power_production_next_hour", + name="Estimated Power Production - Next Hour", + device_class=DEVICE_CLASS_POWER, + entity_registry_enabled_default=False, + unit_of_measurement=POWER_WATT, + ), + ForecastSolarSensor( + key="power_production_next_12hours", + name="Estimated Power Production - Next 12 Hours", + device_class=DEVICE_CLASS_POWER, + entity_registry_enabled_default=False, + unit_of_measurement=POWER_WATT, + ), + ForecastSolarSensor( + key="power_production_next_24hours", + name="Estimated Power Production - Next 24 Hours", + device_class=DEVICE_CLASS_POWER, + entity_registry_enabled_default=False, + unit_of_measurement=POWER_WATT, + ), + ForecastSolarSensor( + key="energy_current_hour", + name="Estimated Energy Production - This Hour", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + ForecastSolarSensor( + key="energy_next_hour", + name="Estimated Energy Production - Next Hour", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), +] diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json new file mode 100644 index 00000000000..c17e8bd51f8 --- /dev/null +++ b/homeassistant/components/forecast_solar/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "forecast_solar", + "name": "Forecast.Solar", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/forecast_solar", + "requirements": ["forecast_solar==1.3.1"], + "codeowners": ["@klaasnicolaas", "@frenck"], + "quality_scale": "platinum", + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/forecast_solar/models.py b/homeassistant/components/forecast_solar/models.py new file mode 100644 index 00000000000..d01f17fc975 --- /dev/null +++ b/homeassistant/components/forecast_solar/models.py @@ -0,0 +1,17 @@ +"""Models for the Forecast.Solar integration.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ForecastSolarSensor: + """Represents an Forecast.Solar Sensor.""" + + key: str + name: str + + device_class: str | None = None + entity_registry_enabled_default: bool = True + state_class: str | None = None + unit_of_measurement: str | None = None diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py new file mode 100644 index 00000000000..a6b1927926e --- /dev/null +++ b/homeassistant/components/forecast_solar/sensor.py @@ -0,0 +1,68 @@ +"""Support for the Forecast.Solar sensor service.""" +from __future__ import annotations + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ATTR_ENTRY_TYPE, DOMAIN, ENTRY_TYPE_SERVICE, SENSORS +from .models import ForecastSolarSensor + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Defer sensor setup to the shared sensor module.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ForecastSolarSensorEntity( + entry_id=entry.entry_id, coordinator=coordinator, sensor=sensor + ) + for sensor in SENSORS + ) + + +class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): + """Defines a Forcast.Solar sensor.""" + + def __init__( + self, + *, + entry_id: str, + coordinator: DataUpdateCoordinator, + sensor: ForecastSolarSensor, + ) -> None: + """Initialize Forcast.Solar sensor.""" + super().__init__(coordinator=coordinator) + self._sensor = sensor + + self.entity_id = f"{SENSOR_DOMAIN}.{sensor.key}" + self._attr_device_class = sensor.device_class + self._attr_entity_registry_enabled_default = ( + sensor.entity_registry_enabled_default + ) + self._attr_name = sensor.name + self._attr_state_class = sensor.state_class + self._attr_unique_id = f"{entry_id}_{sensor.key}" + self._attr_unit_of_measurement = sensor.unit_of_measurement + + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, entry_id)}, + ATTR_NAME: "Solar Production Forecast", + ATTR_MANUFACTURER: "Forecast.Solar", + ATTR_ENTRY_TYPE: ENTRY_TYPE_SERVICE, + } + + @property + def state(self) -> StateType: + """Return the state of the sensor.""" + state: StateType = getattr(self.coordinator.data, self._sensor.key) + return state diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json new file mode 100644 index 00000000000..eb98fc79297 --- /dev/null +++ b/homeassistant/components/forecast_solar/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "description": "Fill in the data of your solar panels. Please refer to the documentation if a field is unclear.", + "data": { + "azimuth": "Azimuth (360 degrees, 0 = North, 90 = East, 180 = South, 270 = West)", + "declination": "Declination (0 = Horizontal, 90 = Vertical)", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "modules power": "Total Watt peak power of your solar modules", + "name": "[%key:common::config_flow::data::name%]" + } + } + } + }, + "options": { + "step": { + "init": { + "description": "These values allow tweaking the Solar.Forecast result. Please refer to the documentation is a field is unclear.", + "data": { + "api_key": "Forecast.Solar API Key (optional)", + "azimuth": "Azimuth (360 degrees, 0 = North, 90 = East, 180 = South, 270 = West)", + "damping": "Damping factor: adjusts the results in the morning and evening", + "declination": "Declination (0 = Horizontal, 90 = Vertical)", + "modules power": "Total Watt peak power of your solar modules" + } + } + } + } +} diff --git a/homeassistant/components/forecast_solar/translations/en.json b/homeassistant/components/forecast_solar/translations/en.json new file mode 100644 index 00000000000..6de9cddc567 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimuth (360 degrees, 0 = North, 90 = East, 180 = South, 270 = West)", + "declination": "Declination (0 = Horizontal, 90 = Vertical)", + "latitude": "Latitude", + "longitude": "Longitude", + "modules power": "Total Watt peak power of your solar modules", + "name": "Name" + }, + "description": "Fill in the data of your solar panels. Please refer to the documentation if a field is unclear." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API Key (optional)", + "azimuth": "Azimuth (360 degrees, 0 = North, 90 = East, 180 = South, 270 = West)", + "damping": "Damping factor: adjusts the results in the morning and evening", + "declination": "Declination (0 = Horizontal, 90 = Vertical)", + "modules power": "Total Watt peak power of your solar modules" + }, + "description": "These values allow tweaking the Solar.Forecast result. Please refer to the documentation is a field is unclear." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7af0bfd129e..69886e370f7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -77,6 +77,7 @@ FLOWS = [ "flo", "flume", "flunearyou", + "forecast_solar", "forked_daapd", "foscam", "freebox", diff --git a/mypy.ini b/mypy.ini index 0c4fe1bf7a8..4472311279f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -341,6 +341,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.forecast_solar.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.fritzbox.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 06b13349058..8feeebdb925 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -623,6 +623,9 @@ fnvhash==0.1.0 # homeassistant.components.foobot foobot_async==1.0.0 +# homeassistant.components.forecast_solar +forecast_solar==1.3.1 + # homeassistant.components.fortios fortiosapi==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de7cd4ed7f5..45cb827bc6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -335,6 +335,9 @@ fnvhash==0.1.0 # homeassistant.components.foobot foobot_async==1.0.0 +# homeassistant.components.forecast_solar +forecast_solar==1.3.1 + # homeassistant.components.freebox freebox-api==0.0.10 diff --git a/tests/components/forecast_solar/__init__.py b/tests/components/forecast_solar/__init__.py new file mode 100644 index 00000000000..e3c1f710aef --- /dev/null +++ b/tests/components/forecast_solar/__init__.py @@ -0,0 +1 @@ +"""Tests for the Forecast Solar integration.""" diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py new file mode 100644 index 00000000000..c2b5fc08181 --- /dev/null +++ b/tests/components/forecast_solar/conftest.py @@ -0,0 +1,92 @@ +"""Fixtures for Forecast.Solar integration tests.""" + +import datetime +from typing import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.forecast_solar.const import ( + CONF_AZIMUTH, + CONF_DAMPING, + CONF_DECLINATION, + CONF_MODULES_POWER, + DOMAIN, +) +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +async def mock_persistent_notification(hass: HomeAssistant) -> None: + """Set up component for persistent notifications.""" + await async_setup_component(hass, "persistent_notification", {}) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Green House", + unique_id="unique", + 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: 0.5, + }, + ) + + +@pytest.fixture +def mock_forecast_solar() -> Generator[None, MagicMock, None]: + """Return a mocked Forecast.Solar client.""" + with patch( + "homeassistant.components.forecast_solar.ForecastSolar", autospec=True + ) as forecast_solar_mock: + forecast_solar = forecast_solar_mock.return_value + + estimate = MagicMock() + estimate.timezone = "Europe/Amsterdam" + estimate.energy_production_today = 100 + estimate.energy_production_tomorrow = 200 + estimate.power_production_now = 300 + estimate.power_highest_peak_time_today = datetime.datetime( + 2021, 6, 27, 13, 0, tzinfo=datetime.timezone.utc + ) + estimate.power_highest_peak_time_tomorrow = datetime.datetime( + 2021, 6, 27, 14, 0, tzinfo=datetime.timezone.utc + ) + estimate.power_production_next_hour = 400 + estimate.power_production_next_6hours = 500 + estimate.power_production_next_12hours = 600 + estimate.power_production_next_24hours = 700 + estimate.energy_current_hour = 800 + estimate.energy_next_hour = 900 + + forecast_solar.estimate.return_value = estimate + yield forecast_solar + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_forecast_solar: MagicMock, +) -> MockConfigEntry: + """Set up the Forecast.Solar integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/forecast_solar/test_config_flow.py b/tests/components/forecast_solar/test_config_flow.py new file mode 100644 index 00000000000..ac950d38b51 --- /dev/null +++ b/tests/components/forecast_solar/test_config_flow.py @@ -0,0 +1,89 @@ +"""Test the Forecast.Solar config flow.""" +from unittest.mock import patch + +from homeassistant.components.forecast_solar.const import ( + CONF_AZIMUTH, + CONF_DAMPING, + CONF_DECLINATION, + CONF_MODULES_POWER, + DOMAIN, +) +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 RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + with patch( + "homeassistant.components.forecast_solar.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "Name", + CONF_LATITUDE: 52.42, + CONF_LONGITUDE: 4.42, + CONF_AZIMUTH: 142, + CONF_DECLINATION: 42, + CONF_MODULES_POWER: 4242, + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "Name" + assert result2.get("data") == { + CONF_LATITUDE: 52.42, + CONF_LONGITUDE: 4.42, + } + assert result2.get("options") == { + CONF_AZIMUTH: 142, + CONF_DECLINATION: 42, + CONF_MODULES_POWER: 4242, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test config flow options.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "init" + assert "flow_id" in result + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "solarPOWER!", + CONF_DECLINATION: 21, + CONF_AZIMUTH: 22, + CONF_MODULES_POWER: 2122, + CONF_DAMPING: 0.25, + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("data") == { + CONF_API_KEY: "solarPOWER!", + CONF_DECLINATION: 21, + CONF_AZIMUTH: 22, + CONF_MODULES_POWER: 2122, + CONF_DAMPING: 0.25, + } diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py new file mode 100644 index 00000000000..719041aaf58 --- /dev/null +++ b/tests/components/forecast_solar/test_init.py @@ -0,0 +1,46 @@ +"""Tests for the Forecast.Solar integration.""" +from unittest.mock import MagicMock, patch + +from forecast_solar import ForecastSolarConnectionError + +from homeassistant.components.forecast_solar.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_forecast_solar: MagicMock, +) -> None: + """Test the Forecast.Solar configuration entry loading/unloading.""" + 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 == ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + + +@patch( + "homeassistant.components.forecast_solar.ForecastSolar.estimate", + side_effect=ForecastSolarConnectionError, +) +async def test_config_entry_not_ready( + mock_request: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Forecast.Solar configuration entry not ready.""" + 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_request.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py new file mode 100644 index 00000000000..a3513b86a5d --- /dev/null +++ b/tests/components/forecast_solar/test_sensor.py @@ -0,0 +1,228 @@ +"""Tests for the sensors provided by the Forecast.Solar integration.""" +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.forecast_solar.const import DOMAIN, ENTRY_TYPE_SERVICE +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TIMESTAMP, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Forecast.Solar sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.energy_production_today") + entry = entity_registry.async_get("sensor.energy_production_today") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_energy_production_today" + assert state.state == "100" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Estimated Energy Production - Today" + ) + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.energy_production_tomorrow") + entry = entity_registry.async_get("sensor.energy_production_tomorrow") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_energy_production_tomorrow" + assert state.state == "200" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Estimated Energy Production - Tomorrow" + ) + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.power_highest_peak_time_today") + entry = entity_registry.async_get("sensor.power_highest_peak_time_today") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_power_highest_peak_time_today" + assert state.state == "2021-06-27 13:00:00+00:00" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Highest Power Peak Time - Today" + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.power_highest_peak_time_tomorrow") + entry = entity_registry.async_get("sensor.power_highest_peak_time_tomorrow") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_power_highest_peak_time_tomorrow" + assert state.state == "2021-06-27 14:00:00+00:00" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "Highest Power Peak Time - Tomorrow" + ) + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.power_production_now") + entry = entity_registry.async_get("sensor.power_production_now") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_power_production_now" + assert state.state == "300" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Power Production - Now" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.energy_current_hour") + entry = entity_registry.async_get("sensor.energy_current_hour") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_energy_current_hour" + assert state.state == "800" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Estimated Energy Production - This Hour" + ) + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.energy_next_hour") + entry = entity_registry.async_get("sensor.energy_next_hour") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_energy_next_hour" + assert state.state == "900" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Estimated Energy Production - Next Hour" + ) + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, f"{entry_id}")} + assert device_entry.manufacturer == "Forecast.Solar" + assert device_entry.name == "Solar Production Forecast" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +@pytest.mark.parametrize( + "entity_id", + ( + "sensor.power_production_next_12hours", + "sensor.power_production_next_24hours", + "sensor.power_production_next_hour", + ), +) +async def test_disabled_by_default( + hass: HomeAssistant, init_integration: MockConfigEntry, entity_id: str +) -> None: + """Test the Forecast.Solar sensors that are disabled by default.""" + entity_registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state is None + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by == er.DISABLED_INTEGRATION + + +@pytest.mark.parametrize( + "key,name,value", + [ + ( + "power_production_next_12hours", + "Estimated Power Production - Next 12 Hours", + "600", + ), + ( + "power_production_next_24hours", + "Estimated Power Production - Next 24 Hours", + "700", + ), + ( + "power_production_next_hour", + "Estimated Power Production - Next Hour", + "400", + ), + ], +) +async def test_enabling_disable_by_default( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_forecast_solar: MagicMock, + key: str, + name: str, + value: str, +) -> None: + """Test the Forecast.Solar sensors that are disabled by default.""" + entry_id = mock_config_entry.entry_id + entity_id = f"{SENSOR_DOMAIN}.{key}" + entity_registry = er.async_get(hass) + + # Pre-create registry entry for disabled by default sensor + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"{entry_id}_{key}", + suggested_object_id=key, + disabled_by=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() + + state = hass.states.get(entity_id) + entry = entity_registry.async_get(entity_id) + assert entry + assert state + assert entry.unique_id == f"{entry_id}_{key}" + assert state.state == value + assert state.attributes.get(ATTR_FRIENDLY_NAME) == name + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes