Initial commit
Deploy Solar Relay / deploy (push) Failing after 4s

This commit is contained in:
franzz committed 2026-11-01 22:33:32 +01:00
1 parent 36180dffd7
commit ec75ac15b0
30 files changed
+3042 -1

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
"""Local Fronius recorder and durable HTTP relay (AGPL-3.0-or-later)."""
__version__ = "0.2.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
raise SystemExit(main())
+166
View File
@@ -0,0 +1,166 @@
"""Commands for recording, uploading, inspecting and replaying the local archive."""
import argparse
import json
import logging
import math
from pathlib import Path
import signal
import sys
import threading
import time
from zoneinfo import ZoneInfoNotFoundError
from . import __version__
from .config import date_bound, load_config
from .fronius import FroniusClient
from .history import HistoryRecovery
from .http import TransportError
from .storage import Store
from .status import StatusServer
from .upload import Uploader, UploadError
LOG = logging.getLogger(__name__)
def _record(client, store, source_id, interval=300):
sample = client.collect(source_id, interval)
# Commit before attempting any upload. Even a completely failed poll is a gap marker.
store.add(sample)
if sample["errors"]:
LOG.warning("Recorded observation %s with errors: %s", sample["id"], json.dumps(sample["errors"]))
else:
LOG.info("Recorded observation %s", sample["id"])
return sample
def next_poll_at(now, interval):
"""UTC wall-clock boundaries: xx:00, xx:05, ... for the default interval."""
return (math.floor(now / interval) + 1) * interval
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
worker = None
history_worker = None
status_server = None
try:
for name in (signal.SIGINT, signal.SIGTERM):
previous[name] = signal.signal(name, lambda *_: stop.set())
if config.http.enabled:
status_server = StatusServer(config, store)
status_server.start()
LOG.info("Status page listening on http://%s:%s", *status_server.address)
if uploader:
# Remote timeouts and retry backoff must not interrupt local recording.
worker = threading.Thread(target=uploader.run, args=(stop,), name="solar-upload", daemon=True)
worker.start()
else:
LOG.info("Upload URL is empty; recording locally only")
if recovery:
history_worker = threading.Thread(target=recovery.run, args=(stop,), name="solar-history", daemon=True)
history_worker.start()
client = FroniusClient(config.inverter)
next_prune = 0
while not stop.is_set():
if time.monotonic() >= next_prune:
deleted = store.prune()
if deleted:
LOG.info("Deleted %d acknowledged observations older than the retention period", deleted)
next_prune = time.monotonic() + 3600
interval = config.collector.poll_interval_seconds
now = time.time()
# Wait before every observation, including the first after a restart.
# Recompute after each poll so a slow poll never triggers a catch-up burst.
if stop.wait(min(interval, max(0, next_poll_at(now, interval) - now))):
break
_record(client, store, config.collector.source_id, interval)
finally:
stop.set()
if status_server:
status_server.close()
if worker:
worker.join(timeout=config.upload.request_timeout_seconds + 1)
if history_worker:
history_worker.join(timeout=config.history.request_timeout_seconds + 1)
for name, handler in previous.items():
signal.signal(name, handler)
return 0
def _parser():
parser = argparse.ArgumentParser(description="Record local Fronius readings and relay them to a website")
parser.add_argument("--version", action="version", version=__version__)
parser.add_argument("--config", type=Path, default=Path("config.toml"))
parser.add_argument("--log-level", choices=("DEBUG", "INFO", "WARNING", "ERROR"), default="INFO")
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("run", help="Record continuously, uploading in a separate worker when configured")
commands.add_parser("sample", help="Record one observation and print its normalized readings")
commands.add_parser("upload", help="Upload the pending queue, stopping on the first failure")
commands.add_parser("status", help="Show local archive/queue status without contacting the inverter")
commands.add_parser("prune", help="Delete records acknowledged more than retention_days ago")
for name, help_text in [
("export", "Export archived samples as JSON Lines, including already uploaded samples"),
("replay", "Requeue archived samples for upload, preserving their IDs"),
("recover", "Retrieve missing energy intervals from the authenticated Solar.web API"),
]:
command = commands.add_parser(name, help=help_text)
command.add_argument("--from", dest="from_date", help="Inclusive local date or ISO timestamp")
command.add_argument("--until", help="Exclusive local date or ISO timestamp")
return parser
def main(argv=None):
args = _parser().parse_args(argv)
logging.basicConfig(level=args.log_level, format="%(asctime)s %(levelname)s %(message)s")
try:
config = load_config(args.config)
start = until = None
if args.command in ("export", "replay", "recover"):
start = date_bound(args.from_date, config.collector.timezone)
until = date_bound(args.until, config.collector.timezone)
if start is not None and until is not None and start >= until:
raise ValueError("--from must be earlier than --until")
store = Store(config.collector.archive, config.collector.retention_days)
if args.command == "run":
return _run(config, store)
if args.command == "sample":
sample = _record(FroniusClient(config.inverter), store, config.collector.source_id, config.collector.poll_interval_seconds)
print(json.dumps({key: value for key, value in sample.items() if key != "raw"}, indent=2))
return 1 if sample["errors"] else 0
if args.command == "status":
print(json.dumps(store.status(), indent=2))
elif args.command == "prune":
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)
print(json.dumps({"recovered": recovery.recover(start, until)}))
elif args.command == "export":
for sample in store.records(start, until):
print(json.dumps(sample, allow_nan=False, separators=(",", ":")))
elif args.command == "replay":
print(json.dumps({"requeued": store.replay(start, until)}))
elif args.command == "upload":
store.prune()
uploader = Uploader(config.upload, store)
count = 0
while uploaded := uploader.upload_once():
count += uploaded
print(json.dumps({"uploaded": count, "pending": store.status()["pending"]}))
return 0
except BrokenPipeError:
return 0
except (ValueError, TypeError, OSError, UploadError, TransportError, ZoneInfoNotFoundError) as error:
LOG.error("%s", error)
return 1
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
sys.exit(main())
+195
View File
@@ -0,0 +1,195 @@
"""Configuration contains environment variable names, never upload credentials."""
from dataclasses import dataclass
from datetime import datetime, timezone
import math
from pathlib import Path
import re
import tomllib
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo
def utc_text(value: datetime) -> str:
if value.tzinfo is None:
raise ValueError("A timestamp must include its timezone")
return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def date_bound(value: str | None, timezone_name: str) -> str | None:
"""Date bounds are local midnights; timestamp bounds may specify an offset."""
if value is None:
return None
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=ZoneInfo(timezone_name))
return utc_text(parsed)
@dataclass(frozen=True)
class CollectorConfig:
source_id: str
archive: Path
timezone: str = "Europe/Zurich"
poll_interval_seconds: float = 300
retention_days: int = 30
@dataclass(frozen=True)
class InverterConfig:
base_url: str = "http://fronius.local"
meter_id: str = "0"
boiler_pin: str = "pin1"
request_timeout_seconds: float = 5
min_request_interval_seconds: float = 4
@dataclass(frozen=True)
class UploadConfig:
url: str = ""
token_env: str = "SOLAR_RELAY_TOKEN"
batch_size: int = 100
request_timeout_seconds: float = 10
retry_initial_seconds: float = 5
retry_max_seconds: float = 300
@dataclass(frozen=True)
class HttpConfig:
enabled: bool = True
bind: str = "127.0.0.1"
port: int = 8765
base_path: str = "/solar-relay"
history_days: int = 14
@dataclass(frozen=True)
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
lag_seconds: int = 7200
check_interval_seconds: int = 3600
request_timeout_seconds: float = 10
@dataclass(frozen=True)
class Config:
collector: CollectorConfig
inverter: InverterConfig
upload: UploadConfig
http: HttpConfig = HttpConfig()
history: HistoryConfig = HistoryConfig()
def _number(value: object, name: str, minimum: float) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{name} must be a number")
if not math.isfinite(value) or value < minimum:
raise ValueError(f"{name} must be at least {minimum}")
return float(value)
def _url(value: str, name: str, *, upload: bool = False) -> None:
if not isinstance(value, str):
raise ValueError(f"{name} must be a URL string")
parsed = urlsplit(value)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise ValueError(f"{name} must be an HTTP(S) URL")
if parsed.username or parsed.password or parsed.fragment:
raise ValueError(f"{name} cannot contain credentials or a fragment")
if upload and parsed.scheme != "https" and parsed.hostname not in ("localhost", "127.0.0.1", "::1"):
raise ValueError("The upload URL must use HTTPS (HTTP is allowed only on loopback for testing)")
# Validate the port now, rather than failing inside the recorder loop.
parsed.port
def load_config(path: Path) -> Config:
path = path.resolve()
with path.open("rb") as handle:
data = tomllib.load(handle)
unknown = set(data) - {"collector", "inverter", "upload", "http", "history"}
if unknown:
raise ValueError(f"Unknown configuration sections: {', '.join(sorted(unknown))}")
for section, keys in {
"collector": set(CollectorConfig.__dataclass_fields__),
"inverter": set(InverterConfig.__dataclass_fields__),
"upload": set(UploadConfig.__dataclass_fields__),
"http": set(HttpConfig.__dataclass_fields__),
"history": set(HistoryConfig.__dataclass_fields__),
}.items():
if not isinstance(data.get(section, {}), dict):
raise ValueError(f"{section} must be a table")
extra = set(data.get(section, {})) - keys
if extra:
raise ValueError(f"Unknown {section} settings: {', '.join(sorted(extra))}")
values = data.get("collector", {})
source_id = values.get("source_id", "home-gen24")
if not isinstance(source_id, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", source_id):
raise ValueError("collector.source_id must contain 1–80 letters, digits, dots, underscores or hyphens")
archive = Path(values.get("archive", "data/archive")).expanduser()
if not archive.is_absolute():
archive = path.parent / archive
timezone_name = values.get("timezone", "Europe/Zurich")
ZoneInfo(timezone_name)
interval = _number(values.get("poll_interval_seconds", 300), "collector.poll_interval_seconds", 10)
if interval != 300:
raise ValueError("collector.poll_interval_seconds must be 300 (one observation every five minutes)")
retention = values.get("retention_days", 30)
if isinstance(retention, bool) or not isinstance(retention, int) or retention < 1:
raise ValueError("collector.retention_days must be a positive integer")
collector = CollectorConfig(source_id, archive.resolve(), timezone_name, interval, retention)
inverter = InverterConfig(**data.get("inverter", {}))
_url(inverter.base_url, "inverter.base_url")
parsed = urlsplit(inverter.base_url)
if parsed.path not in ("", "/") or parsed.query:
raise ValueError("inverter.base_url must be the inverter origin, without a path or query")
if not isinstance(inverter.meter_id, str) or not inverter.meter_id.isdecimal():
raise ValueError('inverter.meter_id must be a string such as "0"')
if not isinstance(inverter.boiler_pin, str) or not re.fullmatch(r"pin[1-4]", inverter.boiler_pin):
raise ValueError("inverter.boiler_pin must be pin1, pin2, pin3 or pin4")
_number(inverter.request_timeout_seconds, "inverter.request_timeout_seconds", 0.1)
_number(inverter.min_request_interval_seconds, "inverter.min_request_interval_seconds", 4)
upload = UploadConfig(**data.get("upload", {}))
if not isinstance(upload.url, str):
raise ValueError("upload.url must be a string")
if upload.url:
_url(upload.url, "upload.url", upload=True)
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:
raise ValueError("upload.batch_size must be an integer between 1 and 500")
_number(upload.request_timeout_seconds, "upload.request_timeout_seconds", 0.1)
_number(upload.retry_initial_seconds, "upload.retry_initial_seconds", 1)
_number(upload.retry_max_seconds, "upload.retry_max_seconds", upload.retry_initial_seconds)
http = HttpConfig(**data.get("http", {}))
if not isinstance(http.enabled, bool):
raise ValueError("http.enabled must be true or false")
if http.bind != "127.0.0.1":
raise ValueError("http.bind must be 127.0.0.1; expose the page through a reverse proxy")
if isinstance(http.port, bool) or not isinstance(http.port, int) or not 1024 <= http.port <= 65535:
raise ValueError("http.port must be an integer between 1024 and 65535")
if not isinstance(http.base_path, str) or (http.base_path and not re.fullmatch(r"/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*", http.base_path)):
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", {}))
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"):
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)]:
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)
+88
View File
@@ -0,0 +1,88 @@
"""Five-minute extraction completeness, distinct from continuous observed coverage."""
from collections import Counter
from datetime import datetime, timedelta, timezone
import math
from zoneinfo import ZoneInfo
from .config import utc_text
BUCKET_MS = 300_000
MAX_SOURCE_AGE_MS = 90_000
def milliseconds(value):
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("Observation timestamp has no timezone")
return round(parsed.timestamp() * 1000)
def _finite(value):
return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(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.
return (sample["id"], sample["source_id"], observed, observed, 1, observed, 0, observed, 0)
readings = sample.get("readings", {})
result = [sample["id"], sample["source_id"], observed]
for source, fields in [
("powerflow", ["production_ac_w", "consumption_w", "grid_import_w", "grid_export_w"]),
("meter", ["grid_import_total_wh", "grid_export_total_wh"]),
("boiler", []),
]:
try:
stamp = milliseconds(sample["source_timestamps"][source])
valid = source not in sample.get("errors", {}) and abs(stamp - observed) <= MAX_SOURCE_AGE_MS
except (KeyError, ValueError, TypeError, AttributeError):
stamp, valid = observed, False
valid = valid and all(_finite(readings.get(field)) for field in fields)
if source == "boiler":
valid = valid and isinstance(readings.get("boiler_enabled"), bool)
result.extend([stamp if valid else observed, int(valid)])
return tuple(result)
def daily_coverage(rows, timezone_name, days, now=None):
now = now or datetime.now(timezone.utc)
zone = ZoneInfo(timezone_name)
today = now.astimezone(zone).date()
first = today - timedelta(days=days)
upper = round(now.timestamp() * 1000)
slots_by_time = {}
observations = Counter()
for row in rows:
observed = row[0]
if observed > upper + 1000:
continue
date = datetime.fromtimestamp(observed / 1000, zone).date().isoformat()
observations[date] += 1
bucket = observed // BUCKET_MS * BUCKET_MS
mask = sum(1 << index for index in range(3) if row[2 + index * 2])
slots_by_time[bucket] = slots_by_time.get(bucket, 0) | mask
result = []
for offset in range(days + 1):
date = today - timedelta(days=offset)
start = datetime.combine(date, datetime.min.time(), zone)
end = datetime.combine(date + timedelta(days=1), datetime.min.time(), zone)
start_ms = round(start.timestamp() * 1000)
expected = round((end.timestamp() - start.timestamp()) * 1000) // BUCKET_MS
elapsed = expected if date < today else max(0, min(expected, (upper - start_ms) // BUCKET_MS))
slots = []
for index in range(expected):
slots.append(slots_by_time.get(start_ms + index * BUCKET_MS, 0) if index < elapsed else -1)
complete = sum(mask == 7 for mask in slots)
count = observations[date.isoformat()]
status = "in_progress" if date == today else "complete" if complete == expected else "partial" if count else "missing"
result.append({
"date": date.isoformat(), "day_start": utc_text(start), "expected": expected, "elapsed": elapsed,
"complete": complete, "missing": elapsed - complete, "samples": count, "status": status,
"energy_complete": sum(mask >= 0 and bool(mask & 1) for mask in slots),
"meter_complete": sum(mask >= 0 and bool(mask & 2) for mask in slots),
"boiler_complete": sum(mask >= 0 and bool(mask & 4) for mask in slots),
"slots": slots,
})
return result
+154
View File
@@ -0,0 +1,154 @@
"""Read Solar API and GEN24 load-management state without changing the inverter."""
from datetime import datetime, timezone
import logging
import math
import time
from uuid import uuid4
from .config import InverterConfig, utc_text
from .http import JsonHttp, TransportError
LOG = logging.getLogger(__name__)
REQUEST_RETRIES = 3
def _number(value):
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ValueError("Invalid numeric reading")
return value
def _sum_known(values):
values = list(values)
return sum(values) if values and all(v is not None for v in values) else None
def _data(response):
head = response.get("Head", {})
if head.get("Status", {}).get("Code") != 0:
raise ValueError("Solar API reported an unsuccessful status")
data = response.get("Body", {}).get("Data")
if not isinstance(data, dict):
raise ValueError("Solar API data is missing")
timestamp = datetime.fromisoformat(head["Timestamp"].replace("Z", "+00:00"))
return data, utc_text(timestamp)
def powerflow_readings(response):
data, timestamp = _data(response)
site = data.get("Site")
inverters = data.get("Inverters", {})
if not isinstance(site, dict) or not isinstance(inverters, dict):
raise ValueError("Solar API site/inverter data is missing")
if not {"P_PV", "P_Load", "P_Grid"}.issubset(site):
raise ValueError("Required Solar API power fields are missing")
load = _number(site.get("P_Load"))
grid = _number(site.get("P_Grid"))
total = _number(site.get("E_Total"))
if total is None:
total = _sum_known(_number(device.get("E_Total")) for device in inverters.values())
return {
# GEN24 P_PV is DC production; keep AC output separately instead of mixing them.
"production_w": _number(site.get("P_PV")),
"production_ac_w": _sum_known(_number(device.get("P")) for device in inverters.values()),
"consumption_w": -load if load is not None else None,
"grid_w": grid, # Positive: import. Negative: export (feed-in point meter).
"grid_import_w": max(0, grid) if grid is not None else None,
"grid_export_w": max(0, -grid) if grid is not None else None,
"production_ac_total_wh": total,
}, timestamp
def meter_readings(response, meter_id):
data, timestamp = _data(response)
meter = data.get(meter_id)
if not isinstance(meter, dict):
raise ValueError(f"Configured meter {meter_id} is missing")
if meter.get("Enable") not in (1, 1.0):
raise ValueError("Configured meter is disabled")
if meter.get("Meter_Location_Current") != 0:
raise ValueError("Configured meter must be at the grid feed-in point")
if not {"EnergyReal_WAC_Sum_Consumed", "EnergyReal_WAC_Sum_Produced"}.issubset(meter):
raise ValueError("Required meter energy counters are missing")
if meter.get("TimeStamp") is not None:
try:
timestamp = utc_text(datetime.fromtimestamp(_number(meter["TimeStamp"]), timezone.utc))
except (OverflowError, OSError):
raise ValueError("Invalid meter measurement timestamp") from None
return {
"grid_import_total_wh": _number(meter.get("EnergyReal_WAC_Sum_Consumed")),
"grid_export_total_wh": _number(meter.get("EnergyReal_WAC_Sum_Produced")),
}, timestamp
def boiler_readings(response, pin):
state = response.get(pin)
if not isinstance(state, dict) or not isinstance(state.get("state"), bool):
raise ValueError(f"Boiler output {pin} has no boolean state")
reason = state.get("reason")
if reason is not None and not isinstance(reason, str):
raise ValueError("Boiler reason is not a string")
return {"boiler_enabled": state["state"], "boiler_reason": reason}
class FroniusClient:
def __init__(self, config: InverterConfig, *, http=None):
self.config = config
self.http = http or JsonHttp(config.request_timeout_seconds, local=True)
self.last_request_started = None
def _read(self, path):
for attempt in range(REQUEST_RETRIES + 1):
if self.last_request_started is not None:
wait = self.config.min_request_interval_seconds - (time.monotonic() - self.last_request_started)
if wait > 0:
time.sleep(wait)
self.last_request_started = time.monotonic()
try:
return self.http.request(self.config.base_url.rstrip("/") + path)
except TransportError as error:
if attempt == REQUEST_RETRIES:
raise
LOG.warning("Fronius GET %s failed (%s); retry %d/%d", path, error, attempt + 1, REQUEST_RETRIES)
def collect(self, source_id, poll_interval_seconds=300):
sample = {
"schema_version": 1,
"id": str(uuid4()),
"source_id": source_id,
"poll_interval_seconds": poll_interval_seconds,
"observed_at": utc_text(datetime.now(timezone.utc)),
"inverter": {"meter_id": self.config.meter_id, "boiler_pin": self.config.boiler_pin},
"readings": dict.fromkeys([
"production_w", "production_ac_w", "consumption_w", "grid_w",
"grid_import_w", "grid_export_w", "production_ac_total_wh",
"grid_import_total_wh", "grid_export_total_wh",
"boiler_enabled", "boiler_reason",
]),
"source_timestamps": {},
"errors": {},
"raw": {},
}
for name, path in [
("powerflow", "/solar_api/v1/GetPowerFlowRealtimeData.fcgi"),
("meter", "/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System"),
("boiler", "/api/status/emrs"),
]:
try:
response = self._read(path)
sample["raw"][name] = response
if name == "powerflow":
readings, timestamp = powerflow_readings(response)
elif name == "meter":
readings, timestamp = meter_readings(response, self.config.meter_id)
else:
readings = boiler_readings(response, self.config.boiler_pin)
timestamp = utc_text(datetime.now(timezone.utc))
sample["readings"].update(readings)
sample["source_timestamps"][name] = timestamp
except (TransportError, ValueError, KeyError, TypeError, AttributeError) as error:
sample["errors"][name] = str(error)
return sample
+129
View File
@@ -0,0 +1,129 @@
"""Optional private Solar.web Query API 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.
"""
from datetime import datetime, timedelta, timezone
import logging
import math
import os
from urllib.parse import urlencode
from uuid import NAMESPACE_URL, uuid5
from .config import utc_text
from .coverage import milliseconds
from .http import JsonHttp, TransportError
LOG = logging.getLogger(__name__)
CHANNELS = {
"production": "EnergyProductionTotal", "consumption": "EnergyConsumptionTotal",
"self_consumption": "EnergySelfConsumptionTotal", "bought": "EnergyPurchased", "sold": "EnergyFeedIn",
}
def history_sample(entry, source_id, system_id):
stamp = milliseconds(entry["logDateTime"])
if stamp % 300_000 or entry.get("logDuration") != 300:
raise ValueError("Historical record is not an aligned five-minute interval")
channels = {}
for channel in entry["channels"]:
name, value = channel.get("channelName"), channel.get("value")
if name not in CHANNELS.values():
continue
if name in channels or channel.get("unit") != "Wh" or channel.get("channelType") != "Energy":
raise ValueError("Unexpected historical channel metadata")
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not 0 <= value <= 1e18:
raise ValueError("Historical energy is missing or invalid")
channels[name] = value
energy = {field: channels[name] for field, name in CHANNELS.items()}
when = utc_text(datetime.fromtimestamp(stamp / 1000, timezone.utc))
return {
"schema_version": 1,
"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([
"production_w", "production_ac_w", "consumption_w", "grid_w", "grid_import_w", "grid_export_w",
"production_ac_total_wh", "grid_import_total_wh", "grid_export_total_wh", "boiler_enabled", "boiler_reason",
]),
"source_timestamps": {},
"errors": {"meter": "Cumulative meter readings are unavailable in recovered energy intervals",
"boiler": "Historical boiler switch state is unavailable"},
"raw": {"solarweb_history": entry},
"history_interval": {"provider": "fronius-solarweb", "start": when, "duration_seconds": 300, "energy_wh": energy},
}
class HistoryRecovery:
def __init__(self, config, source_id, store, *, http=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)
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)
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:
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})
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:
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")
return recovered
def run(self, stop):
while not stop.is_set():
try:
recovered = self.recover(stop=stop)
if recovered:
LOG.info("Recovered %d historical energy intervals from Solar.web", recovered)
except (TransportError, OSError, ValueError) as error:
LOG.warning("Historical recovery failed (%s); retrying on the next scheduled check", type(error).__name__)
stop.wait(self.config.check_interval_seconds)
+63
View File
@@ -0,0 +1,63 @@
"""Bounded JSON requests; redirects cannot forward upload credentials."""
import json
from http.client import HTTPException
from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
from . import __version__
class TransportError(Exception):
pass
class NoRedirects(HTTPRedirectHandler):
def redirect_request(self, request, response, code, message, headers, new_url):
return None
def _invalid_constant(value):
raise ValueError("Non-finite JSON number")
class JsonHttp:
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
def __init__(self, timeout: float, *, local: bool = False):
handlers = [NoRedirects()]
if local:
# LAN traffic must not go through a workstation's HTTP(S) proxy.
handlers.append(ProxyHandler({}))
self.opener = build_opener(*handlers)
self.timeout = timeout
def request(self, url: str, *, payload: dict | None = None, token: str | None = None, extra_headers=None) -> dict:
headers = {"Accept": "application/json", "User-Agent": f"solar-relay/{__version__}"}
headers.update(extra_headers or {})
body = None
if payload is not None:
body = json.dumps(payload, allow_nan=False, separators=(",", ":")).encode("utf-8")
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
request = Request(url, data=body, headers=headers, method="POST" if body is not None else "GET")
try:
with self.opener.open(request, timeout=self.timeout) as response:
raw = response.read(self.MAX_RESPONSE_BYTES + 1)
except HTTPError as error:
# Do not log server bodies: they can echo request headers or credentials.
error.close()
raise TransportError(f"HTTP {error.code}") from None
except (URLError, OSError, TimeoutError, HTTPException) as error:
raise TransportError(f"Network request failed ({type(error).__name__})") from None
if len(raw) > self.MAX_RESPONSE_BYTES:
raise TransportError("JSON response exceeds 2 MiB")
try:
value = json.loads(raw, parse_constant=_invalid_constant)
except (ValueError, UnicodeError, RecursionError):
raise TransportError("Response is not valid JSON") from None
if not isinstance(value, dict):
raise TransportError("Expected a JSON object")
return value
+129
View File
@@ -0,0 +1,129 @@
"""Loopback-only status page; Nginx provides HTTPS and the public path."""
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
import threading
import time
from urllib.parse import parse_qs, urlsplit
from . import __version__
from .config import utc_text
from .coverage import milliseconds
class StatusServer:
def __init__(self, config, store):
self.config, self.store = config, store
self.started = time.monotonic()
self.cache = {}
self.cache_lock = threading.Lock()
folder = Path(__file__).parent / "web"
self.assets = {
"/": ("text/html; charset=utf-8", (folder / "index.html").read_bytes()),
"/assets/status.css": ("text/css; charset=utf-8", (folder / "status.css").read_bytes()),
"/assets/status.js": ("text/javascript; charset=utf-8", (folder / "status.js").read_bytes()),
}
owner = self
class Handler(BaseHTTPRequestHandler):
server_version = "solar-relay"
sys_version = ""
def log_message(self, *_):
pass
def send(self, status, body, kind="application/json; charset=utf-8", *, head=False, headers=None):
if not isinstance(body, bytes):
body = json.dumps(body, allow_nan=False, separators=(",", ":")).encode()
self.send_response(status)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'")
for key, value in (headers or {}).items():
self.send_header(key, value)
self.end_headers()
if not head:
self.wfile.write(body)
def page(self, head=False):
url = urlsplit(self.path)
path = url.path
prefix = owner.config.http.base_path
if prefix and path == prefix:
self.send(308, {}, head=head, headers={"Location": prefix + "/"})
return
if prefix and path.startswith(prefix + "/"):
path = path[len(prefix):]
if path in owner.assets:
kind, body = owner.assets[path]
self.send(200, body, kind, head=head)
elif path == "/healthz":
self.send(200, {"status": "ok", "service": "solar-relay", "version": __version__}, head=head)
elif path == "/api/status":
try:
values = parse_qs(url.query).get("days", [str(owner.config.http.history_days)])
if len(values) != 1 or not values[0].isdecimal() or not 1 <= int(values[0]) <= 90:
self.send(400, {"error": "days must be between 1 and 90"}, head=head)
return
self.send(200, owner.snapshot(int(values[0])), head=head)
except (OSError, ValueError):
self.send(503, {"error": "Archive status is temporarily unavailable"}, head=head)
else:
self.send(404, {"error": "Not found"}, head=head)
def do_GET(self):
self.page()
def do_HEAD(self):
self.page(head=True)
def do_POST(self):
self.send(405, {"error": "Read-only status endpoint"}, headers={"Allow": "GET, HEAD"})
self.server = ThreadingHTTPServer((config.http.bind, config.http.port), Handler)
self.thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.2}, name="solar-status", daemon=True)
@property
def address(self):
return self.server.server_address
def start(self):
self.thread.start()
def close(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
def snapshot(self, days):
# Repeated page refreshes don't repeatedly scan the historical coverage index.
with self.cache_lock:
cached = self.cache.get(days)
if cached and time.monotonic() - cached[0] < 10:
return cached[1]
archive = self.store.status()
now = datetime.now(timezone.utc)
attempt = archive["last_observation"]
age = (now.timestamp() * 1000 - milliseconds(attempt)) / 1000 if attempt else None
errors = list(archive["last_errors"])
success = self.store.last_success(self.config.collector.source_id)
status = "waiting" if age is None else "stale" if age > max(90, self.config.collector.poll_interval_seconds * 3) or age < -90 else "partial" if errors or success != attempt else "ok"
body = {
"service": "solar-relay", "version": __version__, "generated_at": utc_text(now),
"timezone": self.config.collector.timezone, "source_id": self.config.collector.source_id,
"status": status, "uptime_seconds": int(time.monotonic() - self.started),
"collector": {"last_attempt": attempt, "last_success": success,
"age_seconds": round(max(0, age), 1) if age is not None else None, "source_errors": errors,
"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",
"boiler_history_available": False},
"days": self.store.coverage(self.config.collector.source_id, self.config.collector.timezone, days, now),
}
self.cache[days] = time.monotonic(), body
return body
+263
View File
@@ -0,0 +1,263 @@
"""Durable, date-partitioned Parquet archive and acknowledged upload queue.
Each mutation replaces a daily partition atomically. A process/thread lock serializes
readers and writers; it is never held during an HTTP request.
"""
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
import fcntl
import json
import os
from pathlib import Path
import tempfile
import threading
import time
import pyarrow as pa
import pyarrow.parquet as pq
from .config import utc_text
from .coverage import daily_coverage, milliseconds, sample_coverage
NUMBERS = (
"production_w", "production_ac_w", "consumption_w", "grid_w",
"grid_import_w", "grid_export_w", "production_ac_total_wh",
"grid_import_total_wh", "grid_export_total_wh",
)
SCHEMA = pa.schema([
("id", pa.string()), ("source_id", pa.string()), ("observed_at", pa.string()),
("observed_ms", pa.int64()), ("recorded_ns", pa.int64()),
("uploaded_at", pa.string()), ("payload", pa.string()),
*((field, pa.float64()) for field in NUMBERS),
("boiler_enabled", pa.bool_()), ("boiler_reason", pa.string()),
("record_kind", pa.string()),
("interval_energy_wh", pa.struct([(field, pa.float64()) for field in ("production", "consumption", "self_consumption", "bought", "sold")])),
("power_ms", pa.int64()), ("power_ok", pa.bool_()),
("meter_ms", pa.int64()), ("meter_ok", pa.bool_()),
("boiler_ms", pa.int64()), ("boiler_ok", pa.bool_()),
], metadata={b"solar_relay_schema": b"1"})
COVERAGE_COLUMNS = ["observed_ms", "power_ms", "power_ok", "meter_ms", "meter_ok", "boiler_ms", "boiler_ok"]
class StoreError(OSError):
"""An archive could not be read or updated safely."""
class Store:
def __init__(self, path: Path, retention_days=30):
self.path = Path(path)
if self.path.is_file():
raise StoreError("collector.archive must be a directory")
self.path.mkdir(parents=True, exist_ok=True, mode=0o700)
self.retention_days = retention_days
self._thread_lock = threading.RLock()
descriptor = os.open(self.path / ".lock", os.O_CREAT | os.O_WRONLY, 0o600)
os.close(descriptor)
@contextmanager
def _locked(self, *, write=False):
with self._thread_lock, (self.path / ".lock").open("rb") as handle:
fcntl.flock(handle, fcntl.LOCK_EX if write else fcntl.LOCK_SH)
try:
yield
except pa.ArrowException as error:
raise StoreError(f"Parquet archive error ({type(error).__name__})") from error
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
def _files(self):
return sorted(self.path.glob("date=????-??-??/samples.parquet"))
def _partition(self, stamp):
date = datetime.fromtimestamp(milliseconds(stamp) / 1000, timezone.utc).date()
return self.path / f"date={date.isoformat()}" / "samples.parquet"
@staticmethod
def _read(path, columns=None):
# Avoid implicit Hive partition/schema inference from the directory name.
if not path.exists():
return []
parquet = pq.ParquetFile(path, page_checksum_verification=True)
if not parquet.schema_arrow.equals(SCHEMA, check_metadata=True):
raise StoreError("Unsupported Parquet archive schema")
return parquet.read(columns=columns).to_pylist()
@staticmethod
def _sync_directory(path):
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _write(self, path, rows):
new_directory = not path.parent.exists()
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if new_directory:
self._sync_directory(self.path)
if not rows:
path.unlink(missing_ok=True)
self._sync_directory(path.parent)
return
descriptor, temporary = tempfile.mkstemp(prefix=".samples-", suffix=".tmp", dir=path.parent)
try:
with os.fdopen(descriptor, "wb") as handle:
table = pa.Table.from_pylist(sorted(rows, key=lambda row: (row["observed_ms"], row["recorded_ns"], row["id"])), schema=SCHEMA)
pq.write_table(table, handle, compression="zstd", write_page_checksum=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
self._sync_directory(path.parent)
finally:
Path(temporary).unlink(missing_ok=True)
@staticmethod
def _row(sample, uploaded_at=None):
coverage = sample_coverage(sample)
readings = sample.get("readings", {})
return {
"id": sample["id"], "source_id": sample["source_id"], "observed_at": sample["observed_at"],
"observed_ms": coverage[2], "recorded_ns": time.time_ns(), "uploaded_at": uploaded_at,
"payload": json.dumps(sample, allow_nan=False, separators=(",", ":")),
**{name: readings.get(name) for name in NUMBERS},
"boiler_enabled": readings.get("boiler_enabled"), "boiler_reason": readings.get("boiler_reason"),
"record_kind": "history" if "history_interval" in sample else "live",
"interval_energy_wh": sample.get("history_interval", {}).get("energy_wh"),
**{name: bool(value) if name.endswith("_ok") else value
for name, value in zip(COVERAGE_COLUMNS[1:], coverage[3:])},
}
def add(self, sample):
path, row = self._partition(sample["observed_at"]), self._row(sample)
with self._locked(write=True):
rows = self._read(path)
for existing in rows:
if existing["id"] == row["id"]:
if existing["payload"] != row["payload"]:
raise StoreError("Sample ID already contains different data")
return
self._write(path, [*rows, row])
def pending(self, limit):
with self._locked():
rows = []
for path in self._files():
index = self._read(path, ["uploaded_at"])
if any(row["uploaded_at"] is None for row in index):
rows.extend(row for row in self._read(path, ["recorded_ns", "id", "payload", "uploaded_at"]) if row["uploaded_at"] is None)
rows.sort(key=lambda row: (row["recorded_ns"], row["id"]))
return [json.loads(row["payload"]) for row in rows[:limit]]
def acknowledge(self, ids, *, now=None):
wanted, stamp = set(ids), utc_text(now or datetime.now(timezone.utc))
if not wanted:
return
with self._locked(write=True):
for path in self._files():
index = self._read(path, ["id", "uploaded_at"])
if not any(row["id"] in wanted and row["uploaded_at"] is None for row in index):
continue
rows = self._read(path)
for row in rows:
if row["id"] in wanted and row["uploaded_at"] is None:
row["uploaded_at"] = stamp
self._write(path, rows)
def records(self, start=None, until=None):
lower, upper = milliseconds(start) if start else None, milliseconds(until) if until else None
with self._locked():
rows = []
for path in self._files():
rows.extend(row for row in self._read(path, ["observed_ms", "recorded_ns", "id", "payload"])
if (lower is None or row["observed_ms"] >= lower) and (upper is None or row["observed_ms"] < upper))
rows.sort(key=lambda row: (row["observed_ms"], row["recorded_ns"], row["id"]))
return (json.loads(row["payload"]) for row in rows)
def replay(self, start=None, until=None):
lower, upper = milliseconds(start) if start else None, milliseconds(until) if until else None
count = 0
with self._locked(write=True):
for path in self._files():
rows, changed = self._read(path), False
for row in rows:
if (lower is None or row["observed_ms"] >= lower) and (upper is None or row["observed_ms"] < upper):
row["uploaded_at"] = None
count += 1
changed = True
if changed:
self._write(path, rows)
return count
def prune(self, *, now=None):
"""Retain pending records; age acknowledged records from their receipt time."""
cutoff = utc_text((now or datetime.now(timezone.utc)) - timedelta(days=self.retention_days))
deleted = 0
with self._locked(write=True):
for path in self._files():
index = self._read(path, ["uploaded_at"])
if not any(row["uploaded_at"] is not None and row["uploaded_at"] < cutoff for row in index):
continue
rows = self._read(path)
retained = [row for row in rows if row["uploaded_at"] is None or row["uploaded_at"] >= cutoff]
self._write(path, retained)
deleted += len(rows) - len(retained)
return deleted
def status(self):
with self._locked():
rows = []
latest = None
for path in self._files():
index = self._read(path, ["observed_at", "observed_ms", "recorded_ns", "uploaded_at"])
rows.extend(index)
if index:
candidate = max(index, key=lambda row: (row["observed_ms"], row["recorded_ns"]))
if latest is None or (candidate["observed_ms"], candidate["recorded_ns"]) > latest[0]:
latest = ((candidate["observed_ms"], candidate["recorded_ns"]), path)
errors = {}
if latest:
newest = max(self._read(latest[1], ["observed_ms", "recorded_ns", "payload"]), key=lambda row: (row["observed_ms"], row["recorded_ns"]))
errors = json.loads(newest["payload"]).get("errors", {})
return {
"archive": str(self.path), "format": "parquet", "retention_days": self.retention_days,
"samples": len(rows), "pending": sum(row["uploaded_at"] is None for row in rows),
"first_observation": min((row["observed_at"] for row in rows), default=None),
"last_observation": max((row["observed_at"] for row in rows), default=None),
"last_upload": max((row["uploaded_at"] for row in rows if row["uploaded_at"]), default=None),
"last_errors": errors,
}
def coverage(self, source_id, timezone_name, days, now=None):
from zoneinfo import ZoneInfo
now = now or datetime.now(timezone.utc)
first = now.astimezone(ZoneInfo(timezone_name)).date() - timedelta(days=days)
lower = round(datetime.combine(first, datetime.min.time(), ZoneInfo(timezone_name)).timestamp() * 1000)
upper = round(now.timestamp() * 1000)
with self._locked():
rows = []
for path in self._files():
if path.parent.name[5:] < (now - timedelta(days=days + 2)).date().isoformat():
continue
rows.extend(tuple(row[name] for name in COVERAGE_COLUMNS)
for row in self._read(path, ["source_id", *COVERAGE_COLUMNS])
if row["source_id"] == source_id and lower <= row["observed_ms"] <= upper)
return daily_coverage(sorted(rows), timezone_name, days, now)
def last_success(self, source_id):
with self._locked():
for path in reversed(self._files()):
rows = self._read(path, ["source_id", "observed_ms", "power_ok", "meter_ok", "boiler_ok"])
stamps = [row["observed_ms"] for row in rows if row["source_id"] == source_id and row["power_ok"] and row["meter_ok"] and row["boiler_ok"]]
if stamps:
return utc_text(datetime.fromtimestamp(max(stamps) / 1000, timezone.utc))
return None
def energy_slots(self, source_id, start, until):
"""Return UTC bucket starts already having valid local or recovered energy."""
lower, upper = milliseconds(start), milliseconds(until)
with self._locked():
return {row["observed_ms"] // 300_000 * 300
for path in self._files()
for row in self._read(path, ["source_id", "observed_ms", "power_ok"])
if row["source_id"] == source_id and row["power_ok"] and lower <= row["observed_ms"] < upper}
+64
View File
@@ -0,0 +1,64 @@
"""Retryable delivery with explicit per-sample acknowledgement."""
import logging
import os
from .config import 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):
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 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.http = http or JsonHttp(config.request_timeout_seconds)
def upload_once(self) -> int:
batch = self.store.pending(self.config.batch_size)
if not batch:
return 0
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
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)
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Solar relay · Collection status</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23171d20'/%3E%3Ccircle cx='32' cy='32' r='15' fill='%23e8ba70'/%3E%3C/svg%3E">
<link rel="stylesheet" href="assets/status.css">
<script src="assets/status.js" defer></script>
</head>
<body>
<main>
<header class="masthead">
<div class="brand"><span class="sun" aria-hidden="true">☀</span><span>SOLAR RELAY<small>RPI OFFICE</small></span></div>
<div class="header-actions"><span id="overall" class="badge neutral" role="status">Connecting</span><button id="refresh" class="refresh">↻ <span>Refresh</span></button></div>
</header>
<section class="intro">
<p class="eyebrow">COLLECTION MONITOR</p>
<h1>Solar relay status</h1>
<p class="description">Extraction, delivery and five-minute coverage of your local Fronius data.</p>
<div class="context"><span id="source">—</span><span class="separator">/</span><span id="timezone">Europe/Zurich</span><span class="separator">/</span><span id="updated">Waiting for the relay</span></div>
</section>
<p id="notice" class="notice" role="alert" hidden></p>
<section class="metrics" aria-label="Current collection status">
<article class="metric"><p class="metric-label">Last extraction</p><strong id="last-extraction">—</strong><p id="last-extraction-date">No observation received yet</p><small id="last-success">Last successful extraction: —</small></article>
<article class="metric"><p class="metric-label">Yesterday’s coverage</p><strong id="yesterday">— <span>/ —</span></strong><p id="yesterday-detail">Five-minute intervals</p><small>Energy, meter and boiler status</small></article>
<article class="metric"><p class="metric-label">Waiting to upload</p><strong id="pending">—</strong><p id="upload-detail">Checking delivery</p><small id="last-upload">Last acknowledgement: —</small></article>
<article class="metric"><p class="metric-label">Local archive</p><strong id="samples">—</strong><p>Observations retained in Parquet</p><small id="retention">Acknowledged readings kept for 30 days</small></article>
</section>
<section class="panel">
<div class="panel-heading"><div><p class="eyebrow">DAILY COVERAGE</p><h2>Five-minute completeness</h2></div><label class="range">History <select id="history"><option value="7">7 days</option><option value="14" selected>14 days</option><option value="30">30 days</option><option value="90">90 days</option></select></label></div>
<p class="panel-note">Complete means a valid energy, meter and boiler extraction in that five-minute interval. It does not mean continuous measurement. Recovered cloud history supplies energy only.</p>
<div class="table-scroll"><table><thead><tr><th>Day</th><th>Complete intervals</th><th class="source-column">Energy</th><th class="source-column">Meter</th><th class="source-column">Boiler</th><th>Status</th></tr></thead><tbody id="days"><tr><td colspan="6" class="empty">Waiting for the local archive…</td></tr></tbody></table></div>
</section>
<section class="panel detail" aria-label="Selected day interval details">
<div class="panel-heading"><div><p class="eyebrow">FIVE-MINUTE INTERVALS</p><h2 id="selected-date">Select a day</h2></div><div class="legend"><span><i class="complete"></i>Complete</span><span><i class="partial"></i>Partial</span><span><i class="missing"></i>Missing</span><span><i class="future"></i>Upcoming</span></div></div>
<p id="selected-summary" class="panel-note">Each column is one hour. Each cell is a five-minute interval.</p>
<div id="slots" class="slots" aria-label="Five-minute coverage"></div>
<p id="missing-ranges" class="gap-summary"></p>
</section>
<footer><span>Read-only status · refreshes every 15 seconds</span><span id="version">solar-relay</span></footer>
<noscript><p class="notice">Enable JavaScript to load extraction and coverage status.</p></noscript>
</main>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
:root{color-scheme:dark;--bg:#101518;--panel:#171e22;--line:#2b353a;--ink:#eef1ed;--muted:#9ba9ac;--amber:#e8ba70;--green:#7ad6ac;--red:#e68d86;--radius:12px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-synthesis:none}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink)}button,select{font:inherit}button{cursor:pointer}button:focus-visible,select:focus-visible{outline:2px solid var(--amber);outline-offset:4px}main{max-width:1216px;margin:auto;padding:0 32px}.masthead{height:100px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:12px;font-size:13px;letter-spacing:.14em;font-weight:750}.brand small{display:block;color:var(--muted);font-size:9px;letter-spacing:.22em;margin-top:5px}.sun{font-size:36px;color:var(--amber);line-height:1}.header-actions{display:flex;align-items:center;gap:16px}.badge{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;border-radius:20px;padding:6px 11px;font-size:11px;font-weight:650;line-height:1.3;background:#252e32;color:var(--muted)}.badge::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor}.badge.ok,.badge.complete{color:var(--green);background:#193a2e}.badge.partial,.badge.in_progress{color:var(--amber);background:#3a3121}.badge.stale{color:var(--red);background:#3d2829}.badge.missing,.badge.waiting,.badge.neutral{color:var(--muted);background:#252e32}.refresh{border:1px solid var(--line);background:transparent;color:var(--ink);border-radius:7px;padding:8px 12px;font-size:13px}.refresh:hover{border-color:var(--muted)}.refresh:disabled{opacity:.45;cursor:wait}.intro{padding:42px 0 28px}.eyebrow{color:var(--amber);font-size:10px;font-weight:700;letter-spacing:.17em;margin:0 0 13px}h1{font-size:clamp(27px,3.4vw,42px);letter-spacing:-.045em;font-weight:550;line-height:1.14;margin:0 0 13px}.description{color:var(--muted);font-size:14px;line-height:1.6;margin:0}.context{display:flex;align-items:center;gap:10px;font-size:11px;color:var(--muted);margin-top:20px;flex-wrap:wrap}.separator{color:#48565c}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:0 0 28px}.metric{border:1px solid var(--line);border-radius:var(--radius);padding:23px 20px;background:var(--panel)}.metric-label{font-size:11px;font-weight:550;color:var(--muted);margin:0 0 18px}.metric strong{display:block;font-size:30px;font-weight:550;font-variant-numeric:tabular-nums;letter-spacing:-.04em;line-height:1.1;min-height:34px}.metric strong span{color:var(--muted);font-size:17px;font-weight:400;letter-spacing:normal}.metric p:not(.metric-label){font-size:11px;margin:12px 0 7px;color:#d1d9d7}.metric small{font-size:10px;color:var(--muted);line-height:1.6;display:block}.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:22px;overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:24px 24px 0}.panel-heading .eyebrow{margin-bottom:7px}h2{font-size:19px;font-weight:550;letter-spacing:-.025em;margin:0}.panel-note{font-size:11px;line-height:1.7;color:var(--muted);padding:0 24px;margin:12px 0 22px}.range{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:11px;white-space:nowrap}select{background:#20292e;border:1px solid #354149;border-radius:6px;color:var(--ink);padding:7px 27px 7px 9px;font-size:11px}.table-scroll{overflow:auto}table{width:100%;border-collapse:collapse;text-align:left;font-size:12px;font-variant-numeric:tabular-nums}th{background:#1c252a;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:11px 18px;font-size:9px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}th:first-child,td:first-child{padding-left:24px}td{padding:13px 18px;border-bottom:1px solid #283137;white-space:nowrap}tbody tr:last-child td{border-bottom:0}tr.selected{background:#222d32}tr:hover{background:#202a2f}.day-select{display:block;text-align:left;color:var(--ink);background:transparent;border:0;padding:0;line-height:1.55;font-size:12px}.day-select small{display:block;color:var(--muted);font-size:10px}.coverage-number{display:flex;align-items:baseline;gap:5px;font-size:12px}.coverage-number small{font-size:10px;color:var(--muted)}.progress{height:3px;width:126px;border-radius:2px;background:#303c42;margin-top:7px;overflow:hidden}.progress span{display:block;height:100%;background:var(--green)}.source-count{color:#c1ceca;font-size:11px}.source-count.incomplete{color:var(--amber)}.empty{padding:40px!important;text-align:center;color:var(--muted)}.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:10px;color:var(--muted)}.legend span{display:flex;align-items:center;gap:6px}.legend i{display:block;width:7px;height:7px;border-radius:2px}.slots{display:flex;gap:5px;padding:0 24px;overflow-x:auto}.hour{display:grid;grid-template-rows:16px repeat(12,5px);gap:3px;flex:1;min-width:14px}.hour-label{font-size:9px;color:var(--muted);font-variant-numeric:tabular-nums;text-align:center}.slot{display:block;border-radius:1px;min-height:5px}.complete{background:#76c9a4}.partial{background:#bd9558}.missing{bacLine truncated
@media(max-width:900px){.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.metric{padding:20px}.source-column{display:none}.legend{max-width:215px;gap:10px}.slots{gap:4px}.progress{width:115px}}
@media(max-width:560px){main{padding:0 16px}.masthead{height:82px}.brand{font-size:11px;gap:8px}.sun{font-size:30px}.header-actions{gap:8px}.refresh span{display:none}.refresh{font-size:17px;padding:4px 9px}.badge{font-size:10px;padding:6px 8px}.intro{padding-top:30px}.metrics{gap:10px;margin-bottom:20px}.metric{padding:17px 14px}.metric strong{font-size:26px}.metric strong span{font-size:14px}.metric-label{margin-bottom:14px}.metric small{font-size:9px}.panel-heading{padding:20px 16px 0;align-items:flex-start}h2{font-size:16px}.panel-note{padding:0 16px;font-size:10px}th,td{padding-left:11px;padding-right:11px}th:first-child,td:first-child{padding-left:16px}.progress{width:88px}.range{flex-direction:column;align-items:flex-end;gap:5px}.legend{font-size:9px;gap:7px;max-width:150px}.slots{padding:0 16px;gap:3px}.hour{min-width:10px;grid-template-rows:16px repeat(12,5px);gap:3px}.hour-label{font-size:8px}.gap-summary{padding:16px;font-size:10px}.day-select{font-size:11px}footer{font-size:9px;flex-wrap:wrap}.detail .panel-heading{gap:8px}}
+148
View File
@@ -0,0 +1,148 @@
/* Read-only local archive dashboard. All URLs remain relative to the proxy prefix. */
(() => {
'use strict';
const $ = id => document.getElementById(id);
const labels = {ok:'Collecting',waiting:'Waiting for data',partial:'Partial extraction',stale:'Extraction overdue',complete:'Complete',in_progress:'In progress',missing:'No data'};
let current = null;
let selected = null;
let busy = false;
let zone = 'Europe/Zurich';
const number = value => new Intl.NumberFormat('en-GB').format(value);
const format = (value, options) => value ? new Intl.DateTimeFormat('en-GB', {timeZone:zone,...options}).format(new Date(value)) : '—';
const time = value => format(value,{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false});
const date = value => format(value,{day:'2-digit',month:'short',year:'numeric'});
function badge(text, status) {
const element = document.createElement('span');
element.className = 'badge ' + status;
element.textContent = text;
return element;
}
function td(className) {
const element = document.createElement('td');
if(className) element.className = className;
return element;
}
function addText(parent, tag, text, className) {
const element = document.createElement(tag);
if(className) element.className = className;
element.textContent = text;
parent.append(element);
return element;
}
function renderDays() {
$('days').replaceChildren();
for(const day of current.days) {
const row = document.createElement('tr');
row.classList.toggle('selected',day.date===selected);
const dayCell = td();
const button = addText(dayCell,'button',format(day.date+'T12:00:00Z',{day:'2-digit',month:'short'}),'day-select');
button.type = 'button';
button.setAttribute('aria-expanded',String(day.date===selected));
addText(button,'small',day.status==='in_progress'?'Today':format(day.date+'T12:00:00Z',{weekday:'long'}));
button.addEventListener('click',() => {selected=day.date;renderDays();renderSlots();});
row.append(dayCell);
const amount = td();
const count = addText(amount,'div',number(day.complete),'coverage-number');
addText(count,'small','/ '+number(day.elapsed)+(day.status==='in_progress'?' so far':''));
const progress = addText(amount,'div','','progress');
const fill = addText(progress,'span','');
fill.style.width = (day.elapsed ? day.complete/day.elapsed*100:0)+'%';
row.append(amount);
for(const field of ['energy_complete','meter_complete','boiler_complete']) {
const cell = td('source-column');
addText(cell,'span',number(day[field])+'/'+number(day.elapsed),'source-count'+(day[field]<day.elapsed?' incomplete':''));
row.append(cell);
}
const state = td();
state.append(badge(labels[day.status]||day.status,day.status));
row.append(state);
$('days').append(row);
}
}
function renderSlots() {
const day = current.days.find(item => item.date===selected);
if(!day) return;
$('selected-date').textContent = format(day.date+'T12:00:00Z',{weekday:'long',day:'numeric',month:'long',year:'numeric'});
$('selected-summary').textContent = `${number(day.complete)} complete of ${number(day.elapsed)} elapsed intervals · ${number(day.expected)} intervals in this calendar day. Each cell is five minutes.`;
const start = Date.parse(day.day_start);
const stamp = index => format(new Date(start+index*300000),{hour:'2-digit',minute:'2-digit',timeZoneName:'shortOffset',hour12:false});
$('slots').replaceChildren();
let hour;
for(let index=0;index<day.slots.length;index++) {
if(index%12===0) {
hour = addText($('slots'),'div','','hour');
addText(hour,'span',format(new Date(start+index*300000),{hour:'2-digit',hour12:false}),'hour-label');
}
const mask = day.slots[index];
const state = mask===7?'complete':mask===-1?'future':mask>0?'partial':'missing';
const missing = ['Energy','Meter','Boiler'].filter((_,bit) => !(mask & (1<<bit)));
const text = `${stamp(index)}–${stamp(index+1)} · ${mask===7?'Complete':mask===-1?'Upcoming':'Incomplete: '+missing.join(', ')}`;
const cell = addText(hour,'span','','slot '+state);
cell.title = text;
cell.setAttribute('role','img');
cell.setAttribute('aria-label',text);
}
const ranges = [];
for(let index=0;index<day.elapsed;) {
if(day.slots[index]===7) {index++;continue;}
const from = index;
while(index<day.elapsed && day.slots[index]!==7) index++;
ranges.push(`${stamp(from)}–${stamp(index)}`);
}
$('missing-ranges').textContent = ranges.length ? `${number(day.missing)} incomplete intervals. Gaps: ${ranges.slice(0,6).join('; ')}${ranges.length>6?`; plus ${number(ranges.length-6)} more ranges.`:'.'}` : day.status==='in_progress'?'All elapsed intervals are complete. The current interval is still being recorded.':'All five-minute intervals have complete data.';
}
function render(data) {
current = data;
zone = data.timezone;
$('source').textContent = data.source_id;
$('timezone').textContent = zone;
$('updated').textContent = 'Updated '+time(data.generated_at);
$('overall').textContent = labels[data.status]||data.status;
$('overall').className = 'badge '+data.status;
$('last-extraction').textContent = data.collector.last_attempt?time(data.collector.last_attempt):'Waiting';
$('last-extraction-date').textContent = data.collector.last_attempt?date(data.collector.last_attempt):'No observation received yet';
$('last-success').textContent = 'Last successful: '+(data.collector.last_success?date(data.collector.last_success)+' '+time(data.collector.last_success):'not yet recorded');
const yesterday = data.days[1];
if(yesterday) {
$('yesterday').replaceChildren(document.createTextNode(number(yesterday.complete)+' '));
addText($('yesterday'),'span','/ '+number(yesterday.expected));
$('yesterday-detail').textContent = labels[yesterday.status]+' · '+date(yesterday.date+'T12:00:00Z');
}
$('pending').textContent = number(data.archive.pending);
$('upload-detail').textContent = !data.archive.upload_enabled?'Uploads not configured':data.archive.pending?'Saved locally, awaiting delivery':'All observations acknowledged';
$('last-upload').textContent = 'Last acknowledgement: '+(data.archive.last_upload?date(data.archive.last_upload)+' '+time(data.archive.last_upload):'none yet');
$('samples').textContent = number(data.archive.samples);
$('retention').textContent = `Acknowledged readings kept for ${number(data.archive.retention_days)} days · pending readings retained`;
$('version').textContent = 'solar-relay '+data.version;
if(!selected || !data.days.some(day=>day.date===selected)) selected=(data.days.find(day=>day.samples>0)||yesterday||data.days[0]).date;
renderDays();
renderSlots();
}
async function refresh() {
if(busy) return;
busy=true;
$('refresh').disabled=true;
const controller = new AbortController();
const timeout = setTimeout(()=>controller.abort(),12000);
try {
const response = await fetch('api/status?days='+encodeURIComponent($('history').value),{cache:'no-store',signal:controller.signal});
if(!response.ok) throw new Error('Status request failed');
render(await response.json());
$('notice').hidden=true;
} catch {
$('notice').textContent = current?'The relay cannot be reached. Showing the last received status; automatic refresh will retry.':'Waiting for the relay status service. Check that solar-relay.service is running and the reverse proxy forwards this path to port 8765.';
$('notice').hidden=false;
$('overall').textContent='Connection lost';
$('overall').className='badge stale';
} finally {
clearTimeout(timeout);
busy=false;
$('refresh').disabled=false;
}
}
$('refresh').addEventListener('click',refresh);
$('history').addEventListener('change',refresh);
document.addEventListener('visibilitychange',()=>{if(!document.hidden) refresh();});
setInterval(()=>{if(!document.hidden) refresh();},15000);
refresh();
})();