Add automatic optimizer string sensors

This commit is contained in:
2026-08-10 12:31:07 +02:00
parent 02bb4a831e
commit ec6ec30dff
9 changed files with 216 additions and 14 deletions
+6 -1
View File
@@ -19,6 +19,11 @@ 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,
String-ID, Wechselrichter-ID und Zeitpunkt der letzten Messung stehen als
Attribute zur Verfügung.
@@ -38,7 +43,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.3.0.zip
https://git.jensneuber.de/jens/ha-solaredge-optimizers/archive/v0.4.0.zip
```
Kopiere anschließend den enthaltenen Ordner
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://git.jensneuber.de/jens/ha-solaredge-optimizers/issues",
"requirements": [],
"version": "0.3.0"
"version": "0.4.0"
}
@@ -78,6 +78,27 @@ AREA_SENSOR_DESCRIPTIONS = (
),
)
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(
hass: HomeAssistant,
@@ -96,25 +117,44 @@ async def async_setup_entry(
async_add_entities(area_entities)
known_serials: set[str] = set()
known_string_ids: set[str] = set()
@callback
def async_add_new_optimizers() -> None:
def async_add_new_entities() -> None:
entities: list[SensorEntity] = []
new_optimizers = [
optimizer
for optimizer in coordinator.data.optimizers
if optimizer.serial not in known_serials
]
if not new_optimizers:
return
known_serials.update(optimizer.serial for optimizer in new_optimizers)
async_add_entities(
entities.extend(
SolarEdgeOptimizerSensor(coordinator, optimizer.serial, description)
for optimizer in new_optimizers
for description in SENSOR_DESCRIPTIONS
)
async_add_new_optimizers()
entry.async_on_unload(coordinator.async_add_listener(async_add_new_optimizers))
new_string_ids = sorted(
{
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(
@@ -134,6 +174,31 @@ def _remove_stale_area_entities(
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
@@ -264,3 +329,75 @@ class SolarEdgeAreaSensor(
"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),
}
@@ -77,6 +77,12 @@
},
"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"
}
}
}
@@ -77,6 +77,12 @@
},
"area_current_power": {
"name": "{area} aktuelle Leistung"
},
"string_daily_energy": {
"name": "String {string_id} Tagesenergie"
},
"string_current_power": {
"name": "String {string_id} aktuelle Leistung"
}
}
}
@@ -77,6 +77,12 @@
},
"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
View File
@@ -1,6 +1,6 @@
[project]
name = "ha-solaredge-optimizers"
version = "0.3.0"
version = "0.4.0"
description = "Home Assistant custom integration for SolarEdge optimizer data"
readme = "README.md"
requires-python = ">=3.14.2"
+46 -4
View File
@@ -23,12 +23,15 @@ from .sample_data import SAMPLE_PAYLOAD
async def test_optimizer_sensors(hass: HomeAssistant) -> None:
"""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(
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"]}},
options={CONF_AREA_MAPPING: {"west": ["1.1.1", "1.2.1"]}},
)
entry.add_to_hass(hass)
device_registry = dr.async_get(hass)
@@ -40,6 +43,13 @@ async def test_optimizer_sensors(hass: HomeAssistant) -> None:
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(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, "4886699_14F28854-E2")},
@@ -52,14 +62,15 @@ async def test_optimizer_sensors(hass: HomeAssistant) -> None:
with patch(
"custom_components.solaredge_optimizers.api."
"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)
await hass.async_block_till_done()
entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
assert len(entities) == 6
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(
entity
@@ -93,7 +104,26 @@ async def test_optimizer_sensors(hass: HomeAssistant) -> None:
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"]
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)
assert len(devices) == 1
@@ -143,6 +173,18 @@ async def test_area_energy_unavailable_when_one_optimizer_failed(
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"
Generated
+1 -1
View File
@@ -943,7 +943,7 @@ wheels = [
[[package]]
name = "ha-solaredge-optimizers"
version = "0.3.0"
version = "0.4.0"
source = { virtual = "." }
[package.dev-dependencies]