This commit is contained in:
1 parent
646ba3be81
commit
aae5b73581
14 files changed
+883
-142
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user