diff --git a/README.md b/README.md index ac566d9..d24bcd8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom_components/solaredge_optimizers/manifest.json b/custom_components/solaredge_optimizers/manifest.json index a8ccb12..3047771 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.3.0" + "version": "0.4.0" } diff --git a/custom_components/solaredge_optimizers/sensor.py b/custom_components/solaredge_optimizers/sensor.py index 48c07c5..3d0f838 100644 --- a/custom_components/solaredge_optimizers/sensor.py +++ b/custom_components/solaredge_optimizers/sensor.py @@ -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), + } diff --git a/custom_components/solaredge_optimizers/strings.json b/custom_components/solaredge_optimizers/strings.json index 0796ab4..73901aa 100644 --- a/custom_components/solaredge_optimizers/strings.json +++ b/custom_components/solaredge_optimizers/strings.json @@ -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" } } } diff --git a/custom_components/solaredge_optimizers/translations/de.json b/custom_components/solaredge_optimizers/translations/de.json index 407da5a..c99c269 100644 --- a/custom_components/solaredge_optimizers/translations/de.json +++ b/custom_components/solaredge_optimizers/translations/de.json @@ -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" } } } diff --git a/custom_components/solaredge_optimizers/translations/en.json b/custom_components/solaredge_optimizers/translations/en.json index 0796ab4..73901aa 100644 --- a/custom_components/solaredge_optimizers/translations/en.json +++ b/custom_components/solaredge_optimizers/translations/en.json @@ -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" } } } diff --git a/pyproject.toml b/pyproject.toml index bccbd7b..462a431 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 65d6442..2c455a0 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -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" diff --git a/uv.lock b/uv.lock index 887c436..02e54cb 100644 --- a/uv.lock +++ b/uv.lock @@ -943,7 +943,7 @@ wheels = [ [[package]] name = "ha-solaredge-optimizers" -version = "0.3.0" +version = "0.4.0" source = { virtual = "." } [package.dev-dependencies]