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

+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())