1 parent
36180dffd7
commit
ec75ac15b0
30 files changed
+3042
-1
No files matched your search
@@ -0,0 +1,154 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user