1 parent
36180dffd7
commit
ec75ac15b0
30 files changed
+3042
-1
No files matched your search
@@ -0,0 +1,129 @@
|
||||
"""Optional private Solar.web Query API recovery of missing five-minute energy.
|
||||
|
||||
GEN24's local Solar API exposes no archive. This reads the independent cloud API
|
||||
after the inverter has uploaded its internal backlog; it never requests a guest page.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from urllib.parse import urlencode
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from .config import utc_text
|
||||
from .coverage import milliseconds
|
||||
from .http import JsonHttp, TransportError
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
CHANNELS = {
|
||||
"production": "EnergyProductionTotal", "consumption": "EnergyConsumptionTotal",
|
||||
"self_consumption": "EnergySelfConsumptionTotal", "bought": "EnergyPurchased", "sold": "EnergyFeedIn",
|
||||
}
|
||||
|
||||
|
||||
def history_sample(entry, source_id, system_id):
|
||||
stamp = milliseconds(entry["logDateTime"])
|
||||
if stamp % 300_000 or entry.get("logDuration") != 300:
|
||||
raise ValueError("Historical record is not an aligned five-minute interval")
|
||||
channels = {}
|
||||
for channel in entry["channels"]:
|
||||
name, value = channel.get("channelName"), channel.get("value")
|
||||
if name not in CHANNELS.values():
|
||||
continue
|
||||
if name in channels or channel.get("unit") != "Wh" or channel.get("channelType") != "Energy":
|
||||
raise ValueError("Unexpected historical channel metadata")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not 0 <= value <= 1e18:
|
||||
raise ValueError("Historical energy is missing or invalid")
|
||||
channels[name] = value
|
||||
energy = {field: channels[name] for field, name in CHANNELS.items()}
|
||||
when = utc_text(datetime.fromtimestamp(stamp / 1000, timezone.utc))
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"id": str(uuid5(NAMESPACE_URL, f"https://swqapi.solarweb.com/{system_id}/{source_id}/{when}")),
|
||||
"source_id": source_id, "observed_at": when,
|
||||
"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": {"meter": "Cumulative meter readings are unavailable in recovered energy intervals",
|
||||
"boiler": "Historical boiler switch state is unavailable"},
|
||||
"raw": {"solarweb_history": entry},
|
||||
"history_interval": {"provider": "fronius-solarweb", "start": when, "duration_seconds": 300, "energy_wh": energy},
|
||||
}
|
||||
|
||||
|
||||
class HistoryRecovery:
|
||||
def __init__(self, config, source_id, store, *, http=None, credentials=None):
|
||||
self.config, self.source_id, self.store = config, source_id, store
|
||||
names = (config.access_key_id_env, config.access_key_value_env)
|
||||
values = credentials if credentials is not None else [os.environ.get(name, "") for name in names]
|
||||
if len(values) != 2 or any(not isinstance(value, str) or not value or any(ord(c) < 33 or ord(c) > 126 for c in value) for value in values):
|
||||
raise ValueError(f"Set {names[0]} and {names[1]} before enabling Solar.web recovery")
|
||||
self.headers = dict(zip(("AccessKeyId", "AccessKeyValue"), values))
|
||||
self.http = http or JsonHttp(config.request_timeout_seconds)
|
||||
|
||||
def recover(self, start=None, until=None, *, now=None, stop=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
latest = int((now - timedelta(seconds=self.config.lag_seconds)).timestamp()) // 300 * 300
|
||||
first = self.store.status()["first_observation"]
|
||||
earliest = max(int((now - timedelta(days=self.config.lookback_days)).timestamp()) // 300 * 300,
|
||||
milliseconds(first) // 300_000 * 300 if first else latest)
|
||||
lower = milliseconds(start) // 300_000 * 300 if start else earliest
|
||||
upper = min(milliseconds(until) // 300_000 * 300, latest) if until else latest
|
||||
if lower >= upper:
|
||||
return 0
|
||||
if upper - lower > 30 * 86400:
|
||||
raise ValueError("Recover at most 30 days at once")
|
||||
stamp = lambda epoch: utc_text(datetime.fromtimestamp(epoch, timezone.utc))
|
||||
present = self.store.energy_slots(self.source_id, stamp(lower), stamp(upper))
|
||||
missing = set(range(lower, upper, 300)) - present
|
||||
# Query only UTC days containing gaps. No request spans the API's 24-hour limit.
|
||||
days = sorted({epoch // 86400 * 86400 for epoch in missing})
|
||||
recovered = 0
|
||||
for day in days:
|
||||
if stop and stop.is_set():
|
||||
break
|
||||
begin, end = max(day, lower), min(day + 86400, upper)
|
||||
page = 0
|
||||
while True:
|
||||
if stop and stop.is_set():
|
||||
break
|
||||
query = urlencode({"from": stamp(begin), "to": stamp(end), "timezone": "zulu",
|
||||
"channel": ",".join(CHANNELS.values()), "offset": page * 300, "limit": 300})
|
||||
url = f"https://swqapi.solarweb.com/pvsystems/{self.config.pv_system_id}/histdata?{query}"
|
||||
response = self.http.request(url, extra_headers=self.headers)
|
||||
system_id = response.get("pvSystemId")
|
||||
if not isinstance(system_id, str) or system_id.lower() != self.config.pv_system_id.lower() or not isinstance(response.get("data"), list):
|
||||
raise ValueError("Unexpected Solar.web history response")
|
||||
entries = response["data"]
|
||||
if len(entries) > 300:
|
||||
raise ValueError("Solar.web history response exceeds the requested page size")
|
||||
for entry in entries:
|
||||
try:
|
||||
sample = history_sample(entry, self.source_id, self.config.pv_system_id)
|
||||
epoch = milliseconds(sample["observed_at"]) // 1000
|
||||
except (KeyError, TypeError, ValueError, AttributeError):
|
||||
# Partial/unavailable cloud records remain missing and are tried again later.
|
||||
continue
|
||||
if begin <= epoch < end and epoch in missing:
|
||||
self.store.add(sample)
|
||||
missing.remove(epoch)
|
||||
recovered += 1
|
||||
if len(entries) < 300:
|
||||
break
|
||||
page += 1
|
||||
if page > 2:
|
||||
raise ValueError("Solar.web history pagination did not finish")
|
||||
return recovered
|
||||
|
||||
def run(self, stop):
|
||||
while not stop.is_set():
|
||||
try:
|
||||
recovered = self.recover(stop=stop)
|
||||
if recovered:
|
||||
LOG.info("Recovered %d historical energy intervals from Solar.web", recovered)
|
||||
except (TransportError, OSError, ValueError) as error:
|
||||
LOG.warning("Historical recovery failed (%s); retrying on the next scheduled check", type(error).__name__)
|
||||
stop.wait(self.config.check_interval_seconds)
|
||||
Reference in new issue
Block a user