1 parent
36180dffd7
commit
ec75ac15b0
30 files changed
+3042
-1
No files matched your search
@@ -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())
|
||||
Reference in new issue
Block a user