Files
solar-relay/solar_relay/upload.py
T
franzz e7905c667d
Deploy Solar Relay / deploy (push) Successful in 1m7s
Reshape boiler data
2026-11-03 01:48:01 +01:00

81 lines
3.8 KiB
Python

"""Retryable delivery with explicit per-sample acknowledgement."""
import logging
import math
import os
from .config import InverterConfig, UploadConfig
from .http import JsonHttp, TransportError
from .storage import Store
LOG = logging.getLogger(__name__)
class UploadError(Exception):
pass
class Uploader:
def __init__(self, config: UploadConfig, store: Store, *, token=None, http=None, boiler_threshold_w=None):
if not config.url:
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 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)
self.boiler_threshold_w = InverterConfig().boiler_threshold_w if boiler_threshold_w is None else boiler_threshold_w
if (isinstance(self.boiler_threshold_w, bool) or not isinstance(self.boiler_threshold_w, (int, float))
or not math.isfinite(self.boiler_threshold_w) or not 0 < self.boiler_threshold_w <= 99_999_999.99):
raise ValueError("Invalid fallback boiler threshold")
def upload_once(self) -> int:
batch = self.store.pending(self.config.batch_size)
if not batch:
return 0
# Older retained samples contain the switch state only. Supply the configured
# fallback on delivery; never replace thresholds already recorded with a sample.
for sample in batch:
readings = sample.get("readings", {})
if isinstance(readings.get("boiler_enabled"), bool) and "boiler_threshold_w" not in readings:
readings["boiler_threshold_w"] = self.boiler_threshold_w
readings["boiler_threshold_source"] = "config"
try:
response = self.http.request(
self.config.url, payload={"schema_version": 1, "samples": batch}, token=self.token,
)
except TransportError as error:
raise UploadError(str(error)) from None
if "result" in response:
if response["result"] != "success" or not isinstance(response.get("data"), dict):
raise UploadError("Receiver returned an unsuccessful or malformed result")
response = response["data"]
accepted = response.get("accepted_ids")
if not isinstance(accepted, list) or not all(isinstance(value, str) for value in accepted):
raise UploadError("Receiver must return an accepted_ids array")
requested = {sample["id"] for sample in batch}
if len(set(accepted)) != len(accepted) or not set(accepted).issubset(requested):
raise UploadError("Receiver acknowledged duplicate or unknown sample IDs")
self.store.acknowledge(accepted)
if set(accepted) != requested:
raise UploadError(f"Receiver accepted {len(accepted)}/{len(batch)} samples; others remain queued")
return len(accepted)
def run(self, stop):
delay = self.config.retry_initial_seconds
while not stop.is_set():
try:
count = self.upload_once()
except (UploadError, OSError) as error:
LOG.warning("Upload failed: %s; retrying in %.0fs", error, delay)
stop.wait(delay)
delay = min(delay * 2, self.config.retry_max_seconds)
else:
delay = self.config.retry_initial_seconds
if count:
LOG.info("Uploaded and acknowledged %d samples", count)
else:
stop.wait(5)