This commit is contained in:
1 parent
646ba3be81
commit
aae5b73581
14 files changed
+883
-142
No files matched your search
+5
-3
@@ -43,7 +43,8 @@ def _run(config, store):
|
||||
stop = threading.Event()
|
||||
previous = {}
|
||||
uploader = Uploader(config.upload, store) if config.upload.url else None
|
||||
recovery = HistoryRecovery(config.history, config.collector.source_id, store) if config.history.enabled else None
|
||||
recovery = HistoryRecovery(config.history, config.collector.source_id, store,
|
||||
timezone_name=config.collector.timezone) if config.history.enabled else None
|
||||
worker = None
|
||||
history_worker = None
|
||||
status_server = None
|
||||
@@ -137,8 +138,9 @@ def main(argv=None):
|
||||
print(json.dumps({"deleted": store.prune()}))
|
||||
elif args.command == "recover":
|
||||
if not config.history.enabled:
|
||||
raise ValueError("Enable history and configure Solar.web API credentials before recovery")
|
||||
recovery = HistoryRecovery(config.history, config.collector.source_id, store)
|
||||
raise ValueError("Enable history and configure Solar.web account credentials before recovery")
|
||||
recovery = HistoryRecovery(config.history, config.collector.source_id, store,
|
||||
timezone_name=config.collector.timezone)
|
||||
print(json.dumps({"recovered": recovery.recover(start, until)}))
|
||||
elif args.command == "export":
|
||||
for sample in store.records(start, until):
|
||||
|
||||
+25
-12
@@ -1,6 +1,6 @@
|
||||
"""Configuration contains environment variable names, never upload credentials."""
|
||||
"""Private runtime configuration; credential fields are excluded from reprs."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
import math
|
||||
from pathlib import Path
|
||||
@@ -9,6 +9,8 @@ import tomllib
|
||||
from urllib.parse import urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
LEGACY_HISTORY_SETTINGS = {"access_key_id_env", "access_key_value_env", "lookback_days"}
|
||||
|
||||
|
||||
def utc_text(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
@@ -52,6 +54,7 @@ class UploadConfig:
|
||||
request_timeout_seconds: float = 10
|
||||
retry_initial_seconds: float = 5
|
||||
retry_max_seconds: float = 300
|
||||
token: str = field(default="", repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -67,12 +70,13 @@ class HttpConfig:
|
||||
class HistoryConfig:
|
||||
enabled: bool = False
|
||||
pv_system_id: str = ""
|
||||
access_key_id_env: str = "SOLARWEB_ACCESS_KEY_ID"
|
||||
access_key_value_env: str = "SOLARWEB_ACCESS_KEY_VALUE"
|
||||
lookback_days: int = 7
|
||||
username_env: str = "SOLARWEB_USERNAME"
|
||||
password_env: str = "SOLARWEB_PASSWORD"
|
||||
lag_seconds: int = 7200
|
||||
check_interval_seconds: int = 3600
|
||||
request_timeout_seconds: float = 10
|
||||
request_timeout_seconds: float = 45
|
||||
username: str = field(default="", repr=False)
|
||||
password: str = field(default="", repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -118,7 +122,8 @@ def load_config(path: Path) -> Config:
|
||||
"inverter": set(InverterConfig.__dataclass_fields__),
|
||||
"upload": set(UploadConfig.__dataclass_fields__),
|
||||
"http": set(HttpConfig.__dataclass_fields__),
|
||||
"history": set(HistoryConfig.__dataclass_fields__),
|
||||
# Keep existing private configurations valid while using the full retention window.
|
||||
"history": set(HistoryConfig.__dataclass_fields__) | LEGACY_HISTORY_SETTINGS,
|
||||
}.items():
|
||||
if not isinstance(data.get(section, {}), dict):
|
||||
raise ValueError(f"{section} must be a table")
|
||||
@@ -157,6 +162,10 @@ def load_config(path: Path) -> Config:
|
||||
raise ValueError("upload.url must be a string")
|
||||
if upload.url:
|
||||
_url(upload.url, "upload.url", upload=True)
|
||||
if not isinstance(upload.token, str):
|
||||
raise ValueError("upload.token must be a string")
|
||||
if upload.token and any(ord(c) < 33 or ord(c) > 126 for c in upload.token):
|
||||
raise ValueError("upload.token must be a bearer token without whitespace")
|
||||
if not isinstance(upload.token_env, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", upload.token_env):
|
||||
raise ValueError("upload.token_env must be an environment variable name")
|
||||
if isinstance(upload.batch_size, bool) or not isinstance(upload.batch_size, int) or not 1 <= upload.batch_size <= 500:
|
||||
@@ -175,21 +184,25 @@ def load_config(path: Path) -> Config:
|
||||
raise ValueError("http.base_path must be empty or a URL path without a trailing slash")
|
||||
if isinstance(http.history_days, bool) or not isinstance(http.history_days, int) or not 1 <= http.history_days <= 90:
|
||||
raise ValueError("http.history_days must be an integer between 1 and 90")
|
||||
history = HistoryConfig(**data.get("history", {}))
|
||||
history = HistoryConfig(**{key: value for key, value in data.get("history", {}).items()
|
||||
if key not in LEGACY_HISTORY_SETTINGS})
|
||||
if not isinstance(history.enabled, bool):
|
||||
raise ValueError("history.enabled must be true or false")
|
||||
if not isinstance(history.pv_system_id, str) or (history.pv_system_id and not re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", history.pv_system_id)):
|
||||
raise ValueError("history.pv_system_id must be a UUID")
|
||||
if history.enabled and not history.pv_system_id:
|
||||
raise ValueError("Set history.pv_system_id before enabling Solar.web recovery")
|
||||
for name in ("access_key_id_env", "access_key_value_env"):
|
||||
for name in ("username", "password"):
|
||||
if not isinstance(getattr(history, name), str):
|
||||
raise ValueError(f"history.{name} must be a string")
|
||||
if (history.username or history.password) and (not history.username.strip() or not history.password):
|
||||
raise ValueError("Set both history.username and history.password, or leave both empty to use environment variables")
|
||||
for name in ("username_env", "password_env"):
|
||||
if not isinstance(getattr(history, name), str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", getattr(history, name)):
|
||||
raise ValueError(f"history.{name} must name an environment variable")
|
||||
for name, minimum in [("lookback_days", 1), ("lag_seconds", 3600), ("check_interval_seconds", 3600)]:
|
||||
for name, minimum in [("lag_seconds", 3600), ("check_interval_seconds", 3600)]:
|
||||
value = getattr(history, name)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||
raise ValueError(f"history.{name} must be an integer of at least {minimum}")
|
||||
if history.lookback_days > 30 or (history.enabled and history.lookback_days > retention):
|
||||
raise ValueError("history.lookback_days must be at most 30 and no longer than retention_days when enabled")
|
||||
_number(history.request_timeout_seconds, "history.request_timeout_seconds", 0.1)
|
||||
return Config(collector, inverter, upload, http, history)
|
||||
@@ -25,7 +25,7 @@ def _finite(value):
|
||||
def sample_coverage(sample):
|
||||
observed = milliseconds(sample["observed_at"])
|
||||
if "history_interval" in sample:
|
||||
# Cloud history has measured interval energy, but no cumulative meter or relay history.
|
||||
# Cloud history has interval energy, but no cumulative meter or relay history.
|
||||
return (sample["id"], sample["source_id"], observed, observed, 1, observed, 0, observed, 0)
|
||||
readings = sample.get("readings", {})
|
||||
result = [sample["id"], sample["source_id"], observed]
|
||||
|
||||
+61
-47
@@ -1,19 +1,20 @@
|
||||
"""Optional private Solar.web Query API recovery of missing five-minute energy.
|
||||
"""Optional signed-in Solar.web chart 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.
|
||||
GEN24's local Solar API exposes no archive. Use the same private account login,
|
||||
chart series and five-minute W-to-Wh conversion as the sibling solar website.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from urllib.parse import urlencode
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .config import utc_text
|
||||
from .coverage import milliseconds
|
||||
from .http import JsonHttp, TransportError
|
||||
from .http import TransportError
|
||||
from .solarweb import ChartDataError, SolarWebClient
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
CHANNELS = {
|
||||
@@ -40,6 +41,7 @@ def history_sample(entry, source_id, system_id):
|
||||
when = utc_text(datetime.fromtimestamp(stamp / 1000, timezone.utc))
|
||||
return {
|
||||
"schema_version": 1,
|
||||
# Preserve IDs of intervals already recovered with the former Query API.
|
||||
"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([
|
||||
@@ -54,68 +56,80 @@ def history_sample(entry, source_id, system_id):
|
||||
}
|
||||
|
||||
|
||||
def chart_entries(production, consumption):
|
||||
"""Pair real chart samples; assume constant W over five minutes, as solar does."""
|
||||
for stamp in sorted(production.keys() & consumption.keys()):
|
||||
generated, consumed = production[stamp], consumption[stamp]
|
||||
values = {"production": generated / 12, "consumption": consumed / 12,
|
||||
"self_consumption": min(generated, consumed) / 12,
|
||||
"bought": max(0, consumed - generated) / 12, "sold": max(0, generated - consumed) / 12}
|
||||
yield {"logDateTime": utc_text(datetime.fromtimestamp(stamp / 1000, timezone.utc)), "logDuration": 300,
|
||||
"channels": [{"channelName": name, "channelType": "Energy", "unit": "Wh", "value": values[field]}
|
||||
for field, name in CHANNELS.items()],
|
||||
"chart_power_w": {"production": generated, "consumption": consumed},
|
||||
"energy_method": "constant_power_over_five_minutes"}
|
||||
|
||||
|
||||
class HistoryRecovery:
|
||||
def __init__(self, config, source_id, store, *, http=None, credentials=None):
|
||||
def __init__(self, config, source_id, store, *, timezone_name="Europe/Zurich", client=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)
|
||||
self.zone = ZoneInfo(timezone_name)
|
||||
if client is None:
|
||||
names = (config.username_env, config.password_env)
|
||||
if credentials is not None:
|
||||
values = credentials
|
||||
elif config.username or config.password:
|
||||
values = (config.username, config.password)
|
||||
else:
|
||||
values = [os.environ.get(name, "") for name in names]
|
||||
if (len(values) != 2 or any(not isinstance(value, str) or not value for value in values)
|
||||
or not values[0].strip()):
|
||||
raise ValueError("Set history.username and history.password in config.toml, "
|
||||
f"or {names[0]} and {names[1]} before enabling Solar.web recovery")
|
||||
client = SolarWebClient(*values, timeout=config.request_timeout_seconds)
|
||||
self.client = client
|
||||
|
||||
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)
|
||||
# Check the whole retained window, including days before any live observation.
|
||||
# Round up so recovery never requests an interval older than the cutoff.
|
||||
earliest = math.ceil((now - timedelta(days=self.store.retention_days)).timestamp() / 300) * 300
|
||||
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:
|
||||
if (start is not None or until is not None) and 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})
|
||||
LOG.info("Checking %d historical energy intervals; %d are missing", (upper - lower) // 300, len(missing))
|
||||
# Chart dates use the installation's local timezone, including 23/25-hour DST days.
|
||||
days = sorted({datetime.fromtimestamp(epoch, self.zone).date() 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:
|
||||
LOG.info("Recovering Solar.web energy history for %s", day)
|
||||
try:
|
||||
production = self.client.get_chart(self.config.pv_system_id, "production", day)
|
||||
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")
|
||||
consumption = self.client.get_chart(self.config.pv_system_id, "consumption", day)
|
||||
except ChartDataError:
|
||||
# A day with no usable cloud data must not block recovery of newer gaps.
|
||||
LOG.warning("Solar.web chart data unavailable for %s; leaving its gaps for the next check", day)
|
||||
continue
|
||||
for entry in chart_entries(production, consumption):
|
||||
epoch = milliseconds(entry["logDateTime"]) // 1000
|
||||
if epoch not in missing or datetime.fromtimestamp(epoch, self.zone).date() != day:
|
||||
continue
|
||||
sample = history_sample(entry, self.source_id, self.config.pv_system_id)
|
||||
self.store.add(sample)
|
||||
missing.remove(epoch)
|
||||
recovered += 1
|
||||
LOG.info("History check finished: %d intervals recovered, %d still missing", recovered, len(missing))
|
||||
return recovered
|
||||
|
||||
def run(self, stop):
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Private, cookie-based Solar.web chart access, matching solar's SolarWebClient."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from html.parser import HTMLParser
|
||||
from http.client import HTTPException
|
||||
from http.cookiejar import CookieJar
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import unquote, urlencode, urljoin, urlsplit
|
||||
from urllib.request import HTTPCookieProcessor, Request, build_opener
|
||||
|
||||
from .http import NoRedirects, TransportError, _invalid_constant
|
||||
|
||||
API_SERVER = "https://www.solarweb.com"
|
||||
LOGIN_SERVER = "https://login.fronius.com"
|
||||
LOGIN_ERROR = ("Solar.web login failed. Check the configured username and password. "
|
||||
"MFA, CAPTCHA or consent may require an interactive login.")
|
||||
DATASETS = {"production": ("consumption", "FromGen"), "consumption": ("production", "ToConsumer")}
|
||||
|
||||
|
||||
class ChartDataError(TransportError):
|
||||
"""A chart day is unavailable or cannot be safely decoded; login errors are separate."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Response:
|
||||
status: int
|
||||
url: str = field(repr=False)
|
||||
body: bytes = field(default=b"", repr=False)
|
||||
location: str = field(default="", repr=False)
|
||||
|
||||
|
||||
class Forms(HTMLParser):
|
||||
def __init__(self, body):
|
||||
super().__init__()
|
||||
self.forms, self.current = [], None
|
||||
try:
|
||||
self.feed(body.decode("utf-8"))
|
||||
self.close()
|
||||
except (UnicodeError, ValueError):
|
||||
raise TransportError("Solar.web did not return a valid sign-in page") from None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
if tag == "form":
|
||||
self.current = {"action": attrs.get("action") or "", "method": (attrs.get("method") or "get").lower(),
|
||||
"fields": {}, "password": False, "user_input": False, "visible": False, "captcha": False}
|
||||
self.forms.append(self.current)
|
||||
form = self.current
|
||||
if form is None:
|
||||
return
|
||||
if "data-sitekey" in attrs or "frc-captcha" in (attrs.get("class") or ""):
|
||||
form["captcha"] = True
|
||||
if tag != "input" or "disabled" in attrs:
|
||||
return
|
||||
name, kind = attrs.get("name"), (attrs.get("type") or "text").lower()
|
||||
form["password"] |= kind == "password" and name == "password"
|
||||
form["user_input"] |= name == "usernameUserInput"
|
||||
form["visible"] |= kind not in ("hidden", "submit", "button")
|
||||
if kind == "hidden" and name:
|
||||
form["fields"][name] = attrs.get("value") or ""
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "form":
|
||||
self.current = None
|
||||
|
||||
|
||||
def origin(url):
|
||||
parsed = urlsplit(url)
|
||||
return f"{parsed.scheme}://{parsed.hostname}"
|
||||
|
||||
|
||||
def validate_url(url):
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
valid = (parsed.scheme == "https" and origin(url) in (API_SERVER, LOGIN_SERVER)
|
||||
and parsed.username is None and parsed.password is None and parsed.port in (None, 443)
|
||||
and not any(ord(c) <= 32 or ord(c) == 127 or c == "\\" for c in url)
|
||||
and not re.search(r"GuestLogOn|DemoLogOn", unquote(parsed.path), re.I))
|
||||
except ValueError:
|
||||
valid = False
|
||||
if not valid:
|
||||
raise TransportError("Solar.web attempted an untrusted URL or guest access. Request blocked.")
|
||||
|
||||
|
||||
class SolarWebClient:
|
||||
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
def __init__(self, username, password, timeout=45):
|
||||
if not isinstance(username, str) or not username.strip() or not isinstance(password, str) or not password:
|
||||
raise ValueError("Set the Solar.web account username and password before recovery")
|
||||
self._username, self._password = username.strip(), password
|
||||
self.timeout = timeout
|
||||
self.cookies = CookieJar() # In memory only; sessions are never written to disk.
|
||||
self.opener = build_opener(NoRedirects(), HTTPCookieProcessor(self.cookies))
|
||||
self.authenticated = False
|
||||
self.chart_systems = set()
|
||||
|
||||
def _send(self, url, form, referer, as_json):
|
||||
headers = {"Accept": "application/json" if as_json else "text/html",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36"}
|
||||
if as_json:
|
||||
headers["X-Requested-With"] = "XMLHttpRequest"
|
||||
if referer:
|
||||
# SSO query parameters must never appear in a cross-origin Referer.
|
||||
headers["Referer"] = origin(referer) + "/"
|
||||
body = None
|
||||
if form is not None:
|
||||
body = urlencode(form).encode("utf-8")
|
||||
headers.update({"Content-Type": "application/x-www-form-urlencoded", "Origin": origin(referer)})
|
||||
request = Request(url, data=body, headers=headers)
|
||||
try:
|
||||
try:
|
||||
response = self.opener.open(request, timeout=self.timeout)
|
||||
except HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
raw = response.read(self.MAX_RESPONSE_BYTES + 1)
|
||||
result = Response(response.code, url, raw, response.headers.get("Location", ""))
|
||||
except (URLError, OSError, HTTPException, ValueError) as error:
|
||||
raise TransportError(f"Solar.web network request failed ({type(error).__name__})") from None
|
||||
if len(raw) > self.MAX_RESPONSE_BYTES:
|
||||
raise TransportError("Solar.web response exceeds 8 MiB")
|
||||
return result
|
||||
|
||||
def _request(self, url, form=None, referer="", as_json=False):
|
||||
for _ in range(12):
|
||||
validate_url(url)
|
||||
response = self._send(url, form, referer, as_json)
|
||||
if response.status not in (301, 302, 303, 307, 308):
|
||||
return response
|
||||
if not response.location:
|
||||
raise TransportError("Solar.web returned a redirect without a destination")
|
||||
if form is not None and response.status in (307, 308):
|
||||
raise TransportError("Solar.web attempted to redirect a sensitive POST. Request blocked.")
|
||||
referer, url = url, urljoin(url, response.location)
|
||||
form = None
|
||||
raise TransportError("Solar.web exceeded the redirect limit")
|
||||
|
||||
@staticmethod
|
||||
def _signed_out(response):
|
||||
path = urlsplit(response.url).path.lower()
|
||||
return (response.status in (401, 403) or origin(response.url) != API_SERVER
|
||||
or path.startswith("/account/externallogin")
|
||||
or bool(re.search(rb'''(?:type\s*=\s*["']password["']|href\s*=\s*["']/Account/ExternalLogin(?:[?"']))''',
|
||||
response.body, re.I)))
|
||||
|
||||
@staticmethod
|
||||
def _check_status(response):
|
||||
if response.status != 200:
|
||||
raise TransportError(f"Solar.web returned HTTP {response.status}. No response content was logged.")
|
||||
|
||||
def _login(self):
|
||||
response = self._request(API_SERVER + "/Account/ExternalLogin")
|
||||
submitted = callback = False
|
||||
for _ in range(6):
|
||||
self._check_status(response)
|
||||
if callback and not self._signed_out(response):
|
||||
self.authenticated = True
|
||||
return
|
||||
next_form = None
|
||||
for form in Forms(response.body).forms:
|
||||
if form["password"]:
|
||||
if submitted or form["captcha"]:
|
||||
raise TransportError(LOGIN_ERROR)
|
||||
action = urljoin(response.url, form["action"])
|
||||
validate_url(action)
|
||||
if (origin(response.url) != LOGIN_SERVER or origin(action) != LOGIN_SERVER
|
||||
or urlsplit(action).path != "/commonauth" or form["method"] != "post"
|
||||
or not form["fields"].get("sessionDataKey")):
|
||||
raise TransportError("Solar.web sign-in form changed; credentials were not submitted.")
|
||||
fields = dict(form["fields"], username=self._username, password=self._password)
|
||||
if form["user_input"]:
|
||||
fields["usernameUserInput"] = self._username
|
||||
next_form = action, fields
|
||||
submitted = True
|
||||
break
|
||||
if (submitted and not form["visible"] and "state" in form["fields"]
|
||||
and ("id_token" in form["fields"] or "code" in form["fields"])):
|
||||
action = urljoin(response.url, form["action"])
|
||||
validate_url(action)
|
||||
if (origin(response.url) != LOGIN_SERVER or origin(action) != API_SERVER
|
||||
or urlsplit(action).path != "/Account/ExternalLoginCallback" or form["method"] != "post"):
|
||||
raise TransportError("Solar.web sign-in callback changed; authentication was stopped.")
|
||||
next_form = action, form["fields"]
|
||||
callback = True
|
||||
break
|
||||
if next_form is None:
|
||||
raise TransportError(LOGIN_ERROR)
|
||||
response = self._request(*next_form, referer=response.url)
|
||||
raise TransportError(LOGIN_ERROR)
|
||||
|
||||
def _reset(self):
|
||||
self.authenticated = False
|
||||
self.chart_systems.clear()
|
||||
self.cookies.clear()
|
||||
|
||||
def get_chart(self, system_id, dataset, date):
|
||||
view, series_id = DATASETS[dataset]
|
||||
chart_url = API_SERVER + "/Chart/Chart?" + urlencode({"pvSystemId": system_id})
|
||||
query = urlencode({"pvSystemId": system_id, "year": date.year, "month": date.month,
|
||||
"day": date.day, "interval": "day", "view": view})
|
||||
for _ in range(2): # Reauthenticate an expired session once; never retry rejected credentials here.
|
||||
if not self.authenticated:
|
||||
self._login()
|
||||
if system_id not in self.chart_systems:
|
||||
page = self._request(chart_url)
|
||||
if self._signed_out(page):
|
||||
self._reset()
|
||||
continue
|
||||
self._check_status(page)
|
||||
self.chart_systems.add(system_id)
|
||||
response = self._request(API_SERVER + "/Chart/GetChartNew?" + query, referer=chart_url, as_json=True)
|
||||
if self._signed_out(response):
|
||||
self._reset()
|
||||
continue
|
||||
self._check_status(response)
|
||||
return decode_chart(response.body, series_id)
|
||||
raise TransportError("Solar.web session expired or access to this PV system was denied. No guest access was attempted.")
|
||||
|
||||
|
||||
def decode_chart(body, series_id):
|
||||
try:
|
||||
data = json.loads(body, parse_constant=_invalid_constant)
|
||||
series = data["settings"]["series"]
|
||||
if not isinstance(series, list):
|
||||
raise ValueError
|
||||
matches = [item for item in series if isinstance(item, dict) and item.get("id") == series_id]
|
||||
if len(matches) != 1:
|
||||
raise ValueError
|
||||
item = matches[0]
|
||||
if item["tooltip"]["valueSuffix"].strip() != "W" or not isinstance(item["data"], list):
|
||||
raise ValueError
|
||||
points, seen = {}, set()
|
||||
for point in item["data"]:
|
||||
if not isinstance(point, list) or len(point) != 2:
|
||||
raise ValueError
|
||||
stamp, watts = point
|
||||
if (isinstance(stamp, bool) or not isinstance(stamp, (int, float))
|
||||
or not 0 <= stamp <= 253402300799000 or stamp % 300_000 or stamp in seen):
|
||||
raise ValueError
|
||||
seen.add(stamp)
|
||||
if watts is None:
|
||||
continue
|
||||
if (isinstance(watts, bool) or not isinstance(watts, (int, float))
|
||||
or not 0 <= watts <= 1e18 or not math.isfinite(watts)):
|
||||
raise ValueError
|
||||
points[int(stamp)] = watts
|
||||
return points
|
||||
except (KeyError, TypeError, ValueError, AttributeError, UnicodeError, RecursionError):
|
||||
raise ChartDataError("Solar.web chart series, samples or units changed. Import stopped; no response content was logged.") from None
|
||||
@@ -121,7 +121,7 @@ class StatusServer:
|
||||
"poll_interval_seconds": self.config.collector.poll_interval_seconds},
|
||||
"archive": {"samples": archive["samples"], "pending": archive["pending"], "last_upload": archive["last_upload"],
|
||||
"upload_enabled": bool(self.config.upload.url), "format": "parquet", "retention_days": self.config.collector.retention_days},
|
||||
"history": {"enabled": self.config.history.enabled, "provider": "solarweb_query_api",
|
||||
"history": {"enabled": self.config.history.enabled, "provider": "solarweb_account_charts",
|
||||
"boiler_history_available": False},
|
||||
"days": self.store.coverage(self.config.collector.source_id, self.config.collector.timezone, days, now),
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ class Uploader:
|
||||
raise ValueError("Set upload.url to the website's ingestion endpoint first")
|
||||
self.config = config
|
||||
self.store = store
|
||||
self.token = token if token is not None else os.environ.get(config.token_env, "")
|
||||
if not self.token or any(ord(c) < 33 or ord(c) > 126 for c in self.token):
|
||||
raise ValueError(f"Set {config.token_env} to a nonempty bearer token without whitespace")
|
||||
self.token = token if token is not None else config.token or os.environ.get(config.token_env, "")
|
||||
if not isinstance(self.token, str) or not self.token or any(ord(c) < 33 or ord(c) > 126 for c in self.token):
|
||||
raise ValueError(f"Set upload.token in config.toml or {config.token_env} to a nonempty bearer token without whitespace")
|
||||
self.http = http or JsonHttp(config.request_timeout_seconds)
|
||||
|
||||
def upload_once(self) -> int:
|
||||
|
||||
Reference in new issue
Block a user