"""Read Solar API and GEN24 load-management state without changing the inverter.""" from datetime import datetime, timezone import logging import math import time from uuid import uuid4 from .config import InverterConfig, utc_text from .http import JsonHttp, TransportError LOG = logging.getLogger(__name__) REQUEST_RETRIES = 3 def _number(value): if value is None: return None if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): raise ValueError("Invalid numeric reading") return value def _sum_known(values): values = list(values) return sum(values) if values and all(v is not None for v in values) else None def _data(response): head = response.get("Head", {}) if head.get("Status", {}).get("Code") != 0: raise ValueError("Solar API reported an unsuccessful status") data = response.get("Body", {}).get("Data") if not isinstance(data, dict): raise ValueError("Solar API data is missing") timestamp = datetime.fromisoformat(head["Timestamp"].replace("Z", "+00:00")) return data, utc_text(timestamp) def powerflow_readings(response): data, timestamp = _data(response) site = data.get("Site") inverters = data.get("Inverters", {}) if not isinstance(site, dict) or not isinstance(inverters, dict): raise ValueError("Solar API site/inverter data is missing") if not {"P_PV", "P_Load", "P_Grid"}.issubset(site): raise ValueError("Required Solar API power fields are missing") load = _number(site.get("P_Load")) grid = _number(site.get("P_Grid")) total = _number(site.get("E_Total")) if total is None: total = _sum_known(_number(device.get("E_Total")) for device in inverters.values()) return { # GEN24 P_PV is DC production; keep AC output separately instead of mixing them. "production_w": _number(site.get("P_PV")), "production_ac_w": _sum_known(_number(device.get("P")) for device in inverters.values()), "consumption_w": -load if load is not None else None, "grid_w": grid, # Positive: import. Negative: export (feed-in point meter). "grid_import_w": max(0, grid) if grid is not None else None, "grid_export_w": max(0, -grid) if grid is not None else None, "production_ac_total_wh": total, }, timestamp def meter_readings(response, meter_id): data, timestamp = _data(response) meter = data.get(meter_id) if not isinstance(meter, dict): raise ValueError(f"Configured meter {meter_id} is missing") if meter.get("Enable") not in (1, 1.0): raise ValueError("Configured meter is disabled") if meter.get("Meter_Location_Current") != 0: raise ValueError("Configured meter must be at the grid feed-in point") if not {"EnergyReal_WAC_Sum_Consumed", "EnergyReal_WAC_Sum_Produced"}.issubset(meter): raise ValueError("Required meter energy counters are missing") if meter.get("TimeStamp") is not None: try: timestamp = utc_text(datetime.fromtimestamp(_number(meter["TimeStamp"]), timezone.utc)) except (OverflowError, OSError): raise ValueError("Invalid meter measurement timestamp") from None return { "grid_import_total_wh": _number(meter.get("EnergyReal_WAC_Sum_Consumed")), "grid_export_total_wh": _number(meter.get("EnergyReal_WAC_Sum_Produced")), }, timestamp def boiler_readings(response, pin): state = response.get(pin) if not isinstance(state, dict) or not isinstance(state.get("state"), bool): raise ValueError(f"Boiler output {pin} has no boolean state") reason = state.get("reason") if reason is not None and not isinstance(reason, str): raise ValueError("Boiler reason is not a string") return {"boiler_enabled": state["state"], "boiler_reason": reason} class FroniusClient: def __init__(self, config: InverterConfig, *, http=None): self.config = config self.http = http or JsonHttp(config.request_timeout_seconds, local=True) self.last_request_started = None def _read(self, path): for attempt in range(REQUEST_RETRIES + 1): if self.last_request_started is not None: wait = self.config.min_request_interval_seconds - (time.monotonic() - self.last_request_started) if wait > 0: time.sleep(wait) self.last_request_started = time.monotonic() try: return self.http.request(self.config.base_url.rstrip("/") + path) except TransportError as error: if attempt == REQUEST_RETRIES: raise LOG.warning("Fronius GET %s failed (%s); retry %d/%d", path, error, attempt + 1, REQUEST_RETRIES) def collect(self, source_id, poll_interval_seconds=300): sample = { "schema_version": 1, "id": str(uuid4()), "source_id": source_id, "poll_interval_seconds": poll_interval_seconds, "observed_at": utc_text(datetime.now(timezone.utc)), "inverter": {"meter_id": self.config.meter_id, "boiler_pin": self.config.boiler_pin}, "readings": dict.fromkeys([ "production_w", "production_ac_w", "consumption_w", "grid_w", "grid_import_w", "grid_export_w", "production_ac_total_wh", "grid_import_total_wh", "grid_export_total_wh", "boiler_enabled", "boiler_reason", ]), "source_timestamps": {}, "errors": {}, "raw": {}, } for name, path in [ ("powerflow", "/solar_api/v1/GetPowerFlowRealtimeData.fcgi"), ("meter", "/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System"), ("boiler", "/api/status/emrs"), ]: try: response = self._read(path) sample["raw"][name] = response if name == "powerflow": readings, timestamp = powerflow_readings(response) elif name == "meter": readings, timestamp = meter_readings(response, self.config.meter_id) else: readings = boiler_readings(response, self.config.boiler_pin) timestamp = utc_text(datetime.now(timezone.utc)) sample["readings"].update(readings) sample["source_timestamps"][name] = timestamp except (TransportError, ValueError, KeyError, TypeError, AttributeError) as error: sample["errors"][name] = str(error) return sample