Files
franzz ec75ac15b0
Deploy Solar Relay / deploy (push) Failing after 4s
Initial commit
2026-11-01 22:33:32 +01:00

155 lines
6.5 KiB
Python

"""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