Add configurable optimizer area sensors
This commit is contained in:
@@ -64,6 +64,15 @@ class OptimizerSnapshot:
|
||||
"""Return readings indexed by optimizer serial number."""
|
||||
return {optimizer.serial: optimizer for optimizer in self.optimizers}
|
||||
|
||||
@property
|
||||
def optimizers_by_id(self) -> dict[str, OptimizerData]:
|
||||
"""Return readings indexed by logical optimizer ID."""
|
||||
return {
|
||||
optimizer.optimizer_id: optimizer
|
||||
for optimizer in self.optimizers
|
||||
if optimizer.optimizer_id is not None
|
||||
}
|
||||
|
||||
|
||||
class SolarEdgeOptimizerApiClient:
|
||||
"""Read the local SolarEdge Optimizer Data App API."""
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Optimizer area mapping helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection, Mapping
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import CONF_AREA_MAPPING, LOGGER
|
||||
|
||||
type AreaMapping = dict[str, tuple[str, ...]]
|
||||
|
||||
DEFAULT_AREA_MAPPINGS: dict[str, dict[str, list[str]]] = {
|
||||
"4886699": {
|
||||
"west1": [
|
||||
"1.1.1",
|
||||
"1.1.2",
|
||||
"1.1.4",
|
||||
"1.1.5",
|
||||
"1.1.6",
|
||||
"1.1.9",
|
||||
"1.1.10",
|
||||
"1.1.11",
|
||||
"1.1.12",
|
||||
],
|
||||
"west2": ["1.1.3", "1.1.13", "1.1.14"],
|
||||
"gaube": ["1.1.7", "1.1.8"],
|
||||
"süd": ["1.1.15", "1.1.16"],
|
||||
"ost1": [
|
||||
"1.2.1",
|
||||
"1.2.2",
|
||||
"1.2.3",
|
||||
"1.2.4",
|
||||
"1.2.7",
|
||||
"1.2.8",
|
||||
"1.2.9",
|
||||
"1.2.10",
|
||||
"1.2.11",
|
||||
"1.2.12",
|
||||
"1.2.13",
|
||||
"1.2.14",
|
||||
"1.2.17",
|
||||
"1.2.18",
|
||||
],
|
||||
"ost2": ["1.2.5", "1.2.6", "1.2.15", "1.2.16"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AreaMappingError(ValueError):
|
||||
"""An area mapping could not be validated."""
|
||||
|
||||
def __init__(self, translation_key: str) -> None:
|
||||
"""Initialize the mapping error."""
|
||||
super().__init__(translation_key)
|
||||
self.translation_key = translation_key
|
||||
|
||||
|
||||
def default_area_mapping(site_id: str) -> dict[str, list[str]]:
|
||||
"""Return a mutable copy of the site's default area mapping."""
|
||||
return {
|
||||
area: list(optimizer_ids)
|
||||
for area, optimizer_ids in DEFAULT_AREA_MAPPINGS.get(site_id, {}).items()
|
||||
}
|
||||
|
||||
|
||||
def configured_area_mapping(site_id: str, options: Mapping[str, Any]) -> AreaMapping:
|
||||
"""Return the configured mapping or the site-specific default."""
|
||||
value = options.get(CONF_AREA_MAPPING, default_area_mapping(site_id))
|
||||
try:
|
||||
return parse_area_mapping(value)
|
||||
except AreaMappingError:
|
||||
LOGGER.exception("Stored optimizer area mapping is invalid")
|
||||
return {}
|
||||
|
||||
|
||||
def parse_area_mapping(
|
||||
value: Any,
|
||||
available_optimizer_ids: Collection[str] | None = None,
|
||||
) -> AreaMapping:
|
||||
"""Validate and normalize an optimizer area mapping."""
|
||||
if not isinstance(value, dict):
|
||||
raise AreaMappingError("invalid_area_mapping")
|
||||
|
||||
available = (
|
||||
set(available_optimizer_ids) if available_optimizer_ids is not None else None
|
||||
)
|
||||
mapping: AreaMapping = {}
|
||||
assigned_optimizer_ids: set[str] = set()
|
||||
area_slugs: set[str] = set()
|
||||
|
||||
for raw_area, raw_optimizer_ids in value.items():
|
||||
if not isinstance(raw_area, str) or not (area := raw_area.strip()):
|
||||
raise AreaMappingError("invalid_area_mapping")
|
||||
area_slug = slugify(area)
|
||||
if not area_slug or area_slug in area_slugs:
|
||||
raise AreaMappingError("duplicate_area")
|
||||
area_slugs.add(area_slug)
|
||||
|
||||
if not isinstance(raw_optimizer_ids, list) or not raw_optimizer_ids:
|
||||
raise AreaMappingError("empty_area")
|
||||
|
||||
optimizer_ids: list[str] = []
|
||||
for raw_optimizer_id in raw_optimizer_ids:
|
||||
if not isinstance(raw_optimizer_id, str) or not (
|
||||
optimizer_id := raw_optimizer_id.strip()
|
||||
):
|
||||
raise AreaMappingError("invalid_area_mapping")
|
||||
if available is not None and optimizer_id not in available:
|
||||
raise AreaMappingError("unknown_optimizer")
|
||||
if optimizer_id in assigned_optimizer_ids:
|
||||
raise AreaMappingError("duplicate_optimizer")
|
||||
assigned_optimizer_ids.add(optimizer_id)
|
||||
optimizer_ids.append(optimizer_id)
|
||||
|
||||
mapping[area] = tuple(optimizer_ids)
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
def serialize_area_mapping(mapping: AreaMapping) -> dict[str, list[str]]:
|
||||
"""Convert the normalized mapping into config-entry-safe data."""
|
||||
return {area: list(optimizer_ids) for area, optimizer_ids in mapping.items()}
|
||||
@@ -5,10 +5,16 @@ from __future__ import annotations
|
||||
from typing import Any, override
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import ObjectSelector
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
|
||||
from .api import (
|
||||
@@ -18,7 +24,14 @@ from .api import (
|
||||
SolarEdgeOptimizerInvalidResponseError,
|
||||
normalize_base_url,
|
||||
)
|
||||
from .areas import (
|
||||
AreaMappingError,
|
||||
default_area_mapping,
|
||||
parse_area_mapping,
|
||||
serialize_area_mapping,
|
||||
)
|
||||
from .const import (
|
||||
CONF_AREA_MAPPING,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONFIG_ENTRY_VERSION,
|
||||
DEFAULT_PORT,
|
||||
@@ -64,6 +77,13 @@ class SolarEdgeOptimizersConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = CONFIG_ENTRY_VERSION
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@override
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Return the options flow."""
|
||||
return SolarEdgeOptimizerOptionsFlow()
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the flow."""
|
||||
self._discovered_url: str | None = None
|
||||
@@ -193,3 +213,59 @@ class SolarEdgeOptimizersConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception while validating configuration")
|
||||
return {"base": "unknown"}
|
||||
|
||||
|
||||
class SolarEdgeOptimizerOptionsFlow(OptionsFlow):
|
||||
"""Configure optimizer area mappings."""
|
||||
|
||||
@override
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage area mapping options."""
|
||||
coordinator = self.config_entry.runtime_data
|
||||
available_optimizer_ids = {
|
||||
optimizer.optimizer_id
|
||||
for optimizer in coordinator.data.optimizers
|
||||
if optimizer.optimizer_id is not None
|
||||
}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
mapping = parse_area_mapping(
|
||||
user_input.get(CONF_AREA_MAPPING, {}),
|
||||
available_optimizer_ids,
|
||||
)
|
||||
except AreaMappingError as err:
|
||||
errors["base"] = err.translation_key
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
data={
|
||||
**self.config_entry.options,
|
||||
CONF_AREA_MAPPING: serialize_area_mapping(mapping),
|
||||
}
|
||||
)
|
||||
|
||||
site_id = coordinator.data.site_id
|
||||
current_mapping = self.config_entry.options.get(
|
||||
CONF_AREA_MAPPING, default_area_mapping(site_id)
|
||||
)
|
||||
if user_input is not None:
|
||||
current_mapping = user_input.get(CONF_AREA_MAPPING, {})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_AREA_MAPPING,
|
||||
default=current_mapping,
|
||||
): ObjectSelector()
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"optimizer_ids": ", ".join(sorted(available_optimizer_ids))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ LOGGER = logging.getLogger(__package__)
|
||||
CONFIG_ENTRY_VERSION = 2
|
||||
|
||||
CONF_SCAN_INTERVAL = "scan_interval"
|
||||
CONF_AREA_MAPPING = "area_mapping"
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
|
||||
MIN_SCAN_INTERVAL_SECONDS = 60
|
||||
DEFAULT_PORT = 8099
|
||||
|
||||
@@ -19,6 +19,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"title": entry.title,
|
||||
"unique_id": entry.unique_id,
|
||||
"data": dict(entry.data),
|
||||
"options": dict(entry.options),
|
||||
},
|
||||
"coordinator": {
|
||||
"last_update_success": entry.runtime_data.last_update_success,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://git.jensneuber.de/jens/ha-solaredge-optimizers/issues",
|
||||
"requirements": [],
|
||||
"version": "0.2.0"
|
||||
"version": "0.3.0"
|
||||
}
|
||||
|
||||
@@ -13,13 +13,16 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfEnergy, UnitOfPower
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import SolarEdgeOptimizerConfigEntry
|
||||
from .api import OptimizerData
|
||||
from .areas import configured_area_mapping
|
||||
from .const import DOMAIN
|
||||
from .coordinator import SolarEdgeOptimizerCoordinator
|
||||
|
||||
@@ -54,14 +57,44 @@ SENSOR_DESCRIPTIONS = (
|
||||
),
|
||||
)
|
||||
|
||||
AREA_SENSOR_DESCRIPTIONS = (
|
||||
SolarEdgeOptimizerSensorDescription(
|
||||
key="daily_energy",
|
||||
translation_key="area_daily_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda optimizer: optimizer.daily_energy_wh,
|
||||
),
|
||||
SolarEdgeOptimizerSensorDescription(
|
||||
key="current_power",
|
||||
translation_key="area_current_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda optimizer: optimizer.current_power_w,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: Any,
|
||||
hass: HomeAssistant,
|
||||
entry: SolarEdgeOptimizerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up optimizer sensors from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
area_mapping = configured_area_mapping(coordinator.data.site_id, entry.options)
|
||||
area_entities = [
|
||||
SolarEdgeAreaSensor(coordinator, area, optimizer_ids, description)
|
||||
for area, optimizer_ids in area_mapping.items()
|
||||
for description in AREA_SENSOR_DESCRIPTIONS
|
||||
]
|
||||
_remove_stale_area_entities(hass, entry, area_entities)
|
||||
async_add_entities(area_entities)
|
||||
|
||||
known_serials: set[str] = set()
|
||||
|
||||
@callback
|
||||
@@ -84,6 +117,34 @@ async def async_setup_entry(
|
||||
entry.async_on_unload(coordinator.async_add_listener(async_add_new_optimizers))
|
||||
|
||||
|
||||
def _remove_stale_area_entities(
|
||||
hass: HomeAssistant,
|
||||
entry: SolarEdgeOptimizerConfigEntry,
|
||||
area_entities: list[SolarEdgeAreaSensor],
|
||||
) -> None:
|
||||
"""Remove registry entries for areas no longer configured."""
|
||||
entity_registry = er.async_get(hass)
|
||||
desired_unique_ids = {entity.unique_id for entity in area_entities}
|
||||
area_prefix = f"{entry.runtime_data.data.site_id}_area_"
|
||||
for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id):
|
||||
if (
|
||||
entity.unique_id.startswith(area_prefix)
|
||||
and entity.unique_id not in desired_unique_ids
|
||||
):
|
||||
entity_registry.async_remove(entity.entity_id)
|
||||
|
||||
|
||||
def _site_device_info(coordinator: SolarEdgeOptimizerCoordinator) -> DeviceInfo:
|
||||
"""Return the shared SolarEdge site device information."""
|
||||
site_id = coordinator.data.site_id
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, site_id)},
|
||||
manufacturer="SolarEdge",
|
||||
model="Optimizer Monitoring Site",
|
||||
name=f"SolarEdge Site {site_id}",
|
||||
)
|
||||
|
||||
|
||||
class SolarEdgeOptimizerSensor(
|
||||
CoordinatorEntity[SolarEdgeOptimizerCoordinator], SensorEntity
|
||||
):
|
||||
@@ -126,13 +187,7 @@ class SolarEdgeOptimizerSensor(
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the shared SolarEdge site device information."""
|
||||
site_id = self.coordinator.data.site_id
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, site_id)},
|
||||
manufacturer="SolarEdge",
|
||||
model="Optimizer Monitoring Site",
|
||||
name=f"SolarEdge Site {site_id}",
|
||||
)
|
||||
return _site_device_info(self.coordinator)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
@@ -145,3 +200,67 @@ class SolarEdgeOptimizerSensor(
|
||||
"inverter_id": optimizer.inverter_id,
|
||||
"last_measurement": optimizer.last_measurement,
|
||||
}
|
||||
|
||||
|
||||
class SolarEdgeAreaSensor(
|
||||
CoordinatorEntity[SolarEdgeOptimizerCoordinator], SensorEntity
|
||||
):
|
||||
"""Represent the sum of one optimizer area."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: SolarEdgeOptimizerCoordinator,
|
||||
area: str,
|
||||
optimizer_ids: tuple[str, ...],
|
||||
description: SolarEdgeOptimizerSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize an area sum sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._area = area
|
||||
self._optimizer_ids = optimizer_ids
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.data.site_id}_area_{slugify(area)}_{description.key}"
|
||||
)
|
||||
self._attr_translation_placeholders = {"area": area}
|
||||
|
||||
@property
|
||||
def _values(self) -> tuple[OptimizerValue, ...]:
|
||||
optimizers_by_id = self.coordinator.data.optimizers_by_id
|
||||
return tuple(
|
||||
self.entity_description.value_fn(optimizer)
|
||||
if (optimizer := optimizers_by_id.get(optimizer_id)) is not None
|
||||
else None
|
||||
for optimizer_id in self._optimizer_ids
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> OptimizerValue:
|
||||
"""Return the sum when every configured optimizer value is available."""
|
||||
values = self._values
|
||||
if not values or any(value is None for value in values):
|
||||
return None
|
||||
return sum(value for value in values if value is not None)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether every optimizer in the area is available."""
|
||||
return super().available and self.native_value is not None
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the shared SolarEdge site device information."""
|
||||
return _site_device_info(self.coordinator)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return area membership and completeness information."""
|
||||
values = self._values
|
||||
return {
|
||||
"area": self._area,
|
||||
"optimizer_ids": list(self._optimizer_ids),
|
||||
"optimizer_count": len(self._optimizer_ids),
|
||||
"available_optimizer_count": sum(value is not None for value in values),
|
||||
}
|
||||
|
||||
@@ -43,6 +43,27 @@
|
||||
"unknown": "Unexpected error"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Configure optimizer areas",
|
||||
"description": "Map area names to optimizer IDs as a JSON object. Available IDs: {optimizer_ids}",
|
||||
"data": {
|
||||
"area_mapping": "Area mapping"
|
||||
},
|
||||
"data_description": {
|
||||
"area_mapping": "Example: assign IDs 1.1.1 and 1.1.2 to an area named west. Use an empty object to disable area sensors."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"duplicate_area": "Area names must be unique",
|
||||
"duplicate_optimizer": "An optimizer can only belong to one area",
|
||||
"empty_area": "Every area must contain at least one optimizer",
|
||||
"invalid_area_mapping": "Enter a JSON object containing area names and optimizer ID lists",
|
||||
"unknown_optimizer": "The mapping contains an unknown optimizer ID"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"daily_energy": {
|
||||
@@ -50,6 +71,12 @@
|
||||
},
|
||||
"current_power": {
|
||||
"name": "{optimizer_id} current power"
|
||||
},
|
||||
"area_daily_energy": {
|
||||
"name": "{area} daily energy"
|
||||
},
|
||||
"area_current_power": {
|
||||
"name": "{area} current power"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,27 @@
|
||||
"unknown": "Unerwarteter Fehler"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Optimizer-Flächen konfigurieren",
|
||||
"description": "Ordne Flächennamen als JSON-Objekt den Optimizer-IDs zu. Verfügbare IDs: {optimizer_ids}",
|
||||
"data": {
|
||||
"area_mapping": "Flächen-Zuordnung"
|
||||
},
|
||||
"data_description": {
|
||||
"area_mapping": "Beispiel: Ordne einer Fläche west die IDs 1.1.1 und 1.1.2 zu. Ein leeres Objekt deaktiviert die Flächensensoren."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"duplicate_area": "Flächennamen müssen eindeutig sein",
|
||||
"duplicate_optimizer": "Ein Optimierer darf nur einer Fläche zugeordnet sein",
|
||||
"empty_area": "Jede Fläche muss mindestens einen Optimierer enthalten",
|
||||
"invalid_area_mapping": "Gib ein JSON-Objekt mit Flächennamen und Listen von Optimizer-IDs ein",
|
||||
"unknown_optimizer": "Die Zuordnung enthält eine unbekannte Optimizer-ID"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"daily_energy": {
|
||||
@@ -50,6 +71,12 @@
|
||||
},
|
||||
"current_power": {
|
||||
"name": "{optimizer_id} aktuelle Leistung"
|
||||
},
|
||||
"area_daily_energy": {
|
||||
"name": "{area} Tagesenergie"
|
||||
},
|
||||
"area_current_power": {
|
||||
"name": "{area} aktuelle Leistung"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,27 @@
|
||||
"unknown": "Unexpected error"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Configure optimizer areas",
|
||||
"description": "Map area names to optimizer IDs as a JSON object. Available IDs: {optimizer_ids}",
|
||||
"data": {
|
||||
"area_mapping": "Area mapping"
|
||||
},
|
||||
"data_description": {
|
||||
"area_mapping": "Example: assign IDs 1.1.1 and 1.1.2 to an area named west. Use an empty object to disable area sensors."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"duplicate_area": "Area names must be unique",
|
||||
"duplicate_optimizer": "An optimizer can only belong to one area",
|
||||
"empty_area": "Every area must contain at least one optimizer",
|
||||
"invalid_area_mapping": "Enter a JSON object containing area names and optimizer ID lists",
|
||||
"unknown_optimizer": "The mapping contains an unknown optimizer ID"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"daily_energy": {
|
||||
@@ -50,6 +71,12 @@
|
||||
},
|
||||
"current_power": {
|
||||
"name": "{optimizer_id} current power"
|
||||
},
|
||||
"area_daily_energy": {
|
||||
"name": "{area} daily energy"
|
||||
},
|
||||
"area_current_power": {
|
||||
"name": "{area} current power"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user