Skip to content

Commit 6574d43

Browse files
authored
Merge pull request #35 from AndrewTapp/dev
v2.4.13
2 parents a34a528 + b843dca commit 6574d43

File tree

10 files changed

+97
-50
lines changed

10 files changed

+97
-50
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Your solar system is organised in a simple hierarchy. Device and entity names in
5454

5555
- **Voltage**, **Current**, **Optimizer voltage**, **Power** – Live values when the optimizer is reporting.
5656
- **Temperature** – Optimizer temperature from the SolarEdge One API (layout/energy by-inverter with `include-max-temperature`). The portal may report in °C or °F (`temperatureUnit`); the integration normalizes to °C for storage and Home Assistant displays in your preferred unit. Only available when using the One API; shown as “unknown” when missing or when using the legacy API. When the integration is not doing a full refresh (e.g. reusing data after a light check), it still refreshes temperatures when the temperature cache expires (30 minutes, TEMPERATURE_CACHE_TTL), so temperature stays up to date even when power/voltage are not updating.
57-
- **Lifetime energy** – Total energy produced (kWh); this only goes up over time. The integration uses the API’s raw energy value (unscaledEnergy, in Wh) so it updates correctly regardless of how the portal displays units (Wh/kWh/MWh). When optimizer-level lifetime data is reliable, site lifetime is the sum of optimizers; when it is not (e.g. mixed or missing data), the site uses the portal’s total directly.
57+
- **Lifetime energy** – Total energy produced (kWh); this only goes up over time. The integration uses the API’s raw energy value (unscaledEnergy, in Wh) so it updates correctly regardless of how the portal displays units (Wh/kWh/MWh). Site lifetime is the sum of inverters when aggregated data is reliable, or the portal total when unreliable (e.g. below `RELIABLE_THRESHOLD_KWH`). String totals are derived by summing that string's optimizer entries, not from API string-level keys that can be site totals.
5858
- **Last measurement** – When the portal last had a reading for this optimizer.
5959
- **Status** – The optimizer's status from the API. Blank (empty) status is treated as active and displayed as **blank** with the active icon. Values are shown in proper case: "Active", "Inactive", or the raw value for any other status. The icon changes based on status: check-circle for Active or blank, alert-circle for Inactive, help-circle for unknown (any other) status.
6060
- **Azimuth** – The panel's compass direction in degrees (0–360°), converted from radians. Only available when the API provides module orientation data. Icon: compass.
@@ -80,7 +80,7 @@ Names are kept short (e.g. “Current (average)”, “Power”) because the dev
8080
- The **coordinator** runs every **5 minutes** (`UPDATE_DELAY`). It does a lightweight check (one or a few optimizers) to see if the portal has new readings. When data is **fresh**, the desired interval between light checks is about **5 minutes**; when data is **stale or missing**, about **30 minutes**. When the light check detects new data, a full refresh runs so all sensors update; a full refresh is not triggered again within **5 minutes** of the last one (`LIGHT_CHECK_MIN_INTERVAL`). When using **SolarEdge One**, up to `LIGHT_CHECK_BATCH_SIZE` (5) optimizers are chosen at random for each check so different orientations and shade don't block updates; when falling back to the legacy API, a single representative optimizer is used for the light check. When data is currently from the **legacy** API, the integration also forces a full refresh **every 30 minutes** so it re-tries the SolarEdge One API and can switch back to One when it becomes available again.
8181
- **Lifetime energy** is only refreshed from the portal about **once per hour** (`LIFETIME_ENERGY_CACHE_TTL`), because that value changes slowly. It is derived from the API’s unscaled energy (Wh), not the display units, so values update correctly. Totals for strings, inverters, and the site are calculated from that data.
8282

83-
- **Layout (panels)** is cached for **2 hours** when using SolarEdge One (`PANELS_CACHE_TTL_ONE`) and **1 hour** when using the legacy API (`PANELS_CACHE_TTL_LEGACY`).
83+
- **Layout (panels)** is cached for **2 hours** when using SolarEdge One (`PANELS_CACHE_TTL_ONE`) or the legacy API (`PANELS_CACHE_TTL_LEGACY`).
8484

8585
- **Temperature** (SolarEdge One only): When the integration does not perform a full refresh (e.g. it reuses existing data after a light check), it still refreshes optimizer temperatures when the temperature cache expires (**30 minutes**, `TEMPERATURE_CACHE_TTL`). So temperature sensors stay updated even when power, voltage, and current are not being refreshed.
8686

