155 lines
6.5 KiB
Python
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
|