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