@@ -176,6 +176,8 @@ logger:
176176
177177
Restart Home Assistant for the change to take effect. Debug logging covers the full lifecycle: config flow (user form, validation, unique_id check, entry creation, reauth form, options/reconfigure form when showing or saving — including entity_id_prefix, include_site_id_in_entity_id, use_solaredge_one — removal), setup and unload (dual API with use_solaredge_one, coordinator, platform forward, API session close, registry cleanup), coordinator updates (inverter models fetch, device creation with model and suffix, adaptive polling with configurable batch size (`LIGHT_CHECK_BATCH_SIZE`), full refresh vs reuse, obtained_from source, revert-to-One retry every 30 min when data from legacy, representative optimizers or random batch, lifetime energy entries, inactive device skipping with status, duplicate position resolution with suffix assignment, string/inverter/site aggregated data creation with status and child counts, refresh strategy determination, update complete), sensor setup (base_name, include_site_id, per-optimizer serial/model/panel_type/status, duplicate optimizer position resolution with suffixes, inactive device sensor skipping, aggregated sensors with status, obtained_from sensor, entity count, device status summary when inactive devices exist, status value updates), and API requests (SolarEdge One: OAuth, token, GET/POST to /services/ with configurable timeouts (`API_TIMEOUT_SHORT`/`API_TIMEOUT_LONG`), requestAllData (single batch for all optimizers; per-optimizer fallback when batch fails), optimizer/inverter information including panel type (description), cache hit/miss, optimizer temperatures with unit and F→°C conversion when portal sends Fahrenheit; legacy: login, layout, system data, lifetime energy; dual API: use_solaredge_one/legacy-only, fallback to legacy when One has no valid measurements or fails). All debug output is guarded with `isEnabledFor(logging.DEBUG)`, so there is no performance cost when the log level is `info`. Debug messages use consistent prefixes (e.g. `SolarEdge Optimizers`, `SolarEdge Optimizers coordinator`, `SolarEdge Optimizers sensor`, `SolarEdge One`, `SolarEdge Optimizers (legacy)`, `SolarEdge Dual API`) so you can filter logs easily. Turn logging back to `info` when you are done.
178178
179+
**Code quality:** The project uses Pylint and pycodestyle (e.g. via CodeFactor). See `pyproject.toml` for tool config and `CODEQUALITY.md` for intentional patterns and suppressions.
180+
179181
## Translations
180182

