Files
core/homeassistant/components/solaredge/coordinator.py

488 lines
17 KiB
Python

"""Provides the data update coordinators for SolarEdge."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Iterable
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, Any
from aiosolaredge import SolarEdge
from solaredge_web import EnergyData, SolarEdgeWeb, TimeUnit
from stringcase import snakecase
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
statistics_during_period,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
from .const import (
CONF_SITE_ID,
DETAILS_UPDATE_DELAY,
DOMAIN,
ENERGY_DETAILS_DELAY,
INVENTORY_UPDATE_DELAY,
LOGGER,
MODULE_STATISTICS_UPDATE_DELAY,
OVERVIEW_UPDATE_DELAY,
POWER_FLOW_UPDATE_DELAY,
)
if TYPE_CHECKING:
from .types import SolarEdgeConfigEntry
class SolarEdgeDataService(ABC):
"""Get and update the latest data."""
coordinator: DataUpdateCoordinator[None]
def __init__(
self,
hass: HomeAssistant,
config_entry: SolarEdgeConfigEntry,
api: SolarEdge,
site_id: str,
) -> None:
"""Initialize the data object."""
self.api = api
self.site_id = site_id
self.data: dict[str, Any] = {}
self.attributes: dict[str, Any] = {}
self.hass = hass
self.config_entry = config_entry
@callback
def async_setup(self) -> None:
"""Coordinator creation."""
self.coordinator = DataUpdateCoordinator(
self.hass,
LOGGER,
config_entry=self.config_entry,
name=str(self),
update_method=self.async_update_data,
update_interval=self.update_interval,
)
@property
@abstractmethod
def update_interval(self) -> timedelta:
"""Update interval."""
@abstractmethod
async def async_update_data(self) -> None:
"""Update data."""
class SolarEdgeOverviewDataService(SolarEdgeDataService):
"""Get and update the latest overview data."""
@property
def update_interval(self) -> timedelta:
"""Update interval."""
return OVERVIEW_UPDATE_DELAY
async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
data = await self.api.get_overview(self.site_id)
overview = data["overview"]
except KeyError as ex:
raise UpdateFailed("Missing overview data, skipping update") from ex
self.data = {}
energy_keys = ["lifeTimeData", "lastYearData", "lastMonthData", "lastDayData"]
for key, value in overview.items():
if key in energy_keys:
data = value["energy"]
elif key in ["currentPower"]:
data = value["power"]
else:
data = value
self.data[key] = data
# Sanity check the energy values. SolarEdge API sometimes report "lifetimedata" of zero,
# while values for last Year, Month and Day energy are still OK.
# See https://github.com/home-assistant/core/issues/59285 .
if set(energy_keys).issubset(self.data.keys()):
for index, key in enumerate(energy_keys, start=1):
# All coming values in list should be larger than the current value.
if any(self.data[k] > self.data[key] for k in energy_keys[index:]):
LOGGER.warning(
"Ignoring invalid energy value %s for %s", self.data[key], key
)
self.data.pop(key)
LOGGER.debug("Updated SolarEdge overview: %s", self.data)
class SolarEdgeDetailsDataService(SolarEdgeDataService):
"""Get and update the latest details data."""
@property
def update_interval(self) -> timedelta:
"""Update interval."""
return DETAILS_UPDATE_DELAY
async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
data = await self.api.get_details(self.site_id)
details = data["details"]
except KeyError as ex:
raise UpdateFailed("Missing details data, skipping update") from ex
self.data = {}
self.attributes = {}
for key, value in details.items():
key = snakecase(key)
if key in ["primary_module"]:
for module_key, module_value in value.items():
self.attributes[snakecase(module_key)] = module_value
elif key in [
"peak_power",
"type",
"name",
"last_update_time",
"installation_date",
]:
self.attributes[key] = value
elif key == "status":
self.data["status"] = value
LOGGER.debug(
"Updated SolarEdge details: %s, %s",
self.data.get("status"),
self.attributes,
)
class SolarEdgeInventoryDataService(SolarEdgeDataService):
"""Get and update the latest inventory data."""
@property
def update_interval(self) -> timedelta:
"""Update interval."""
return INVENTORY_UPDATE_DELAY
async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
data = await self.api.get_inventory(self.site_id)
inventory = data["Inventory"]
except KeyError as ex:
raise UpdateFailed("Missing inventory data, skipping update") from ex
self.data = {}
self.attributes = {}
for key, value in inventory.items():
self.data[key] = len(value)
self.attributes[key] = {key: value}
LOGGER.debug("Updated SolarEdge inventory: %s, %s", self.data, self.attributes)
class SolarEdgeEnergyDetailsService(SolarEdgeDataService):
"""Get and update the latest power flow data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: SolarEdgeConfigEntry,
api: SolarEdge,
site_id: str,
) -> None:
"""Initialize the power flow data service."""
super().__init__(hass, config_entry, api, site_id)
self.unit = None
@property
def update_interval(self) -> timedelta:
"""Update interval."""
return ENERGY_DETAILS_DELAY
async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
now = datetime.now()
today = date.today()
midnight = datetime.combine(today, datetime.min.time())
data = await self.api.get_energy_details(
self.site_id,
midnight,
now,
time_unit="DAY",
)
energy_details = data["energyDetails"]
except KeyError as ex:
raise UpdateFailed("Missing power flow data, skipping update") from ex
if "meters" not in energy_details:
LOGGER.debug(
"Missing meters in energy details data. Assuming site does not have any"
)
return
self.data = {}
self.attributes = {}
self.unit = energy_details["unit"]
for meter in energy_details["meters"]:
if "type" not in meter or "values" not in meter:
continue
if meter["type"] not in [
"Production",
"SelfConsumption",
"FeedIn",
"Purchased",
"Consumption",
]:
continue
if len(meter["values"][0]) == 2:
self.data[meter["type"]] = meter["values"][0]["value"]
self.attributes[meter["type"]] = {"date": meter["values"][0]["date"]}
LOGGER.debug(
"Updated SolarEdge energy details: %s, %s", self.data, self.attributes
)
class SolarEdgePowerFlowDataService(SolarEdgeDataService):
"""Get and update the latest power flow data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: SolarEdgeConfigEntry,
api: SolarEdge,
site_id: str,
) -> None:
"""Initialize the power flow data service."""
super().__init__(hass, config_entry, api, site_id)
self.unit = None
@property
def update_interval(self) -> timedelta:
"""Update interval."""
return POWER_FLOW_UPDATE_DELAY
async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
data = await self.api.get_current_power_flow(self.site_id)
power_flow = data["siteCurrentPowerFlow"]
except KeyError as ex:
raise UpdateFailed("Missing power flow data, skipping update") from ex
power_from = []
power_to = []
if "connections" not in power_flow:
LOGGER.debug(
"Missing connections in power flow data. Assuming site does not"
" have any"
)
return
for connection in power_flow["connections"]:
power_from.append(connection["from"].lower())
power_to.append(connection["to"].lower())
self.data = {}
self.attributes = {}
self.unit = power_flow["unit"]
for key, value in power_flow.items():
if key in ["LOAD", "PV", "GRID", "STORAGE"]:
self.data[key] = value.get("currentPower")
self.attributes[key] = {"status": value["status"]}
if key in ["GRID"]:
export = key.lower() in power_to
if self.data[key]:
self.data[key] *= -1 if export else 1
self.attributes[key]["flow"] = "export" if export else "import"
if key in ["STORAGE"]:
charge = key.lower() in power_to
if self.data[key]:
self.data[key] *= -1 if charge else 1
self.attributes[key]["flow"] = "charge" if charge else "discharge"
self.attributes[key]["soc"] = value["chargeLevel"]
LOGGER.debug("Updated SolarEdge power flow: %s, %s", self.data, self.attributes)
class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]):
"""Handle fetching SolarEdge Modules data and inserting statistics."""
config_entry: SolarEdgeConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: SolarEdgeConfigEntry,
) -> None:
"""Initialize the data handler."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name="SolarEdge Modules",
# API refreshes every 15 minutes, but since we only have statistics
# and no sensors, refresh every 12h.
update_interval=MODULE_STATISTICS_UPDATE_DELAY,
)
self.api = SolarEdgeWeb(
username=config_entry.data[CONF_USERNAME],
password=config_entry.data[CONF_PASSWORD],
site_id=config_entry.data[CONF_SITE_ID],
session=aiohttp_client.async_get_clientsession(hass),
)
self.site_id = config_entry.data[CONF_SITE_ID]
self.title = config_entry.title
@callback
def _dummy_listener() -> None:
pass
# Force the coordinator to periodically update by registering a listener.
# Needed because there are no sensors added.
self.async_add_listener(_dummy_listener)
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint and update statistics."""
equipment: dict[int, dict[str, Any]] = await self.api.async_get_equipment()
# We fetch last week's data from the API and refresh every 12h so we overwrite recent
# statistics. This is intended to allow adding any corrected/updated data from the API.
energy_data_list: list[EnergyData] = await self.api.async_get_energy_data(
TimeUnit.WEEK
)
if not energy_data_list:
LOGGER.warning(
"No data received from SolarEdge API for site: %s", self.site_id
)
return
last_sums = await self._async_get_last_sums(
equipment.keys(),
energy_data_list[0].start_time.replace(
tzinfo=dt_util.get_default_time_zone()
),
)
for equipment_id, equipment_data in equipment.items():
display_name = equipment_data.get(
"displayName", f"Equipment {equipment_id}"
)
statistic_id = self.get_statistic_id(equipment_id)
statistic_metadata = StatisticMetaData(
mean_type=StatisticMeanType.ARITHMETIC,
has_sum=True,
name=f"{self.title} {display_name}",
source=DOMAIN,
statistic_id=statistic_id,
unit_class=EnergyConverter.UNIT_CLASS,
unit_of_measurement=UnitOfEnergy.WATT_HOUR,
)
statistic_sum = last_sums[statistic_id]
statistics = []
current_hour_sum = 0.0
current_hour_count = 0
for energy_data in energy_data_list:
start_time = energy_data.start_time.replace(
tzinfo=dt_util.get_default_time_zone()
)
value = energy_data.values.get(equipment_id, 0.0)
current_hour_sum += value
current_hour_count += 1
if start_time.minute != 45:
continue
# API returns data every 15 minutes; aggregate to 1-hour statistics
# when we reach the energy_data for the last 15 minutes of the hour.
current_avg = current_hour_sum / current_hour_count
statistic_sum += current_avg
statistics.append(
StatisticData(
start=start_time - timedelta(minutes=45),
state=current_avg,
sum=statistic_sum,
)
)
current_hour_sum = 0.0
current_hour_count = 0
LOGGER.debug(
"Adding %s statistics for %s %s",
len(statistics),
statistic_id,
display_name,
)
async_add_external_statistics(self.hass, statistic_metadata, statistics)
def get_statistic_id(self, equipment_id: int) -> str:
"""Return the statistic ID for this equipment_id."""
return f"{DOMAIN}:{self.site_id}_{equipment_id}"
async def _async_get_last_sums(
self, equipment_ids: Iterable[int], start_time: datetime
) -> dict[str, float]:
"""Get the last sum from the recorder before start_time for each statistic."""
start = start_time - timedelta(hours=1)
statistic_ids = {self.get_statistic_id(eq_id) for eq_id in equipment_ids}
LOGGER.debug(
"Getting sum for %s statistic IDs at: %s", len(statistic_ids), start
)
current_stats = await get_instance(self.hass).async_add_executor_job(
statistics_during_period,
self.hass,
start,
start + timedelta(seconds=1),
statistic_ids,
"hour",
None,
{"sum"},
)
result = {}
for statistic_id in statistic_ids:
if statistic_id in current_stats:
statistic_sum = current_stats[statistic_id][0]["sum"]
else:
# If no statistics found right before start_time, try to get the last statistic
# but use it only if it's before start_time.
# This is needed if the integration hasn't run successfully for at least a week.
last_stat = await get_instance(self.hass).async_add_executor_job(
get_last_statistics, self.hass, 1, statistic_id, True, {"sum"}
)
if (
last_stat
and last_stat[statistic_id][0]["start"] < start_time.timestamp()
):
statistic_sum = last_stat[statistic_id][0]["sum"]
else:
# Expected for new installations or if the statistics were cleared,
# e.g. from the developer tools
statistic_sum = 0.0
assert isinstance(statistic_sum, float)
result[statistic_id] = statistic_sum
return result