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
+883 -142

No files matched your search

+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]
+61 -47
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.
continue
if begin <= epoch < end and epoch in missing:
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")
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
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
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: