Add Forecast Solar integration (#52158)
Co-authored-by: Franck Nijhof <git@frenck.dev>pull/52224/head
parent
473ab98a67
commit
6b08aebe5f
|
@ -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.*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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),
|
||||
}
|
||||
),
|
||||
)
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -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"
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -77,6 +77,7 @@ FLOWS = [
|
|||
"flo",
|
||||
"flume",
|
||||
"flunearyou",
|
||||
"forecast_solar",
|
||||
"forked_daapd",
|
||||
"foscam",
|
||||
"freebox",
|
||||
|
|
11
mypy.ini
11
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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the Forecast Solar integration."""
|
|
@ -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
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue