mirror of
https://github.com/home-assistant/core.git
synced 2025-11-09 10:59:40 +00:00
488 lines
17 KiB
Python
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
|