Swap to private solarweb access
Deploy Solar Relay / deploy (push) Successful in 56s

This commit is contained in:
franzz committed 2026-11-02 18:06:01 +01:00
1 parent 646ba3be81
commit aae5b73581
14 files changed
+879 -138

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Private configuration
`config.toml` is private and must remain secret from coding agents.
- Never open, read, search, copy, print, diff, or otherwise inspect that file's contents with agent tools or helper scripts.
- Exclude it explicitly from repository searches, even when searching ignored files.
- The application may load its private configuration internally during user-authorized runs; this does not authorize displaying or inspecting the configuration or credentials. Use isolated tests with synthetic configuration and credentials for unit verification.
- Make configuration changes only in `config.example.toml`; the user maintains the private file.
- Do not inspect or log credentials, runtime session cookies, or authentication tokens. The user permits inspecting API/chart response data for debugging.
+102 -35
View File
@@ -28,10 +28,10 @@ observations never expire, even if their measurement dates are much older.
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --only-binary=pyarrow -r requirements.txt
python3 -m pip install --only-binary=pyarrow -r requirements.txt
cp config.example.toml config.toml
python -m solar_relay --config config.toml status
python -m solar_relay --config config.toml run
python3 -m solar_relay --config config.toml status
python3 -m solar_relay --config config.toml run
```
Use `.venv/bin/python` directly if you prefer not to activate the environment.
@@ -96,7 +96,8 @@ uploader, status request or CLI replay from overwriting another operation. HTTP
requests happen outside the archive lock.
The upload request is a JSON object with `schema_version: 1` and a `samples` array,
authenticated with the bearer token named by `upload.token_env`. The website must
authenticated with `upload.token` from private `config.toml`, or the environment
variable named by `upload.token_env` when that setting is empty. The website must
commit its data before responding:
```json
@@ -130,6 +131,11 @@ stop the service and other CLI writers before copying the archive directory as a
## Recovering history after Wi-Fi loss
The default mode always collects live data from the inverter's local Fronius API.
The optional backup (`[history].enabled = true`) fills historical energy gaps from
Solar.web in a separate worker. It does not replace or change live collection;
when there are no eligible gaps, it makes no Solar.web requests.
GEN24 records historical data for transfer to Solar.web after reconnecting. This is
separate from its local Solar API: Fronius explicitly lists archive access for GEN24
as unavailable, and this inverter's `GetArchiveData.cgi` returns 404. Inspecting its
@@ -137,36 +143,88 @@ current web interface also found no energy-history retrieval endpoint. Its SDP s
download is an encrypted `.cry` diagnostic file, not a documented energy-history API.
See the official [Solar API manual, section 5.1.1](https://www.fronius.com/~/downloads/Solar%20Energy/Operating%20Instructions/42%2C0410%2C2012.pdf)
and [Solar.web Query API manual, section 6.5](https://www.fronius.com/~/downloads/Solar%20Energy/User%20Information/SE_UI_API_InterfaceDocumentation_EN.pdf).
for the local API's archive limitations.
Optional recovery reads missing five-minute **measured energy** through the separate,
authenticated Solar.web Query API, after the inverter uploads its backlog. This uses
no public guest page. Obtain Query API access and keys from Fronius; its commercial
terms are separate from normal Solar.web access. No cloud calls occur while disabled.
Optional recovery signs in to Solar.web with a real Fronius account and reads its
private daily charts after the inverter uploads its backlog, using the same login
flow and chart series as the sibling `solar` website. No cloud calls occur while
disabled. A public guest link and Query API subscription/keys are not needed.
Set `[history].enabled = true` and `pv_system_id` in the configuration, and put these
in the service's root-readable `/etc/solar-relay/solar-relay.env`:
Create a dedicated Solar.web / Fronius account and grant it permission to view this
installation's metrics. Keep public sharing disabled. The account can have guest
permissions on the installation, but it signs in privately through the normal account
login; the deactivated public guest interface is never used.
```text
SOLARWEB_ACCESS_KEY_ID=your-access-key-id
SOLARWEB_ACCESS_KEY_VALUE=your-access-key-value
Set the backup account credentials directly in your private `config.toml`:
```toml
[history]
enabled = true
pv_system_id = "your-pv-system-uuid"
username = "your-account-email"
password = "your-account-password"
```
The recovery worker checks hourly, looking back seven days by default and allowing
a two-hour delay for the inverter's hourly uploads. It fetches only UTC days with
missing energy extractions, requests five energy channels, limits each request to
24 hours, and skips intervals already present. Requests use HTTPS, headers for
credentials, timeouts and no redirects. Missing/partial cloud data stays missing and
is tried again later. The configured lookback cannot exceed the retention period.
No extra environment file is needed for this login. Set both `username` and `password`;
an incomplete pair is rejected. Direct credentials take precedence over the environment.
Use TOML literal strings (`'...'`) if a password contains backslashes or double quotes.
Alternatively, leave both credentials empty and set the environment variables named
by `username_env` and `password_env`. For the systemd service, these can go in its
root-readable `/etc/solar-relay/solar-relay.env`:
```text
SOLARWEB_USERNAME=your-account-email
SOLARWEB_PASSWORD=your-account-password
```
While live collection runs, the separate recovery worker checks at startup and hourly
by default. It checks every five-minute energy interval back to
`collector.retention_days` (30 days by default), including entirely empty days and
dates before the first local observation. A two-hour delay allows the inverter to
upload its recent history. Only local dates containing gaps are fetched, and existing
live or recovered energy is preserved. When all eligible intervals are present, no
Solar.web requests are made. Unavailable chart days remain missing for retry and do
not prevent other dates from being recovered. Login and network failures stop that
attempt and retry on the next scheduled check.
Set `collector.timezone` to the installation's Solar.web timezone; local chart dates
include 23/25-hour DST days. The old `history.lookback_days` setting is accepted but
ignored; `collector.retention_days` controls the automatic recovery window.
Recovery pairs the `FromGen` Production series from the consumption view with the
`ToConsumer` Consumption series from the production view, regardless of the display
language. As in `solar`, each five-minute W value is treated as constant over that
interval and divided by 12 to obtain Wh. Self-consumption is the smaller of production
and consumption; purchase/export are their nonnegative differences. These are chart
estimates, rather than the former Query API's measured Wh channels. The original
paired power values and conversion method are retained in each recovered record.
Null or unpaired readings stay missing and are tried again later.
Login uses HTTPS and in-memory cookies. Credentials are submitted only to the Fronius
password endpoint; SSO callbacks and redirects are restricted to the Solar.web and
Fronius login hosts. Credentials are excluded from configuration reprs and never
logged. Session cookies and authentication responses are never logged or written to
disk. An expired session is renewed once. Rejected credentials,
MFA, CAPTCHA, consent, or a changed sign-in form stop that attempt with a redacted
error. Complete any required interactive account setup in a browser.
When migrating from Query API recovery, replace `access_key_id_env` and
`access_key_value_env` with `username` and `password` in `[history]`, or use the
environment fallback above. Obsolete configuration keys are accepted and ignored
for migration. Restart the service after updating its configuration or environment.
Recovery requires the account credentials even if API keys remain set.
```bash
.venv/bin/python -m solar_relay --config config.toml recover \
--from 2026-10-29 --until 2026-10-31
```
Manual recovery accepts at most 30 days per invocation and can query dates older than
the automatic lookback. Only aligned 300-second energy records with all required Wh
channels are accepted; longer aggregates are not split into invented five-minute values.
Manual recovery with explicit date bounds accepts at most 30 days per invocation and
can query dates older than the automatic retention window. Unbounded recovery checks
the whole retention window, even when longer than 30 days. Only aligned five-minute
chart samples in W are accepted;
longer aggregates and unexpected units are rejected.
Recovery stores stable UUIDs and `history_interval` records, which the main website
uses directly without integrating them as live power. Install the corresponding
`solar` receiver update before enabling cloud recovery.
@@ -186,13 +244,14 @@ fresh installation, create it from the example only when it does not exist:
```bash
cd /var/www/html/solar-relay
test -e config.toml || cp config.example.toml config.toml
chmod 0644 config.toml
sudo chgrp www-data config.toml
chmod 0640 config.toml
```
The `www-data` service user needs read access to this configuration. It contains
setting values and environment variable names; keep credentials in the root-readable
environment file below. Ensure the proxy serves the status endpoint rather than
repository files.
The `www-data` service user needs read access to this private configuration, which
may contain the upload token and Solar.web login credentials. Keep real credentials
out of `config.example.toml` and Git. Ensure the proxy serves the status endpoint
rather than repository files.
Keep the archive under `/var/www/html/solar-relay/data/archive`. The relative setting
`data/archive` resolves to that location because paths are relative to `config.toml`.
@@ -222,12 +281,20 @@ exist before starting the service; otherwise systemd exits with `226/NAMESPACE`
before Python can create it. Existing data must be writable by `www-data`. Use
`sudo -u www-data` for archive-writing CLI commands to preserve ownership.
Set `[upload].url` to the main website's `https://your-solar-host/index.php?a=relay`.
Put `SOLAR_RELAY_TOKEN=your-shared-token` in `/etc/solar-relay/solar-relay.env` with
permissions 0600, readable by root. Configure the same token and source ID on the
main website as described in its README. Tokens and API keys stay out of TOML/Git.
Create `/etc/solar-relay` with `sudo install -d -m 0755 /etc/solar-relay` when adding
that optional environment file.
Set the upload URL and shared bearer token in private `config.toml`:
```toml
[upload]
url = "https://your-solar-host/index.php?a=relay"
token = "your-shared-token"
```
Configure the same token and source ID on the main website as described in its README.
No extra environment file is needed. Alternatively, leave `token` empty and provide
the variable named by `token_env` (default `SOLAR_RELAY_TOKEN`). For systemd, put
`SOLAR_RELAY_TOKEN=your-shared-token` in `/etc/solar-relay/solar-relay.env` with
permissions 0600, readable by root. Create `/etc/solar-relay` with
`sudo install -d -m 0755 /etc/solar-relay` when adding that optional environment file.
```bash
sudo cp deploy/solar-relay.service /etc/systemd/system/
@@ -322,4 +389,4 @@ node --check solar_relay/web/status.js
Tests cover normalization, real local HTTP delivery/retries, Parquet round-trips,
concurrent writers, interrupted writes, retention, DST completeness,
mocked authenticated history recovery, status routes and the deployment script.
Live Solar.web recovery requires real Query API credentials and is not exercised by tests.
Live Solar.web recovery requires real account credentials and is not exercised by tests.
+4 -8
View File
@@ -14,7 +14,7 @@ min_request_interval_seconds = 4
[upload]
url = "" # Set to https://your-solar-host/index.php?a=relay to enable uploads.
token_env = "SOLAR_RELAY_TOKEN"
token = "" # Shared bearer token; enter the real value only in private config.toml.
batch_size = 100
request_timeout_seconds = 10
retry_initial_seconds = 5
@@ -28,14 +28,10 @@ base_path = "/solar-relay"
history_days = 14
[history]
# Optional, authenticated Solar.web Query API. No public guest link is used.
# GEN24's local Solar API has no historical-data endpoint.
enabled = false
pv_system_id = "" # Your Solar.web PV system UUID; required when enabled.
access_key_id_env = "SOLARWEB_ACCESS_KEY_ID"
access_key_value_env = "SOLARWEB_ACCESS_KEY_VALUE"
lookback_days = 7
username = "" # Account email; enter real credentials only in private config.toml.
password = ""
lag_seconds = 7200 # Allow the inverter to upload its hourly historical data.
check_interval_seconds = 3600
request_timeout_seconds = 10
request_timeout_seconds = 45
+5 -3
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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]
+57 -43
View File
@@ -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.
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
if begin <= epoch < end and epoch in missing:
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
if len(entries) < 300:
break
page += 1
if page > 2:
raise ValueError("Solar.web history pagination did not finish")
LOG.info("History check finished: %d intervals recovered, %d still missing", recovered, len(missing))
return recovered
def run(self, stop):
+254
View File
@@ -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
+1 -1
View File
@@ -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),
}
+3 -3
View File
@@ -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:
+142 -30
View File
@@ -1,14 +1,16 @@
from copy import deepcopy
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
import pyarrow.parquet as pq
from urllib.parse import parse_qs, urlsplit
from zoneinfo import ZoneInfo
from solar_relay.config import HistoryConfig, utc_text
from solar_relay.history import CHANNELS, HistoryRecovery, history_sample
from solar_relay.history import CHANNELS, HistoryRecovery, chart_entries, history_sample
from solar_relay.http import TransportError
from solar_relay.solarweb import ChartDataError
from solar_relay.storage import Store
from tests.test_status import observed
@@ -22,13 +24,16 @@ def entry(when):
for field, channel in CHANNELS.items()]}
class FakeHistoryHttp:
def __init__(self, entries):
self.entries, self.requests = entries, []
class FakeCharts:
def __init__(self, when):
self.points = {dataset: {int((when + timedelta(minutes=offset)).timestamp() * 1000): watts
for offset in [0, 5, 10]}
for dataset, watts in [("production", 1200), ("consumption", 1440)]}
self.requests = []
def request(self, url, **kwargs):
self.requests.append((url, kwargs))
return {"pvSystemId": SYSTEM, "data": deepcopy(self.entries)}
def get_chart(self, system_id, dataset, date):
self.requests.append((system_id, dataset, date))
return dict(self.points[dataset])
class HistoryTests(unittest.TestCase):
@@ -38,9 +43,9 @@ class HistoryTests(unittest.TestCase):
self.store = Store(Path(self.directory.name) / "archive")
self.when = datetime(2026, 10, 30, 10, tzinfo=timezone.utc)
self.source = "test-house"
self.http = FakeHistoryHttp([entry(self.when + timedelta(minutes=offset)) for offset in [0, 5, 10]])
self.client = FakeCharts(self.when)
self.config = HistoryConfig(enabled=True, pv_system_id=SYSTEM)
self.recovery = HistoryRecovery(self.config, self.source, self.store, http=self.http, credentials=("id-test", "value-test"))
self.recovery = HistoryRecovery(self.config, self.source, self.store, client=self.client)
def recover(self):
return self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(minutes=15)), now=self.when + timedelta(hours=4))
@@ -56,12 +61,12 @@ class HistoryTests(unittest.TestCase):
self.assertIsNone(recovered[0]["readings"]["boiler_enabled"])
self.assertNotIn("boiler", recovered[0]["source_timestamps"])
self.assertIn("boiler", recovered[0]["errors"])
url, kwargs = self.http.requests[0]
self.assertEqual(urlsplit(url).hostname, "swqapi.solarweb.com")
self.assertEqual(kwargs["extra_headers"], {"AccessKeyId": "id-test", "AccessKeyValue": "value-test"})
self.assertNotIn("value-test", url)
self.assertEqual(self.client.requests, [(SYSTEM, dataset, self.when.date())
for dataset in ("production", "consumption")])
self.assertEqual(recovered[0]["raw"]["solarweb_history"]["chart_power_w"],
{"production": 1200, "consumption": 1440})
self.assertEqual(self.recover(), 0)
self.assertEqual(len(self.http.requests), 1)
self.assertEqual(len(self.client.requests), 2)
def test_cloud_energy_does_not_mark_boiler_or_meter_history_complete(self):
self.assertEqual(self.recover(), 3)
@@ -76,10 +81,11 @@ class HistoryTests(unittest.TestCase):
self.assertEqual(row["interval_energy_wh"]["production"], 100)
def test_partial_cloud_records_remain_missing_for_retry(self):
self.http.entries[1]["channels"][0]["value"] = None
stamp = int((self.when + timedelta(minutes=5)).timestamp() * 1000)
del self.client.points["production"][stamp]
self.assertEqual(self.recover(), 2)
self.assertEqual(self.recover(), 0)
self.http.entries[1] = entry(self.when + timedelta(minutes=5))
self.client.points["production"][stamp] = 1200
self.assertEqual(self.recover(), 1)
def test_recovered_identifiers_are_stable_for_duplicate_delivery(self):
@@ -99,23 +105,129 @@ class HistoryTests(unittest.TestCase):
history_sample(value, self.source, SYSTEM)
def test_missing_private_credentials_fail_before_requests(self):
with self.assertRaisesRegex(ValueError, "SOLARWEB_ACCESS_KEY"):
with self.assertRaisesRegex(ValueError, "SOLARWEB_USERNAME and SOLARWEB_PASSWORD"):
HistoryRecovery(self.config, self.source, self.store, credentials=("", ""))
def test_requests_are_split_at_utc_midnight_even_across_dst(self):
self.http.entries = []
def test_config_credentials_take_precedence_over_environment(self):
config = replace(self.config, username="private@example.test", password="synthetic password é &")
with patch("solar_relay.history.os.environ", {"SOLARWEB_USERNAME": "env@example.test", "SOLARWEB_PASSWORD": "env-password"}), \
patch("solar_relay.history.SolarWebClient", return_value=self.client) as client:
recovery = HistoryRecovery(config, self.source, self.store)
self.assertEqual(recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(minutes=15)),
now=self.when + timedelta(hours=4)), 3)
client.assert_called_once_with("private@example.test", "synthetic password é &", timeout=config.request_timeout_seconds)
def test_empty_config_credentials_use_environment_fallback(self):
config = replace(self.config, username_env="BACKUP_USER", password_env="BACKUP_PASSWORD")
with patch("solar_relay.history.os.environ", {"BACKUP_USER": "env@example.test", "BACKUP_PASSWORD": "env-password"}), \
patch("solar_relay.history.SolarWebClient", return_value=self.client) as client:
HistoryRecovery(config, self.source, self.store)
client.assert_called_once_with("env@example.test", "env-password", timeout=config.request_timeout_seconds)
def test_partial_config_credentials_never_mix_with_environment(self):
for changes in [{"username": "private@example.test"}, {"password": "private-password"}]:
with self.subTest(changes=changes), \
patch("solar_relay.history.os.environ", {"SOLARWEB_USERNAME": "env@example.test", "SOLARWEB_PASSWORD": "env-password"}), \
patch("solar_relay.history.SolarWebClient") as client:
with self.assertRaisesRegex(ValueError, "history.username and history.password"):
HistoryRecovery(replace(self.config, **changes), self.source, self.store)
client.assert_not_called()
def test_requests_use_local_dates_across_dst(self):
self.recovery.recover("2026-10-24T22:00:00Z", "2026-10-26T00:00:00Z", now=datetime(2026, 10, 27, tzinfo=timezone.utc))
self.assertEqual(len(self.http.requests), 2)
for url, _ in self.http.requests:
query = parse_qs(urlsplit(url).query)
start, end = [datetime.fromisoformat(query[name][0]) for name in ("from", "to")]
self.assertLessEqual((end - start).total_seconds(), 86400)
self.assertEqual([date.isoformat() for _, dataset, date in self.client.requests if dataset == "production"],
["2026-10-25", "2026-10-26"])
def test_recent_intervals_wait_for_the_hourly_cloud_upload(self):
self.http.entries = []
self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(hours=4)), now=self.when + timedelta(hours=4))
query = parse_qs(urlsplit(self.http.requests[0][0]).query)
self.assertEqual(query["to"], [utc_text(self.when + timedelta(hours=2))])
for points in self.client.points.values():
points.clear()
for offset in [0, 115, 120, 235]:
points[int((self.when + timedelta(minutes=offset)).timestamp() * 1000)] = 1200
self.assertEqual(self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(hours=4)),
now=self.when + timedelta(hours=4)), 2)
def test_automatic_recovery_checks_retention_before_first_observation_and_respects_cutoff(self):
now = datetime(2026, 11, 2, 12, 2, 17, tzinfo=timezone.utc)
cutoff = datetime(2026, 10, 3, 12, 5, tzinfo=timezone.utc)
latest = datetime(2026, 11, 2, 10, tzinfo=timezone.utc)
recoverable = [cutoff, now.replace(minute=0, second=0) - timedelta(days=25), latest - timedelta(minutes=5)]
points = {int(when.timestamp() * 1000): 1200 for when in
[cutoff - timedelta(minutes=5), *recoverable, latest]}
self.client.points = {"production": points, "consumption": points}
self.store.add(observed(now.replace(minute=0, second=0)))
self.assertEqual(self.recovery.recover(now=now), 3)
recovered = [record for record in self.store.records() if "history_interval" in record]
self.assertEqual([record["observed_at"] for record in recovered], [utc_text(when) for when in recoverable])
self.assertEqual(min(day for _, _, day in self.client.requests), cutoff.date())
def test_automatic_recovery_checks_entirely_empty_days_and_archives(self):
self.store.retention_days = 2
self.assertEqual(self.recovery.recover(now=self.when + timedelta(days=2)), 3)
self.assertEqual({day for _, _, day in self.client.requests},
{(self.when + timedelta(days=offset)).date() for offset in range(3)})
def test_automatic_recovery_supports_retention_longer_than_manual_limit(self):
self.store.retention_days = 45
now = self.when + timedelta(days=40)
self.assertEqual(self.recovery.recover(now=now), 3)
self.assertEqual(min(day for _, _, day in self.client.requests), (now - timedelta(days=45)).date())
with self.assertRaisesRegex(ValueError, "at most 30 days"):
self.recovery.recover(utc_text(now - timedelta(days=45)), utc_text(now), now=now)
def test_complete_retention_window_does_not_request_solarweb(self):
now = self.when + timedelta(days=1)
lower = int(self.when.timestamp())
upper = int((now - timedelta(seconds=self.config.lag_seconds)).timestamp())
store = Mock(retention_days=1)
store.energy_slots.return_value = set(range(lower, upper, 300))
recovery = HistoryRecovery(self.config, self.source, store, client=self.client)
self.assertEqual(recovery.recover(now=now), 0)
self.assertEqual(self.client.requests, [])
store.add.assert_not_called()
def test_unavailable_cloud_day_does_not_block_newer_missing_days(self):
self.store.retention_days = 2
available = self.when + timedelta(days=1)
self.client.points = {dataset: {int(available.timestamp() * 1000): 1200}
for dataset in ("production", "consumption")}
get_chart = self.client.get_chart
def charts(system, dataset, day):
if day == self.when.date():
raise ChartDataError("synthetic secret response must not be logged")
return get_chart(system, dataset, day)
with patch.object(self.client, "get_chart", side_effect=charts), self.assertLogs("solar_relay.history", level="WARNING") as logs:
self.assertEqual(self.recovery.recover(now=self.when + timedelta(days=2)), 1)
self.assertNotIn("synthetic secret response", " ".join(logs.output))
self.assertEqual(next(self.store.records())["observed_at"], utc_text(available))
def test_login_or_transport_failure_stops_the_scan_without_repeated_attempts(self):
with patch.object(self.client, "get_chart", side_effect=TransportError("Login failed")) as client:
with self.assertRaisesRegex(TransportError, "Login failed"):
self.recovery.recover(now=self.when + timedelta(days=2))
self.assertEqual(client.call_count, 1)
def test_chart_conversion_matches_solar_and_retains_provenance(self):
for generated, consumed in [(1200, 1440), (2400, 1200), (0, 0)]:
with self.subTest(generated=generated, consumed=consumed):
stamp = int(self.when.timestamp() * 1000)
record = next(chart_entries({stamp: generated}, {stamp: consumed}))
values = history_sample(record, self.source, SYSTEM)["history_interval"]["energy_wh"]
self.assertEqual(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})
self.assertEqual(record["energy_method"], "constant_power_over_five_minutes")
def test_dst_days_recover_every_actual_five_minute_slot(self):
for day, expected in [("2026-03-29", 276), ("2026-10-25", 300)]:
with self.subTest(day=day):
start = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("Europe/Zurich"))
end = start + timedelta(days=1)
points = {stamp: 1200 for stamp in range(int(start.timestamp() * 1000), int(end.timestamp() * 1000), 300_000)}
self.client.points = {"production": points, "consumption": points}
self.assertEqual(self.recovery.recover(utc_text(start), utc_text(end), now=end + timedelta(days=1)), expected)
if __name__ == "__main__":
+68 -2
View File
@@ -10,7 +10,7 @@ import threading
import unittest
import subprocess
import sys
from unittest.mock import patch
from unittest.mock import Mock, patch
from urllib.parse import urlsplit
from solar_relay.config import InverterConfig, UploadConfig, date_bound, load_config
@@ -244,7 +244,7 @@ class DeliveryTests(unittest.TestCase):
reading = client.collect("test")
self.assertEqual(reading["errors"], {})
self.store.add(reading)
uploader = Uploader(UploadConfig(url=base + "/ingest"), self.store, token="test-token")
uploader = Uploader(UploadConfig(url=base + "/ingest", token="test-token"), self.store)
httpd.fail_once = True
with self.assertRaises(UploadError):
uploader.upload_once()
@@ -323,6 +323,27 @@ class DeliveryTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "SOLAR_RELAY_TOKEN"):
Uploader(UploadConfig(url="https://example.test/ingest"), self.store)
def test_config_upload_token_takes_precedence_over_environment(self):
reading = sample()
self.store.add(reading)
http = Mock()
http.request.return_value = {"accepted_ids": [reading["id"]]}
with patch("solar_relay.upload.os.environ", {"SOLAR_RELAY_TOKEN": "env-token"}):
uploader = Uploader(UploadConfig(url="https://example.test/ingest", token="config-token"), self.store, http=http)
self.assertEqual(uploader.upload_once(), 1)
self.assertEqual(http.request.call_args.kwargs["token"], "config-token")
def test_empty_config_token_uses_environment_fallback(self):
with patch("solar_relay.upload.os.environ", {"BACKUP_TOKEN": "env-token"}):
uploader = Uploader(UploadConfig(url="https://example.test/ingest", token_env="BACKUP_TOKEN"), self.store)
self.assertEqual(uploader.token, "env-token")
def test_invalid_config_token_does_not_fall_back_or_leak_its_value(self):
with patch("solar_relay.upload.os.environ", {"SOLAR_RELAY_TOKEN": "env-token"}):
with self.assertRaises(ValueError) as raised:
Uploader(UploadConfig(url="https://example.test/ingest", token="invalid secret token"), self.store)
self.assertNotIn("invalid secret token", str(raised.exception))
def test_cli_sample_and_export_use_the_persistent_archive(self):
with server() as (_, base):
config = Path(self.directory.name) / "config.toml"
@@ -381,6 +402,51 @@ class ConfigTests(unittest.TestCase):
with self.assertRaises(ValueError):
self.load('\n[http]\nbase_path="/../config"\n')
def test_history_account_settings_and_migration_from_query_api_keys(self):
config, _ = self.load('\n[history]\nusername_env="BACKUP_USER"\npassword_env="BACKUP_PASSWORD"\n')
self.assertEqual(config.history.username_env, "BACKUP_USER")
self.assertEqual(config.history.password_env, "BACKUP_PASSWORD")
self.assertFalse(config.history.enabled)
config, _ = self.load('\n[history]\naccess_key_id_env="OLD_ID"\naccess_key_value_env="OLD_KEY"\n')
self.assertEqual(config.history.username_env, "SOLARWEB_USERNAME")
self.assertEqual(config.history.password_env, "SOLARWEB_PASSWORD")
for name in ("username_env", "password_env"):
with self.subTest(name=name), self.assertRaisesRegex(ValueError, "environment variable"):
self.load(f'\n[history]\n{name}="invalid name"\n')
def test_history_uses_retention_instead_of_legacy_lookback_setting(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "synthetic-test.toml"
path.write_text('[collector]\nretention_days=2\n[history]\nenabled=true\n'
'pv_system_id="73733052-6f10-47a5-9746-7f7e76ebcb8c"\nlookback_days=7\n')
config = load_config(path)
self.assertEqual(config.collector.retention_days, 2)
self.assertTrue(config.history.enabled)
self.assertFalse(hasattr(config.history, "lookback_days"))
def test_private_config_accepts_credentials_and_redacts_them_from_repr(self):
config, _ = self.load('\n[upload]\ntoken="synthetic-shared-token"\n'
'\n[history]\nusername="synthetic@example.test"\npassword="synthetic password é &"\n')
self.assertEqual(config.upload.token, "synthetic-shared-token")
self.assertEqual(config.history.username, "synthetic@example.test")
self.assertEqual(config.history.password, "synthetic password é &")
for secret in (config.upload.token, config.history.username, config.history.password):
self.assertNotIn(secret, repr(config))
def test_partial_or_mistyped_private_credentials_are_rejected_without_values(self):
for fields in ['username="synthetic@example.test"', 'password="synthetic-secret"',
'username=" "\npassword="synthetic-secret"', 'username=42', 'password=42']:
with self.subTest(fields=fields):
with self.assertRaises(ValueError) as raised:
self.load(f'\n[history]\n{fields}\n')
self.assertNotIn("synthetic-secret", str(raised.exception))
self.assertNotIn("synthetic@example.test", str(raised.exception))
for value in ['42', '"invalid secret token"']:
with self.subTest(value=value):
with self.assertRaisesRegex(ValueError, "upload.token") as raised:
self.load(f'\n[upload]\ntoken={value}\n')
self.assertNotIn("invalid secret token", str(raised.exception))
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -63,6 +63,11 @@ class ScheduleTests(unittest.TestCase):
self.assertEqual(clock.waits[0], (301, 299))
self.assertTrue(all(stamp % 300 == 0 for stamp in recorded))
def test_default_live_collection_never_initializes_cloud_recovery(self):
with patch("solar_relay.cli.HistoryRecovery", side_effect=AssertionError("Unexpected cloud recovery")):
recorded, _ = self.run_collector(301)
self.assertEqual(recorded, [600, 900, 1200])
def test_restart_in_the_same_interval_adds_no_extra_observation(self):
before, _ = self.run_collector(590, count=1)
after, _ = self.run_collector(615, count=2)
+203
View File
@@ -0,0 +1,203 @@
"""Exercise browser authentication with synthetic credentials and responses only."""
from datetime import date
from io import BytesIO
import json
import unittest
from urllib.error import URLError
from urllib.parse import parse_qs, urlsplit
from unittest.mock import Mock
from solar_relay.http import TransportError
from solar_relay.solarweb import API_SERVER, LOGIN_SERVER, Response, SolarWebClient, decode_chart, validate_url
SYSTEM = "73733052-6f10-47a5-9746-7f7e76ebcb8c"
DAY = date(2026, 10, 30)
STAMP = 1793354400000
PASSWORD = "synthetic password & Unicode é"
LOGIN_FORM = b'''<form action="/commonauth" method="post">
<input type="hidden" name="sessionDataKey" value="synthetic-session">
<input name="usernameUserInput"><input type="password" name="password">
<input type="hidden" name="disabled" value="unused" disabled></form>'''
CALLBACK_FORM = b'''<form action="https://www.solarweb.com/Account/ExternalLoginCallback" method="post">
<input type="hidden" name="state" value="synthetic-state">
<input type="hidden" name="id_token" value="synthetic-token"></form>'''
def chart(series_id="FromGen", points=None, suffix=" W"):
return json.dumps({"settings": {"series": [{"id": series_id, "name": "Localized display name",
"tooltip": {"valueSuffix": suffix},
"data": points if points is not None else [[STAMP, 1200], [STAMP + 300_000, None]]}]}}).encode()
def login_steps():
return [(302, b"", LOGIN_SERVER + "/authenticationendpoint/login.do?sessionDataKey=synthetic-session"),
(200, LOGIN_FORM, ""), (200, CALLBACK_FORM, ""),
(302, b"", API_SERVER + "/"), (200, b"<html>Account</html>", "")]
class ScriptedClient(SolarWebClient):
def __init__(self, steps):
super().__init__("account@example.test", PASSWORD)
self.steps, self.requests = list(steps), []
def _send(self, url, form, referer, as_json):
self.requests.append((url, form, referer, as_json))
if not self.steps:
raise AssertionError("Unexpected request")
status, body, location = self.steps.pop(0)
return Response(status, url, body, location)
class LoginTests(unittest.TestCase):
def test_real_account_flow_and_authenticated_charts_reuse_session(self):
client = ScriptedClient(login_steps() + [(200, b"Chart", ""), (200, chart(), ""),
(200, chart("ToConsumer"), "")])
self.assertEqual(client.get_chart(SYSTEM, "production", DAY), {STAMP: 1200})
self.assertEqual(client.get_chart(SYSTEM, "consumption", DAY), {STAMP: 1200})
self.assertTrue(client.authenticated)
self.assertFalse(client.steps)
posts = [(url, form) for url, form, _, _ in client.requests if form is not None]
self.assertEqual(posts, [(LOGIN_SERVER + "/commonauth", {"sessionDataKey": "synthetic-session",
"username": "account@example.test", "usernameUserInput": "account@example.test",
"password": PASSWORD}),
(API_SERVER + "/Account/ExternalLoginCallback",
{"state": "synthetic-state", "id_token": "synthetic-token"})])
for request, view in zip(client.requests[-2:], ["consumption", "production"]):
url, form, referer, as_json = request
query = parse_qs(urlsplit(url).query)
self.assertEqual(query, {"pvSystemId": [SYSTEM], "year": ["2026"], "month": ["10"],
"day": ["30"], "interval": ["day"], "view": [view]})
self.assertIsNone(form)
self.assertTrue(as_json)
self.assertEqual(urlsplit(referer).path, "/Chart/Chart")
for url, _, _, _ in client.requests:
self.assertNotIn(PASSWORD, url)
self.assertNotIn("GuestLogOn", url)
self.assertNotIn("swqapi", url)
def test_expired_chart_session_is_renewed_once(self):
client = ScriptedClient(login_steps() + [(200, b"Chart", ""), (401, b"", "")]
+ login_steps() + [(200, b"Chart", ""), (200, chart(), "")])
self.assertEqual(client.get_chart(SYSTEM, "production", DAY), {STAMP: 1200})
self.assertEqual(sum(form is not None and "password" in form for _, form, _, _ in client.requests), 2)
def test_persistent_denial_stops_after_one_session_renewal(self):
client = ScriptedClient(login_steps() + [(403, b"", "")] + login_steps() + [(403, b"", "")])
with self.assertRaisesRegex(TransportError, "No guest access"):
client.get_chart(SYSTEM, "production", DAY)
self.assertFalse(client.steps)
def test_rejected_credentials_are_submitted_only_once(self):
client = ScriptedClient(login_steps()[:2] + [(200, LOGIN_FORM, "")])
with self.assertRaisesRegex(TransportError, "login failed"):
client.get_chart(SYSTEM, "production", DAY)
self.assertEqual(sum(form is not None for _, form, _, _ in client.requests), 1)
def test_captcha_or_mfa_does_not_trigger_password_retries(self):
for body in [LOGIN_FORM.replace(b"</form>", b'<div data-sitekey="captcha"></div></form>'),
b'<form method="post"><input name="otp"></form>']:
with self.subTest(body=body):
client = ScriptedClient([login_steps()[0], (200, body, "")])
with self.assertRaisesRegex(TransportError, "login failed"):
client.get_chart(SYSTEM, "production", DAY)
self.assertFalse(any(form is not None for _, form, _, _ in client.requests))
def test_changed_or_untrusted_password_form_is_not_submitted(self):
bodies = [LOGIN_FORM.replace(b"/commonauth", b"https://evil.example/commonauth"),
LOGIN_FORM.replace(b"/commonauth", b"/other"), LOGIN_FORM.replace(b'"post"', b'"get"'),
LOGIN_FORM.replace(b"sessionDataKey", b"differentKey")]
for body in bodies:
with self.subTest(body=body):
client = ScriptedClient([login_steps()[0], (200, body, "")])
with self.assertRaises(TransportError):
client.get_chart(SYSTEM, "production", DAY)
self.assertFalse(any(form is not None for _, form, _, _ in client.requests))
def test_untrusted_callback_is_never_sent_tokens(self):
for target in [b"https://evil.example/callback", b"https://login.fronius.com/commonauth",
b"https://www.solarweb.com/other"]:
with self.subTest(target=target):
body = CALLBACK_FORM.replace(b"https://www.solarweb.com/Account/ExternalLoginCallback", target)
client = ScriptedClient(login_steps()[:2] + [(200, body, "")])
with self.assertRaises(TransportError):
client.get_chart(SYSTEM, "production", DAY)
self.assertEqual(sum(form is not None for _, form, _, _ in client.requests), 1)
def test_guest_and_untrusted_redirects_are_blocked_before_network(self):
for target in [API_SERVER + "/Account/GuestLogOn?pvSystemId=x", API_SERVER + "/Account/DemoLogOn",
"https://evil.example/", "http://www.solarweb.com/"]:
with self.subTest(target=target):
client = ScriptedClient([(302, b"", target)])
with self.assertRaisesRegex(TransportError, "Request blocked"):
client.get_chart(SYSTEM, "production", DAY)
self.assertEqual(len(client.requests), 1)
def test_post_redirect_cannot_forward_credentials(self):
for status in [307, 308]:
with self.subTest(status=status):
client = ScriptedClient(login_steps()[:2] + [(status, b"", API_SERVER + "/")])
with self.assertRaisesRegex(TransportError, "sensitive POST"):
client.get_chart(SYSTEM, "production", DAY)
self.assertEqual(len(client.requests), 3)
class TransportTests(unittest.TestCase):
def test_url_allowlist(self):
for url in [API_SERVER + "/", LOGIN_SERVER + "/commonauth", API_SERVER + ":443/Chart/Chart"]:
validate_url(url)
for url in ["https://www.solarweb.com.evil.example/", "https://user:pass@www.solarweb.com/",
API_SERVER + ":444/", "http://www.solarweb.com/", API_SERVER + "/%47uestLogOn",
API_SERVER + "/DemoLogOn", API_SERVER + "/bad\npath", API_SERVER + "\\@evil.example/"]:
with self.subTest(url=url), self.assertRaises(TransportError):
validate_url(url)
def test_transport_redacts_network_errors_and_response_repr(self):
client = SolarWebClient("account@example.test", PASSWORD)
client.opener = Mock()
client.opener.open.side_effect = URLError("synthetic-token " + PASSWORD)
with self.assertRaises(TransportError) as raised:
client._request(API_SERVER + "/")
self.assertNotIn(PASSWORD, str(raised.exception))
self.assertNotIn("synthetic-token", str(raised.exception))
self.assertEqual(repr(Response(200, API_SERVER + "/?token=synthetic-token", b"secret", "secret")), "Response(status=200)")
def test_transport_keeps_sso_queries_out_of_referer_and_encodes_password(self):
client = SolarWebClient("account@example.test", PASSWORD)
response = BytesIO(b"success")
response.code, response.headers = 200, {}
client.opener = Mock()
client.opener.open.return_value = response
client._request(LOGIN_SERVER + "/commonauth", {"password": PASSWORD}, LOGIN_SERVER + "/?state=synthetic-token")
request = client.opener.open.call_args.args[0]
self.assertEqual(request.get_header("Referer"), LOGIN_SERVER + "/")
self.assertEqual(request.get_header("Origin"), LOGIN_SERVER)
self.assertEqual(parse_qs(request.data.decode())["password"], [PASSWORD])
def test_responses_are_bounded(self):
client = SolarWebClient("account@example.test", PASSWORD)
response = BytesIO(b"x" * (client.MAX_RESPONSE_BYTES + 1))
response.code, response.headers = 200, {}
client.opener = Mock()
client.opener.open.return_value = response
with self.assertRaisesRegex(TransportError, "exceeds 8 MiB"):
client._request(API_SERVER + "/")
class ChartTests(unittest.TestCase):
def test_series_uses_id_and_skips_null_instead_of_zeroing(self):
self.assertEqual(decode_chart(chart(), "FromGen"), {STAMP: 1200})
def test_bad_units_missing_series_and_invalid_points_stop_import(self):
bodies = [b"not JSON", b'{"settings":{"series":[]}}', chart("Other"), chart(suffix="kW"),
chart(points=[[STAMP + 1, 1200]]), chart(points=[[STAMP, True]]),
chart(points=[[STAMP, -1]]), chart(points=[[STAMP, float("nan")]]),
chart(points=[[STAMP, 1], [STAMP, 2]]), chart(points=[[True, 1]]),
chart(points=[[STAMP, "1200"]]), chart(points=[[STAMP]])]
for body in bodies:
with self.subTest(body=body), self.assertRaises(TransportError):
decode_chart(body, "FromGen")
if __name__ == "__main__":
unittest.main()