181183
The integration is localized for multiple languages: config flow (labels, errors, entry title), sensor and device names, and API locale follow the user’s Home Assistant language where supported. See [Internationalization (i18n)](https://github.com/AndrewTapp/solaredgeoptimizers/blob/main/docs/internationalization.md) for details.

custom_components/solaredgeoptimizers/__init__.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -276,27 +276,34 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
276276
if LOGGER.isEnabledFor(logging.DEBUG):
277277
LOGGER.debug("SolarEdge Optimizers: Unloading config entry %s", entry.entry_id)
278278
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
279-
# Remove from entity and device registries so no leftovers after delete
280-
try:
281-
remove_entities_and_devices_for_entry(hass, entry)
282-
except Exception as e: # pylint: disable=broad-except
283-
LOGGER.warning(
284-
"SolarEdge Optimizers: Error cleaning registries during unload: %s",
285-
e,
286-
)
287-
# Added cleanup of coordinator resources
279+
# Pop coordinator first so we always close its API (release file descriptors) even if cleanup fails
288280
coordinator = hass.data[DOMAIN].pop(entry.entry_id, None)
289-
# Close API sessions to prevent file descriptor leaks
290-
if coordinator and hasattr(coordinator, "my_api"):
281+
try:
282+
# Remove from entity and device registries so no leftovers after delete
291283
try:
292-
await hass.async_add_executor_job(coordinator.my_api.close)
284+
remove_entities_and_devices_for_entry(hass, entry)
285+
except Exception as e: # pylint: disable=broad-except
286+
LOGGER.warning(
287+
"SolarEdge Optimizers: Error cleaning registries during unload: %s",
288+
e,
289+
)
290+
finally:
291+
# Always close API sessions to release all file descriptors (legacy Session pool, etc.)
292+
if coordinator is not None and hasattr(coordinator, "my_api"):
293293
if LOGGER.isEnabledFor(logging.DEBUG):
294294
LOGGER.debug(
295-
"SolarEdge Optimizers: Closed API session for entry %s during unload",
295+
"SolarEdge Optimizers: Unload finally: closing API sessions for entry %s",
296296
entry.entry_id,
297297
)
298-
except Exception as e: # pylint: disable=broad-except
299-
LOGGER.warning("SolarEdge Optimizers: Error closing API sessions: %s", e)
298+
try:
299+
await hass.async_add_executor_job(coordinator.my_api.close)
300+
if LOGGER.isEnabledFor(logging.DEBUG):
301+
LOGGER.debug(
302+
"SolarEdge Optimizers: Closed API session for entry %s during unload",
303+
entry.entry_id,
304+
)
305+
except Exception as e: # pylint: disable=broad-except
306+
LOGGER.warning("SolarEdge Optimizers: Error closing API sessions: %s", e)
300307
else:
301308
if LOGGER.isEnabledFor(logging.DEBUG):
302309
LOGGER.debug(

custom_components/solaredgeoptimizers/api_dual.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ def close(self) -> None:
170170
"""Close both API clients and release all file descriptors (sessions, connection pools).
171171
Idempotent; safe to call multiple times. Both clients are always closed even if one raises.
172172
"""
173+
if _LOGGER.isEnabledFor(logging.DEBUG):
174+
_LOGGER.debug("SolarEdge Dual API: Closing both API clients (One and Legacy)")
173175
for name, client in [("One", self._one), ("Legacy", self._legacy)]:
174176
try:
175177
client.close()

custom_components/solaredgeoptimizers/config_flow.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,11 +288,16 @@ async def async_remove_entry(
288288
# Close API so all sessions/connection pools are released (in case unload did not run or did not close)
289289
coordinator = hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
290290
if coordinator is not None and hasattr(coordinator, "my_api"):
291+
if _LOGGER.isEnabledFor(logging.DEBUG):
292+
_LOGGER.debug(
293+
"SolarEdge Optimizers config: Closing API on config entry removal for entry %s",
294+
entry.entry_id,
295+
)
291296
try:
292297
await hass.async_add_executor_job(coordinator.my_api.close)
293298
if _LOGGER.isEnabledFor(logging.DEBUG):
294299
_LOGGER.debug(
295-
"SolarEdge Optimizers: Closed API on config entry removal for entry %s",
300+
"SolarEdge Optimizers config: Closed API on config entry removal for entry %s",
296301
entry.entry_id,
297302
)
298303
except Exception as e: # pylint: disable=broad-except
@@ -317,7 +322,9 @@ def __init__(self, entry: ConfigEntry) -> None:
317322
"""Initialize options flow."""
318323
self._entry = entry
319324

320-
def async_show_form(self, *, step_id=None, data_schema=None, errors=None, description_placeholders=None, last_step=None, preview=None):
325+
def async_show_form( # pylint: disable=too-many-arguments
326+
self, *, step_id=None, data_schema=None, errors=None, description_placeholders=None, last_step=None, preview=None
327+
):
321328
"""Show form and ensure frontend uses this integration's translations (options.step.init.data.*)."""
322329
result = super().async_show_form(
323330
step_id=step_id,

custom_components/solaredgeoptimizers/coordinator.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -270,21 +270,27 @@ async def _fetch_inverter_models(self, site) -> None:
270270
_LOGGER.warning("SolarEdge Optimizers: Could not fetch inverter models: %s", e)
271271

272272
def _build_lifetime_energy_lookup(self, lifetime_energy_data):
273-
"""Build stringId -> energy_data lookup; derive string total from optimizer sum when needed."""
273+
"""Build stringId -> energy_data lookup; derive string total from optimizer sum when needed.
274+
275+
Prefer the sum of this string's optimizer entries over any API string-level key. The legacy
276+
layout/energy response can contain a key that matches a stringId but holds a site- or
277+
inverter-level total; using that would inflate one string and (with multiple inverters or
278+
duplicate layout entries) produce a grossly inflated site total and double-counting.
279+
"""
274280
lifetime_energy_lookup = {}
275281
for inv in self._site_structure.inverters:
276282
for s in inv.strings:
277-
key = str(s.stringId)
278-
if key in lifetime_energy_data:
279-
lifetime_energy_lookup[s.stringId] = lifetime_energy_data[key]
283+
total_wh = 0.0
284+
for opt in s.optimizers:
285+
ent = lifetime_energy_data.get(str(opt.optimizerId)) or lifetime_energy_data.get(opt.optimizerId)
286+
if ent and isinstance(ent.get("unscaledEnergy"), (int, float)):
287+
total_wh += float(ent["unscaledEnergy"])
288+
if total_wh > 0:
289+
lifetime_energy_lookup[s.stringId] = {"unscaledEnergy": total_wh}
280290
else:
281-
total_wh = 0.0
282-
for opt in s.optimizers:
283-
ent = lifetime_energy_data.get(str(opt.optimizerId)) or lifetime_energy_data.get(opt.optimizerId)
284-
if ent and isinstance(ent.get("unscaledEnergy"), (int, float)):
285-
total_wh += float(ent["unscaledEnergy"])
286-
if total_wh > 0:
287-
lifetime_energy_lookup[s.stringId] = {"unscaledEnergy": total_wh}
291+
key = str(s.stringId)
292+
if key in lifetime_energy_data:
293+
lifetime_energy_lookup[s.stringId] = lifetime_energy_data[key]
288294
return lifetime_energy_lookup
289295

290296
def _aggregate_optimizers_in_string(self, string, data_dict, timetocheck):
@@ -516,7 +522,11 @@ def _register_inverter_and_string_devices(
516522
via_device=(DOMAIN, inv_device_id),
517523
)
518524
if _LOGGER.isEnabledFor(logging.DEBUG):
519-
_LOGGER.debug("Created device for string: %s (suffix=%s)", string_name, str_suffix or "(none)")
525+
_LOGGER.debug(
526+
"SolarEdge Optimizers: Created device for string: %s (suffix=%s)",
527+
string_name,
528+
str_suffix or "(none)",
529+
)
520530

521531
def ensure_devices_registered(self) -> None:
522532
"""Ensure site, inverter, and string devices exist in the device registry.
@@ -842,6 +852,12 @@ def _calculate_aggregated_data(
842852
Child counts (optimizer count, string count, inverter count) count only active (blank or ACTIVE) devices.
843853
"""
844854
lifetime_energy_lookup = self._build_lifetime_energy_lookup(lifetime_energy_data)
855+
if _LOGGER.isEnabledFor(logging.DEBUG):
856+
_LOGGER.debug(
857+
"SolarEdge Optimizers coordinator: Lifetime energy lookup built with %d string(s) for site %s",
858+
len(lifetime_energy_lookup),
859+
site_id,
860+
)
845861
site_id_str = str(site_id)
846862
site = SiteRollupState(
847863
current=0.0, power=0.0, voltage_sum=0.0, voltage_count=0,
@@ -864,6 +880,13 @@ def _calculate_aggregated_data(
864880
and portal_site_lifetime_kwh >= RELIABLE_THRESHOLD_KWH
865881
and site.lifetime_energy < RELIABLE_THRESHOLD_KWH
866882
):
883+
if _LOGGER.isEnabledFor(logging.DEBUG):
884+
_LOGGER.debug(
885+
"SolarEdge Optimizers coordinator: Site %s using portal lifetime (aggregated=%.3f kWh < threshold, portal=%.3f kWh)",
886+
site_id,
887+
site.lifetime_energy,
888+
portal_site_lifetime_kwh,
889+
)
867890
site = site._replace(lifetime_energy=portal_site_lifetime_kwh)
868891

869892
site_aggregated = self._create_site_aggregated(site_id, site, current_utc)
@@ -977,7 +1000,7 @@ async def _run_light_check(self, now_utc, latest_measurement) -> bool:
9771000
return self._light_check_should_trigger_full_refresh(rep_list, latest_measurement, now_utc)
9781001
except Exception as e: # pylint: disable=broad-except
9791002
if _LOGGER.isEnabledFor(logging.DEBUG):
980-
_LOGGER.debug("Lightweight update check failed: %s", e)
1003+
_LOGGER.debug("SolarEdge Optimizers coordinator: Lightweight update check failed: %s", e)
9811004
return False
9821005

9831006
def _index_optimizers_by_position(self, data_dict: dict) -> None:

custom_components/solaredgeoptimizers/docs/SolarEdge-One-API-Summary.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ A site-level sensor **Obtained from** shows whether current data came from **"On
3636
- **Auth**: Single OAuth flow and Bearer token instead of mixing Basic Auth and session cookies.
3737
- **Data**: Structured JSON (e.g. `power_W`, `voltage_V`, `optimizerVoltage_V`) instead of locale-dependent labels; layout in a single v2 structure.
3838
- **Devices**: Optimizer and inverter **model** (and serial) come from the API, so HA devices can show real model names (e.g. P405-4RM4MRM-NA25, SE5000H-RW000BNN4). When the API provides a **panel type** (description, e.g. SunPower SPR-MAX3-400), it is included in the optimizer device model and exposed as a **panel_type** attribute on optimizer sensors.
39-
- **Polling**: Lightweight “any new data?” check can use a batch request (up to `LIGHT_CHECK_BATCH_SIZE` = 5 optimizers) instead of one panel; 1-hour stale threshold for live values (legacy used 2 hours). **Full refresh** fetches all optimizer live data in **one batch POST** (`requestSystemDataBatch` with all serials), reducing portal load; if that batch fails (e.g. 5xx), the integration falls back to per-optimizer requests.
39+
- **Polling**: Lightweight “any new data?” check can use a batch request (up to `LIGHT_CHECK_BATCH_SIZE` = 5 optimizers) instead of one panel; 1-hour stale threshold for live values (legacy uses 2 hours). Site layout (panels) is cached for **2 hours** (`PANELS_CACHE_TTL_ONE`) when using SolarEdge One. **Full refresh** fetches all optimizer live data in **one batch POST** (`requestSystemDataBatch` with all serials), reducing portal load; if that batch fails (e.g. 5xx), the integration falls back to per-optimizer requests.
4040

4141
## In this integration
4242

0 commit comments

Comments
 (0)