Files
solar-relay/solar_relay/solarweb.py
T
franzz aae5b73581
Deploy Solar Relay / deploy (push) Successful in 56s
Swap to private solarweb access
2026-11-02 18:06:01 +01:00

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