Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec6ec30dff | ||
|
|
02bb4a831e |
@@ -12,6 +12,18 @@ Pro Optimierer werden zwei Sensoren angelegt:
|
|||||||
- **Tagesenergie** in Wh (`total_increasing`)
|
- **Tagesenergie** in Wh (`total_increasing`)
|
||||||
- **Aktuelle Leistung** in W (`measurement`)
|
- **Aktuelle Leistung** in W (`measurement`)
|
||||||
|
|
||||||
|
Zusätzlich können Optimierer zu Dachflächen zusammengefasst werden. Pro Fläche
|
||||||
|
entstehen zwei weitere Sensoren mit der Summe aller vollständig verfügbaren
|
||||||
|
Optimiererwerte:
|
||||||
|
|
||||||
|
- **Fläche Tagesenergie** in Wh
|
||||||
|
- **Fläche aktuelle Leistung** in W
|
||||||
|
|
||||||
|
Die von SolarEdge gelieferten Strings wie `1.1` und `1.2` werden automatisch
|
||||||
|
erkannt. Auch für jeden String entstehen Sensoren für die summierte
|
||||||
|
**Tagesenergie** und **aktuelle Leistung**. Eine manuelle Zuordnung ist dafür
|
||||||
|
nicht notwendig.
|
||||||
|
|
||||||
Die Seriennummer bleibt Bestandteil der stabilen Entity-ID. Optimierer-ID,
|
Die Seriennummer bleibt Bestandteil der stabilen Entity-ID. Optimierer-ID,
|
||||||
String-ID, Wechselrichter-ID und Zeitpunkt der letzten Messung stehen als
|
String-ID, Wechselrichter-ID und Zeitpunkt der letzten Messung stehen als
|
||||||
Attribute zur Verfügung.
|
Attribute zur Verfügung.
|
||||||
@@ -31,7 +43,7 @@ HACS unterstützt keine Gitea-Repositories. Lade deshalb das Archiv der
|
|||||||
gewünschten Version herunter und entpacke es:
|
gewünschten Version herunter und entpacke es:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://git.jensneuber.de/jens/ha-solaredge-optimizers/archive/v0.2.0.zip
|
https://git.jensneuber.de/jens/ha-solaredge-optimizers/archive/v0.4.0.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
Kopiere anschließend den enthaltenen Ordner
|
Kopiere anschließend den enthaltenen Ordner
|
||||||
@@ -57,6 +69,24 @@ Die Integration liest den Cache standardmäßig alle 600 Sekunden. Dieser Wert
|
|||||||
kann über **Neu konfigurieren** geändert werden. Dadurch entstehen keine
|
kann über **Neu konfigurieren** geändert werden. Dadurch entstehen keine
|
||||||
zusätzlichen SolarEdge-Portalabrufe; diese steuert die App selbst.
|
zusätzlichen SolarEdge-Portalabrufe; diese steuert die App selbst.
|
||||||
|
|
||||||
|
## Flächen konfigurieren
|
||||||
|
|
||||||
|
Öffne die Integration unter **Einstellungen → Geräte & Dienste** und wähle
|
||||||
|
**Konfigurieren**. Die Flächen-Zuordnung ist ein JSON-Objekt, dessen Schlüssel
|
||||||
|
die Flächennamen und dessen Werte Listen von Optimizer-IDs sind:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"west1": ["1.1.1", "1.1.2"],
|
||||||
|
"süd": ["1.1.15", "1.1.16"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Optimierer darf nur einer Fläche zugeordnet sein. Unbekannte IDs und leere
|
||||||
|
Flächen werden beim Speichern abgewiesen. Für Site `4886699` sind `west1`,
|
||||||
|
`west2`, `gaube`, `süd`, `ost1` und `ost2` bereits als Startwert hinterlegt.
|
||||||
|
Mit `{}` können alle Flächensensoren deaktiviert werden.
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ class OptimizerSnapshot:
|
|||||||
"""Return readings indexed by optimizer serial number."""
|
"""Return readings indexed by optimizer serial number."""
|
||||||
return {optimizer.serial: optimizer for optimizer in self.optimizers}
|
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:
|
class SolarEdgeOptimizerApiClient:
|
||||||
"""Read the local SolarEdge Optimizer Data App API."""
|
"""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
|
from typing import Any, override
|
||||||
|
|
||||||
import voluptuous as vol
|
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.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.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.selector import ObjectSelector
|
||||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||||
|
|
||||||
from .api import (
|
from .api import (
|
||||||
@@ -18,7 +24,14 @@ from .api import (
|
|||||||
SolarEdgeOptimizerInvalidResponseError,
|
SolarEdgeOptimizerInvalidResponseError,
|
||||||
normalize_base_url,
|
normalize_base_url,
|
||||||
)
|
)
|
||||||
|
from .areas import (
|
||||||
|
AreaMappingError,
|
||||||
|
default_area_mapping,
|
||||||
|
parse_area_mapping,
|
||||||
|
serialize_area_mapping,
|
||||||
|
)
|
||||||
from .const import (
|
from .const import (
|
||||||
|
CONF_AREA_MAPPING,
|
||||||
CONF_SCAN_INTERVAL,
|
CONF_SCAN_INTERVAL,
|
||||||
CONFIG_ENTRY_VERSION,
|
CONFIG_ENTRY_VERSION,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
@@ -64,6 +77,13 @@ class SolarEdgeOptimizersConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
VERSION = CONFIG_ENTRY_VERSION
|
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:
|
def __init__(self) -> None:
|
||||||
"""Initialize the flow."""
|
"""Initialize the flow."""
|
||||||
self._discovered_url: str | None = None
|
self._discovered_url: str | None = None
|
||||||
@@ -193,3 +213,59 @@ class SolarEdgeOptimizersConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
LOGGER.exception("Unexpected exception while validating configuration")
|
LOGGER.exception("Unexpected exception while validating configuration")
|
||||||
return {"base": "unknown"}
|
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
|
CONFIG_ENTRY_VERSION = 2
|
||||||
|
|
||||||
CONF_SCAN_INTERVAL = "scan_interval"
|
CONF_SCAN_INTERVAL = "scan_interval"
|
||||||
|
CONF_AREA_MAPPING = "area_mapping"
|
||||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
|
DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
|
||||||
MIN_SCAN_INTERVAL_SECONDS = 60
|
MIN_SCAN_INTERVAL_SECONDS = 60
|
||||||
DEFAULT_PORT = 8099
|
DEFAULT_PORT = 8099
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ async def async_get_config_entry_diagnostics(
|
|||||||
"title": entry.title,
|
"title": entry.title,
|
||||||
"unique_id": entry.unique_id,
|
"unique_id": entry.unique_id,
|
||||||
"data": dict(entry.data),
|
"data": dict(entry.data),
|
||||||
|
"options": dict(entry.options),
|
||||||
},
|
},
|
||||||
"coordinator": {
|
"coordinator": {
|
||||||
"last_update_success": entry.runtime_data.last_update_success,
|
"last_update_success": entry.runtime_data.last_update_success,
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"issue_tracker": "https://git.jensneuber.de/jens/ha-solaredge-optimizers/issues",
|
"issue_tracker": "https://git.jensneuber.de/jens/ha-solaredge-optimizers/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "0.2.0"
|
"version": "0.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ from homeassistant.components.sensor import (
|
|||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.const import UnitOfEnergy, UnitOfPower
|
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.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
from . import SolarEdgeOptimizerConfigEntry
|
from . import SolarEdgeOptimizerConfigEntry
|
||||||
from .api import OptimizerData
|
from .api import OptimizerData
|
||||||
|
from .areas import configured_area_mapping
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import SolarEdgeOptimizerCoordinator
|
from .coordinator import SolarEdgeOptimizerCoordinator
|
||||||
|
|
||||||
@@ -54,34 +57,157 @@ 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
STRING_SENSOR_DESCRIPTIONS = (
|
||||||
|
SolarEdgeOptimizerSensorDescription(
|
||||||
|
key="daily_energy",
|
||||||
|
translation_key="string_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="string_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(
|
async def async_setup_entry(
|
||||||
hass: Any,
|
hass: HomeAssistant,
|
||||||
entry: SolarEdgeOptimizerConfigEntry,
|
entry: SolarEdgeOptimizerConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up optimizer sensors from a config entry."""
|
"""Set up optimizer sensors from a config entry."""
|
||||||
coordinator = entry.runtime_data
|
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()
|
known_serials: set[str] = set()
|
||||||
|
known_string_ids: set[str] = set()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_add_new_optimizers() -> None:
|
def async_add_new_entities() -> None:
|
||||||
|
entities: list[SensorEntity] = []
|
||||||
new_optimizers = [
|
new_optimizers = [
|
||||||
optimizer
|
optimizer
|
||||||
for optimizer in coordinator.data.optimizers
|
for optimizer in coordinator.data.optimizers
|
||||||
if optimizer.serial not in known_serials
|
if optimizer.serial not in known_serials
|
||||||
]
|
]
|
||||||
if not new_optimizers:
|
|
||||||
return
|
|
||||||
known_serials.update(optimizer.serial for optimizer in new_optimizers)
|
known_serials.update(optimizer.serial for optimizer in new_optimizers)
|
||||||
async_add_entities(
|
entities.extend(
|
||||||
SolarEdgeOptimizerSensor(coordinator, optimizer.serial, description)
|
SolarEdgeOptimizerSensor(coordinator, optimizer.serial, description)
|
||||||
for optimizer in new_optimizers
|
for optimizer in new_optimizers
|
||||||
for description in SENSOR_DESCRIPTIONS
|
for description in SENSOR_DESCRIPTIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
async_add_new_optimizers()
|
new_string_ids = sorted(
|
||||||
entry.async_on_unload(coordinator.async_add_listener(async_add_new_optimizers))
|
{
|
||||||
|
optimizer.string_id
|
||||||
|
for optimizer in coordinator.data.optimizers
|
||||||
|
if optimizer.string_id is not None
|
||||||
|
}
|
||||||
|
- known_string_ids
|
||||||
|
)
|
||||||
|
known_string_ids.update(new_string_ids)
|
||||||
|
entities.extend(
|
||||||
|
SolarEdgeStringSensor(coordinator, string_id, description)
|
||||||
|
for string_id in new_string_ids
|
||||||
|
for description in STRING_SENSOR_DESCRIPTIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
if entities:
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
_remove_stale_string_entities(hass, entry)
|
||||||
|
async_add_new_entities()
|
||||||
|
entry.async_on_unload(coordinator.async_add_listener(async_add_new_entities))
|
||||||
|
|
||||||
|
|
||||||
|
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 _remove_stale_string_entities(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: SolarEdgeOptimizerConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Remove registry entries for strings no longer returned by the App."""
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
site_id = entry.runtime_data.data.site_id
|
||||||
|
desired_unique_ids = {
|
||||||
|
f"{site_id}_string_{slugify(string_id)}_{description.key}"
|
||||||
|
for string_id in {
|
||||||
|
optimizer.string_id
|
||||||
|
for optimizer in entry.runtime_data.data.optimizers
|
||||||
|
if optimizer.string_id is not None
|
||||||
|
}
|
||||||
|
for description in STRING_SENSOR_DESCRIPTIONS
|
||||||
|
}
|
||||||
|
string_prefix = f"{site_id}_string_"
|
||||||
|
for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id):
|
||||||
|
if (
|
||||||
|
entity.unique_id.startswith(string_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(
|
class SolarEdgeOptimizerSensor(
|
||||||
@@ -126,13 +252,7 @@ class SolarEdgeOptimizerSensor(
|
|||||||
@property
|
@property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
"""Return the shared SolarEdge site device information."""
|
"""Return the shared SolarEdge site device information."""
|
||||||
site_id = self.coordinator.data.site_id
|
return _site_device_info(self.coordinator)
|
||||||
return DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, site_id)},
|
|
||||||
manufacturer="SolarEdge",
|
|
||||||
model="Optimizer Monitoring Site",
|
|
||||||
name=f"SolarEdge Site {site_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, Any]:
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
@@ -145,3 +265,139 @@ class SolarEdgeOptimizerSensor(
|
|||||||
"inverter_id": optimizer.inverter_id,
|
"inverter_id": optimizer.inverter_id,
|
||||||
"last_measurement": optimizer.last_measurement,
|
"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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SolarEdgeStringSensor(
|
||||||
|
CoordinatorEntity[SolarEdgeOptimizerCoordinator], SensorEntity
|
||||||
|
):
|
||||||
|
"""Represent the sum of one optimizer string."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: SolarEdgeOptimizerCoordinator,
|
||||||
|
string_id: str,
|
||||||
|
description: SolarEdgeOptimizerSensorDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize a string sum sensor."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.entity_description = description
|
||||||
|
self._string_id = string_id
|
||||||
|
self._attr_unique_id = (
|
||||||
|
f"{coordinator.data.site_id}_string_{slugify(string_id)}_{description.key}"
|
||||||
|
)
|
||||||
|
self._attr_translation_placeholders = {"string_id": string_id}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _optimizers(self) -> tuple[OptimizerData, ...]:
|
||||||
|
return tuple(
|
||||||
|
optimizer
|
||||||
|
for optimizer in self.coordinator.data.optimizers
|
||||||
|
if optimizer.string_id == self._string_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _values(self) -> tuple[OptimizerValue, ...]:
|
||||||
|
return tuple(
|
||||||
|
self.entity_description.value_fn(optimizer)
|
||||||
|
for optimizer in self._optimizers
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> OptimizerValue:
|
||||||
|
"""Return the sum when every 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 string 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 string membership and completeness information."""
|
||||||
|
optimizers = self._optimizers
|
||||||
|
values = self._values
|
||||||
|
return {
|
||||||
|
"string_id": self._string_id,
|
||||||
|
"optimizer_ids": [
|
||||||
|
optimizer.optimizer_id
|
||||||
|
for optimizer in optimizers
|
||||||
|
if optimizer.optimizer_id is not None
|
||||||
|
],
|
||||||
|
"optimizer_count": len(optimizers),
|
||||||
|
"available_optimizer_count": sum(value is not None for value in values),
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,27 @@
|
|||||||
"unknown": "Unexpected error"
|
"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": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"daily_energy": {
|
"daily_energy": {
|
||||||
@@ -50,6 +71,18 @@
|
|||||||
},
|
},
|
||||||
"current_power": {
|
"current_power": {
|
||||||
"name": "{optimizer_id} current power"
|
"name": "{optimizer_id} current power"
|
||||||
|
},
|
||||||
|
"area_daily_energy": {
|
||||||
|
"name": "{area} daily energy"
|
||||||
|
},
|
||||||
|
"area_current_power": {
|
||||||
|
"name": "{area} current power"
|
||||||
|
},
|
||||||
|
"string_daily_energy": {
|
||||||
|
"name": "String {string_id} daily energy"
|
||||||
|
},
|
||||||
|
"string_current_power": {
|
||||||
|
"name": "String {string_id} current power"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,27 @@
|
|||||||
"unknown": "Unerwarteter Fehler"
|
"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": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"daily_energy": {
|
"daily_energy": {
|
||||||
@@ -50,6 +71,18 @@
|
|||||||
},
|
},
|
||||||
"current_power": {
|
"current_power": {
|
||||||
"name": "{optimizer_id} aktuelle Leistung"
|
"name": "{optimizer_id} aktuelle Leistung"
|
||||||
|
},
|
||||||
|
"area_daily_energy": {
|
||||||
|
"name": "{area} Tagesenergie"
|
||||||
|
},
|
||||||
|
"area_current_power": {
|
||||||
|
"name": "{area} aktuelle Leistung"
|
||||||
|
},
|
||||||
|
"string_daily_energy": {
|
||||||
|
"name": "String {string_id} Tagesenergie"
|
||||||
|
},
|
||||||
|
"string_current_power": {
|
||||||
|
"name": "String {string_id} aktuelle Leistung"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,27 @@
|
|||||||
"unknown": "Unexpected error"
|
"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": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"daily_energy": {
|
"daily_energy": {
|
||||||
@@ -50,6 +71,18 @@
|
|||||||
},
|
},
|
||||||
"current_power": {
|
"current_power": {
|
||||||
"name": "{optimizer_id} current power"
|
"name": "{optimizer_id} current power"
|
||||||
|
},
|
||||||
|
"area_daily_energy": {
|
||||||
|
"name": "{area} daily energy"
|
||||||
|
},
|
||||||
|
"area_current_power": {
|
||||||
|
"name": "{area} current power"
|
||||||
|
},
|
||||||
|
"string_daily_energy": {
|
||||||
|
"name": "String {string_id} daily energy"
|
||||||
|
},
|
||||||
|
"string_current_power": {
|
||||||
|
"name": "String {string_id} current power"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ha-solaredge-optimizers"
|
name = "ha-solaredge-optimizers"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
description = "Home Assistant custom integration for SolarEdge optimizer data"
|
description = "Home Assistant custom integration for SolarEdge optimizer data"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.14.2"
|
requires-python = ">=3.14.2"
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests for optimizer area mappings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.solaredge_optimizers.areas import (
|
||||||
|
AreaMappingError,
|
||||||
|
default_area_mapping,
|
||||||
|
parse_area_mapping,
|
||||||
|
serialize_area_mapping,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_site_default_mapping() -> None:
|
||||||
|
"""Load the six known areas for site 4886699."""
|
||||||
|
mapping = default_area_mapping("4886699")
|
||||||
|
|
||||||
|
assert set(mapping) == {"west1", "west2", "gaube", "süd", "ost1", "ost2"}
|
||||||
|
assert sum(len(optimizer_ids) for optimizer_ids in mapping.values()) == 34
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_and_serialize_mapping() -> None:
|
||||||
|
"""Normalize and serialize a valid mapping."""
|
||||||
|
mapping = parse_area_mapping(
|
||||||
|
{"west": ["1.1.1", "1.1.2"]},
|
||||||
|
{"1.1.1", "1.1.2"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mapping == {"west": ("1.1.1", "1.1.2")}
|
||||||
|
assert serialize_area_mapping(mapping) == {"west": ["1.1.1", "1.1.2"]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mapping", "error"),
|
||||||
|
[
|
||||||
|
({"west": []}, "empty_area"),
|
||||||
|
({"west": ["1.1.99"]}, "unknown_optimizer"),
|
||||||
|
(
|
||||||
|
{"west": ["1.1.1"], "roof": ["1.1.1"]},
|
||||||
|
"duplicate_optimizer",
|
||||||
|
),
|
||||||
|
({"süd": ["1.1.1"], "sud": ["1.1.2"]}, "duplicate_area"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_reject_invalid_mapping(mapping: object, error: str) -> None:
|
||||||
|
"""Reject invalid mappings with a translated error key."""
|
||||||
|
with pytest.raises(AreaMappingError) as raised:
|
||||||
|
parse_area_mapping(mapping, {"1.1.1", "1.1.2"})
|
||||||
|
|
||||||
|
assert raised.value.translation_key == error
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
@@ -9,9 +10,11 @@ from homeassistant.const import CONF_URL
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.solaredge_optimizers.api import parse_snapshot
|
from custom_components.solaredge_optimizers.api import parse_snapshot
|
||||||
from custom_components.solaredge_optimizers.const import (
|
from custom_components.solaredge_optimizers.const import (
|
||||||
|
CONF_AREA_MAPPING,
|
||||||
CONF_SCAN_INTERVAL,
|
CONF_SCAN_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
@@ -68,3 +71,55 @@ async def test_hassio_discovery_flow(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result["data"][CONF_URL] == "http://abc-solaredge-optimizer-data:8099"
|
assert result["data"][CONF_URL] == "http://abc-solaredge-optimizer-data:8099"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_options_flow_configures_areas(hass: HomeAssistant) -> None:
|
||||||
|
"""Configure area mappings through the options flow."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="SolarEdge Site 4886699",
|
||||||
|
unique_id="4886699",
|
||||||
|
data={CONF_URL: "http://app:8099", CONF_SCAN_INTERVAL: 600},
|
||||||
|
version=2,
|
||||||
|
)
|
||||||
|
entry.runtime_data = SimpleNamespace(data=parse_snapshot(SAMPLE_PAYLOAD))
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "init"
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_AREA_MAPPING: {"west": ["1.1.1", "1.1.2"]}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert entry.options[CONF_AREA_MAPPING] == {"west": ["1.1.1", "1.1.2"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_options_flow_rejects_duplicate_optimizer(hass: HomeAssistant) -> None:
|
||||||
|
"""Reject assigning one optimizer to multiple areas."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="SolarEdge Site 4886699",
|
||||||
|
unique_id="4886699",
|
||||||
|
data={CONF_URL: "http://app:8099", CONF_SCAN_INTERVAL: 600},
|
||||||
|
version=2,
|
||||||
|
)
|
||||||
|
entry.runtime_data = SimpleNamespace(data=parse_snapshot(SAMPLE_PAYLOAD))
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
CONF_AREA_MAPPING: {
|
||||||
|
"west": ["1.1.1"],
|
||||||
|
"roof": ["1.1.1"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["errors"] == {"base": "duplicate_optimizer"}
|
||||||
|
|||||||
+129
-7
@@ -2,30 +2,54 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from homeassistant.const import CONF_URL
|
from homeassistant.const import CONF_URL, STATE_UNAVAILABLE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.solaredge_optimizers.api import parse_snapshot
|
from custom_components.solaredge_optimizers.api import parse_snapshot
|
||||||
from custom_components.solaredge_optimizers.const import CONF_SCAN_INTERVAL, DOMAIN
|
from custom_components.solaredge_optimizers.const import (
|
||||||
|
CONF_AREA_MAPPING,
|
||||||
|
CONF_SCAN_INTERVAL,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
from .sample_data import SAMPLE_PAYLOAD
|
from .sample_data import SAMPLE_PAYLOAD
|
||||||
|
|
||||||
|
|
||||||
async def test_optimizer_sensors(hass: HomeAssistant) -> None:
|
async def test_optimizer_sensors(hass: HomeAssistant) -> None:
|
||||||
"""Create energy and power sensors for every optimizer."""
|
"""Create energy and power sensors for every optimizer."""
|
||||||
|
payload = deepcopy(SAMPLE_PAYLOAD)
|
||||||
|
payload["optimizers"][1]["stringId"] = "1.2"
|
||||||
|
payload["optimizers"][1]["optimizerId"] = "1.2.1"
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
title="SolarEdge Site 4886699",
|
title="SolarEdge Site 4886699",
|
||||||
unique_id="4886699",
|
unique_id="4886699",
|
||||||
data={CONF_URL: "http://app:8099", CONF_SCAN_INTERVAL: 600},
|
data={CONF_URL: "http://app:8099", CONF_SCAN_INTERVAL: 600},
|
||||||
|
options={CONF_AREA_MAPPING: {"west": ["1.1.1", "1.2.1"]}},
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
device_registry = dr.async_get(hass)
|
device_registry = dr.async_get(hass)
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
stale_area_entity = entity_registry.async_get_or_create(
|
||||||
|
"sensor",
|
||||||
|
DOMAIN,
|
||||||
|
"4886699_area_old_daily_energy",
|
||||||
|
config_entry=entry,
|
||||||
|
original_name="Old area energy",
|
||||||
|
)
|
||||||
|
stale_string_entity = entity_registry.async_get_or_create(
|
||||||
|
"sensor",
|
||||||
|
DOMAIN,
|
||||||
|
"4886699_string_old_daily_energy",
|
||||||
|
config_entry=entry,
|
||||||
|
original_name="Old string energy",
|
||||||
|
)
|
||||||
device_registry.async_get_or_create(
|
device_registry.async_get_or_create(
|
||||||
config_entry_id=entry.entry_id,
|
config_entry_id=entry.entry_id,
|
||||||
identifiers={(DOMAIN, "4886699_14F28854-E2")},
|
identifiers={(DOMAIN, "4886699_14F28854-E2")},
|
||||||
@@ -38,20 +62,25 @@ async def test_optimizer_sensors(hass: HomeAssistant) -> None:
|
|||||||
with patch(
|
with patch(
|
||||||
"custom_components.solaredge_optimizers.api."
|
"custom_components.solaredge_optimizers.api."
|
||||||
"SolarEdgeOptimizerApiClient.async_get_optimizers",
|
"SolarEdgeOptimizerApiClient.async_get_optimizers",
|
||||||
AsyncMock(return_value=parse_snapshot(SAMPLE_PAYLOAD)),
|
AsyncMock(return_value=parse_snapshot(payload)),
|
||||||
):
|
):
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
entity_registry = er.async_get(hass)
|
|
||||||
entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
|
entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
|
||||||
assert len(entities) == 4
|
assert len(entities) == 10
|
||||||
|
assert entity_registry.async_get(stale_area_entity.entity_id) is None
|
||||||
|
assert entity_registry.async_get(stale_string_entity.entity_id) is None
|
||||||
|
|
||||||
energy_entity = next(
|
energy_entity = next(
|
||||||
entity for entity in entities if entity.unique_id.endswith("_daily_energy")
|
entity
|
||||||
|
for entity in entities
|
||||||
|
if entity.unique_id == "4886699_14F28854-E2_daily_energy"
|
||||||
)
|
)
|
||||||
power_entity = next(
|
power_entity = next(
|
||||||
entity for entity in entities if entity.unique_id.endswith("_current_power")
|
entity
|
||||||
|
for entity in entities
|
||||||
|
if entity.unique_id == "4886699_14F28854-E2_current_power"
|
||||||
)
|
)
|
||||||
energy_state = hass.states.get(energy_entity.entity_id)
|
energy_state = hass.states.get(energy_entity.entity_id)
|
||||||
power_state = hass.states.get(power_entity.entity_id)
|
power_state = hass.states.get(power_entity.entity_id)
|
||||||
@@ -60,9 +89,102 @@ async def test_optimizer_sensors(hass: HomeAssistant) -> None:
|
|||||||
assert "1.1.1" in energy_state.name
|
assert "1.1.1" in energy_state.name
|
||||||
assert "1.1.1" in power_state.name
|
assert "1.1.1" in power_state.name
|
||||||
|
|
||||||
|
area_energy_entity = next(
|
||||||
|
entity
|
||||||
|
for entity in entities
|
||||||
|
if entity.unique_id == "4886699_area_west_daily_energy"
|
||||||
|
)
|
||||||
|
area_power_entity = next(
|
||||||
|
entity
|
||||||
|
for entity in entities
|
||||||
|
if entity.unique_id == "4886699_area_west_current_power"
|
||||||
|
)
|
||||||
|
area_energy_state = hass.states.get(area_energy_entity.entity_id)
|
||||||
|
area_power_state = hass.states.get(area_power_entity.entity_id)
|
||||||
|
assert area_energy_state.state == "2468.5"
|
||||||
|
assert area_power_state.state == "321.4"
|
||||||
|
assert area_energy_state.attributes["optimizer_count"] == 2
|
||||||
|
assert area_energy_state.attributes["optimizer_ids"] == ["1.1.1", "1.2.1"]
|
||||||
|
|
||||||
|
string_1_energy_entity = next(
|
||||||
|
entity
|
||||||
|
for entity in entities
|
||||||
|
if entity.unique_id == "4886699_string_1_1_daily_energy"
|
||||||
|
)
|
||||||
|
string_2_power_entity = next(
|
||||||
|
entity
|
||||||
|
for entity in entities
|
||||||
|
if entity.unique_id == "4886699_string_1_2_current_power"
|
||||||
|
)
|
||||||
|
string_1_energy_state = hass.states.get(string_1_energy_entity.entity_id)
|
||||||
|
string_2_power_state = hass.states.get(string_2_power_entity.entity_id)
|
||||||
|
assert string_1_energy_state.state == "1234.5"
|
||||||
|
assert string_1_energy_state.attributes["string_id"] == "1.1"
|
||||||
|
assert string_1_energy_state.attributes["optimizer_ids"] == ["1.1.1"]
|
||||||
|
assert string_2_power_state.state == "160.7"
|
||||||
|
assert string_2_power_state.attributes["string_id"] == "1.2"
|
||||||
|
assert string_2_power_state.attributes["optimizer_ids"] == ["1.2.1"]
|
||||||
|
|
||||||
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
||||||
assert len(devices) == 1
|
assert len(devices) == 1
|
||||||
assert devices[0].identifiers == {(DOMAIN, "4886699")}
|
assert devices[0].identifiers == {(DOMAIN, "4886699")}
|
||||||
assert devices[0].name == "SolarEdge Site 4886699"
|
assert devices[0].name == "SolarEdge Site 4886699"
|
||||||
assert all(entity.device_id == devices[0].id for entity in entities)
|
assert all(entity.device_id == devices[0].id for entity in entities)
|
||||||
assert entry.version == 2
|
assert entry.version == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_area_energy_unavailable_when_one_optimizer_failed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> None:
|
||||||
|
"""Do not publish a misleading partial area sum."""
|
||||||
|
payload = deepcopy(SAMPLE_PAYLOAD)
|
||||||
|
payload["optimizers"][1]["dailyEnergyWh"] = None
|
||||||
|
payload["optimizers"][1]["error"] = "Request failed"
|
||||||
|
payload["successfulOptimizerCount"] = 1
|
||||||
|
payload["failedOptimizerCount"] = 1
|
||||||
|
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="SolarEdge Site 4886699",
|
||||||
|
unique_id="4886699",
|
||||||
|
data={CONF_URL: "http://app:8099", CONF_SCAN_INTERVAL: 600},
|
||||||
|
options={CONF_AREA_MAPPING: {"west": ["1.1.1", "1.1.2"]}},
|
||||||
|
version=2,
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"custom_components.solaredge_optimizers.api."
|
||||||
|
"SolarEdgeOptimizerApiClient.async_get_optimizers",
|
||||||
|
AsyncMock(return_value=parse_snapshot(payload)),
|
||||||
|
):
|
||||||
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
|
||||||
|
area_energy_entity = next(
|
||||||
|
entity
|
||||||
|
for entity in entities
|
||||||
|
if entity.unique_id == "4886699_area_west_daily_energy"
|
||||||
|
)
|
||||||
|
area_power_entity = next(
|
||||||
|
entity
|
||||||
|
for entity in entities
|
||||||
|
if entity.unique_id == "4886699_area_west_current_power"
|
||||||
|
)
|
||||||
|
string_energy_entity = next(
|
||||||
|
entity
|
||||||
|
for entity in entities
|
||||||
|
if entity.unique_id == "4886699_string_1_1_daily_energy"
|
||||||
|
)
|
||||||
|
string_power_entity = next(
|
||||||
|
entity
|
||||||
|
for entity in entities
|
||||||
|
if entity.unique_id == "4886699_string_1_1_current_power"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert hass.states.get(area_energy_entity.entity_id).state == STATE_UNAVAILABLE
|
||||||
|
assert hass.states.get(area_power_entity.entity_id).state == "321.4"
|
||||||
|
assert hass.states.get(string_energy_entity.entity_id).state == STATE_UNAVAILABLE
|
||||||
|
assert hass.states.get(string_power_entity.entity_id).state == "321.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user