255 lines
12 KiB
Python
255 lines
12 KiB
Python
"""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
|