Add Forecast Solar integration (#52158)

Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Klaas Schoute 2021-06-27 14:05:04 +02:00 committed by GitHub
parent 473ab98a67
commit 6b08aebe5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 920 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -77,6 +77,7 @@ FLOWS = [
"flo",
"flume",
"flunearyou",
"forecast_solar",
"forked_daapd",
"foscam",
"freebox",

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Tests for the Forecast Solar integration."""

View File

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

View File

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

View File

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

View File

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