From 02bb4a831ea2382a3147e8a8d2b33b577f10151c Mon Sep 17 00:00:00 2001 From: Jens Neuber Date: Mon, 10 Aug 2026 12:25:43 +0200 Subject: [PATCH] Add configurable optimizer area sensors --- README.md | 27 +++- custom_components/solaredge_optimizers/api.py | 9 ++ .../solaredge_optimizers/areas.py | 124 ++++++++++++++++ .../solaredge_optimizers/config_flow.py | 80 +++++++++- .../solaredge_optimizers/const.py | 1 + .../solaredge_optimizers/diagnostics.py | 1 + .../solaredge_optimizers/manifest.json | 2 +- .../solaredge_optimizers/sensor.py | 137 ++++++++++++++++-- .../solaredge_optimizers/strings.json | 27 ++++ .../solaredge_optimizers/translations/de.json | 27 ++++ .../solaredge_optimizers/translations/en.json | 27 ++++ pyproject.toml | 2 +- tests/test_areas.py | 51 +++++++ tests/test_config_flow.py | 55 +++++++ tests/test_sensor.py | 92 +++++++++++- uv.lock | 2 +- 16 files changed, 643 insertions(+), 21 deletions(-) create mode 100644 custom_components/solaredge_optimizers/areas.py create mode 100644 tests/test_areas.py diff --git a/README.md b/README.md index 4572c42..ac566d9 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,13 @@ Pro Optimierer werden zwei Sensoren angelegt: - **Tagesenergie** in Wh (`total_increasing`) - **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 Seriennummer bleibt Bestandteil der stabilen Entity-ID. Optimierer-ID, String-ID, Wechselrichter-ID und Zeitpunkt der letzten Messung stehen als Attribute zur Verfügung. @@ -31,7 +38,7 @@ HACS unterstützt keine Gitea-Repositories. Lade deshalb das Archiv der gewünschten Version herunter und entpacke es: ```text -https://git.jensneuber.de/jens/ha-solaredge-optimizers/archive/v0.2.0.zip +https://git.jensneuber.de/jens/ha-solaredge-optimizers/archive/v0.3.0.zip ``` Kopiere anschließend den enthaltenen Ordner @@ -57,6 +64,24 @@ Die Integration liest den Cache standardmäßig alle 600 Sekunden. Dieser Wert kann über **Neu konfigurieren** geändert werden. Dadurch entstehen keine 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 ```shell diff --git a/custom_components/solaredge_optimizers/api.py b/custom_components/solaredge_optimizers/api.py index 185de8c..3598ef3 100644 --- a/custom_components/solaredge_optimizers/api.py +++ b/custom_components/solaredge_optimizers/api.py @@ -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.""" diff --git a/custom_components/solaredge_optimizers/areas.py b/custom_components/solaredge_optimizers/areas.py new file mode 100644 index 0000000..56da3e7 --- /dev/null +++ b/custom_components/solaredge_optimizers/areas.py @@ -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()} diff --git a/custom_components/solaredge_optimizers/config_flow.py b/custom_components/solaredge_optimizers/config_flow.py index 39d50ff..e569d79 100644 --- a/custom_components/solaredge_optimizers/config_flow.py +++ b/custom_components/solaredge_optimizers/config_flow.py @@ -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)) + }, + ) diff --git a/custom_components/solaredge_optimizers/const.py b/custom_components/solaredge_optimizers/const.py index 9890f19..63faa94 100644 --- a/custom_components/solaredge_optimizers/const.py +++ b/custom_components/solaredge_optimizers/const.py @@ -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 diff --git a/custom_components/solaredge_optimizers/diagnostics.py b/custom_components/solaredge_optimizers/diagnostics.py index 07fecd9..b131206 100644 --- a/custom_components/solaredge_optimizers/diagnostics.py +++ b/custom_components/solaredge_optimizers/diagnostics.py @@ -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, diff --git a/custom_components/solaredge_optimizers/manifest.json b/custom_components/solaredge_optimizers/manifest.json index 1398403..a8ccb12 100644 --- a/custom_components/solaredge_optimizers/manifest.json +++ b/custom_components/solaredge_optimizers/manifest.json @@ -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" } diff --git a/custom_components/solaredge_optimizers/sensor.py b/custom_components/solaredge_optimizers/sensor.py index 07cd6f6..48c07c5 100644 --- a/custom_components/solaredge_optimizers/sensor.py +++ b/custom_components/solaredge_optimizers/sensor.py @@ -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), + } diff --git a/custom_components/solaredge_optimizers/strings.json b/custom_components/solaredge_optimizers/strings.json index 3aea159..0796ab4 100644 --- a/custom_components/solaredge_optimizers/strings.json +++ b/custom_components/solaredge_optimizers/strings.json @@ -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" } } } diff --git a/custom_components/solaredge_optimizers/translations/de.json b/custom_components/solaredge_optimizers/translations/de.json index ae7dfe3..407da5a 100644 --- a/custom_components/solaredge_optimizers/translations/de.json +++ b/custom_components/solaredge_optimizers/translations/de.json @@ -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" } } } diff --git a/custom_components/solaredge_optimizers/translations/en.json b/custom_components/solaredge_optimizers/translations/en.json index 3aea159..0796ab4 100644 --- a/custom_components/solaredge_optimizers/translations/en.json +++ b/custom_components/solaredge_optimizers/translations/en.json @@ -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" } } } diff --git a/pyproject.toml b/pyproject.toml index 6bf2e21..bccbd7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ha-solaredge-optimizers" -version = "0.2.0" +version = "0.3.0" description = "Home Assistant custom integration for SolarEdge optimizer data" readme = "README.md" requires-python = ">=3.14.2" diff --git a/tests/test_areas.py b/tests/test_areas.py new file mode 100644 index 0000000..f42b4d4 --- /dev/null +++ b/tests/test_areas.py @@ -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 diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 554fd18..e23d345 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from types import SimpleNamespace from unittest.mock import AsyncMock, patch from homeassistant import config_entries @@ -9,9 +10,11 @@ from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType 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.const import ( + CONF_AREA_MAPPING, CONF_SCAN_INTERVAL, DOMAIN, ) @@ -68,3 +71,55 @@ async def test_hassio_discovery_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY 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"} diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 0188953..65d6442 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -2,16 +2,21 @@ from __future__ import annotations +from copy import deepcopy 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.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er from pytest_homeassistant_custom_component.common import MockConfigEntry 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 @@ -23,9 +28,18 @@ async def test_optimizer_sensors(hass: HomeAssistant) -> None: 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"]}}, ) entry.add_to_hass(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", + ) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "4886699_14F28854-E2")}, @@ -43,15 +57,19 @@ async def test_optimizer_sensors(hass: HomeAssistant) -> None: 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) - assert len(entities) == 4 + assert len(entities) == 6 + assert entity_registry.async_get(stale_area_entity.entity_id) is None 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( - 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) power_state = hass.states.get(power_entity.entity_id) @@ -60,9 +78,71 @@ async def test_optimizer_sensors(hass: HomeAssistant) -> None: assert "1.1.1" in energy_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.1.2"] + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) assert len(devices) == 1 assert devices[0].identifiers == {(DOMAIN, "4886699")} assert devices[0].name == "SolarEdge Site 4886699" assert all(entity.device_id == devices[0].id for entity in entities) 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" + ) + + assert hass.states.get(area_energy_entity.entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(area_power_entity.entity_id).state == "321.4" diff --git a/uv.lock b/uv.lock index cf467f4..887c436 100644 --- a/uv.lock +++ b/uv.lock @@ -943,7 +943,7 @@ wheels = [ [[package]] name = "ha-solaredge-optimizers" -version = "0.2.0" +version = "0.3.0" source = { virtual = "." } [package.dev-dependencies]