"""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