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

+386
View File
@@ -0,0 +1,386 @@
from contextlib import contextmanager
from copy import deepcopy
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
import subprocess
import sys
from unittest.mock import patch
from urllib.parse import urlsplit
from solar_relay.config import InverterConfig, UploadConfig, date_bound, load_config
from solar_relay.fronius import FroniusClient, powerflow_readings
from solar_relay.http import JsonHttp, TransportError
from solar_relay.storage import Store
from solar_relay.upload import Uploader, UploadError
def api(data):
return {
"Body": {"Data": data},
"Head": {"Status": {"Code": 0}, "Timestamp": "2026-11-01T12:00:00+00:00"},
}
POWER = api({
"Site": {"P_PV": 2100, "P_Load": -2500, "P_Grid": 500, "E_Total": 100000},
"Inverters": {"1": {"P": 2000, "E_Total": 100000}},
})
METER = api({"0": {
"Enable": 1, "Meter_Location_Current": 0,
"EnergyReal_WAC_Sum_Consumed": 50000, "EnergyReal_WAC_Sum_Produced": 80000,
}})
BOILER = {"pin1": {"state": True, "reason": "PSTR_EMRS_REASON_3"}}
RESPONSES = {
"/solar_api/v1/GetPowerFlowRealtimeData.fcgi": POWER,
"/solar_api/v1/GetMeterRealtimeData.cgi": METER,
"/api/status/emrs": BOILER,
}
class FakeHttp:
def __init__(self, responses=None):
self.responses = deepcopy(RESPONSES if responses is None else responses)
def request(self, url):
result = self.responses[urlsplit(url).path]
if isinstance(result, Exception):
raise result
return deepcopy(result)
def sample():
return FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp()).collect("test-house")
@contextmanager
def server():
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def respond(self, status, data):
body = json.dumps(data).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlsplit(self.path).path
self.server.get_counts[path] = self.server.get_counts.get(path, 0) + 1
failures = self.server.get_failures.get(path, [])
if failures:
self.respond(failures.pop(0), {"error": "temporary"})
return
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", "/unexpected")
self.send_header("Content-Length", "0")
self.end_headers()
elif self.path == "/unexpected":
self.server.redirect_followed = True
self.respond(200, {})
elif self.path == "/nan":
self.respond(200, {"reading": float("nan")})
else:
path = urlsplit(self.path).path
self.respond(200, RESPONSES[path])
def do_POST(self):
self.server.authorizations.append(self.headers.get("Authorization"))
data = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
self.server.requests.append(data)
ids = [s["id"] for s in data["samples"]]
# Emulate an idempotent receiver that committed before its response failed.
self.server.received_ids.update(ids)
if self.server.fail_once:
self.server.fail_once = False
self.respond(503, {"error": "temporary"})
elif self.server.ack_override is not None:
self.respond(200, self.server.ack_override)
else:
self.respond(200, {"accepted_ids": ids})
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
httpd.fail_once = False
httpd.ack_override = None
httpd.requests = []
httpd.get_counts = {}
httpd.get_failures = {}
httpd.authorizations = []
httpd.received_ids = set()
httpd.redirect_followed = False
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
yield httpd, f"http://127.0.0.1:{httpd.server_port}"
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=2)
class NormalizationTests(unittest.TestCase):
def test_dc_ac_and_meter_directions_stay_distinct(self):
result = sample()
readings = result["readings"]
self.assertEqual(readings["production_w"], 2100)
self.assertEqual(readings["production_ac_w"], 2000)
self.assertEqual(readings["consumption_w"], 2500)
self.assertEqual(readings["grid_import_w"], 500)
self.assertEqual(readings["grid_export_w"], 0)
self.assertEqual(readings["grid_import_total_wh"], 50000)
self.assertTrue(readings["boiler_enabled"])
self.assertEqual(result["errors"], {})
self.assertEqual(result["raw"]["powerflow"], POWER)
self.assertEqual(result["source_timestamps"]["powerflow"], "2026-11-01T12:00:00.000Z")
def test_export_direction_and_nighttime_nulls(self):
response = deepcopy(POWER)
response["Body"]["Data"]["Site"].update(P_Grid=-1200, P_PV=None)
response["Body"]["Data"]["Inverters"]["1"]["P"] = None
readings, _ = powerflow_readings(response)
self.assertEqual(readings["grid_import_w"], 0)
self.assertEqual(readings["grid_export_w"], 1200)
self.assertIsNone(readings["production_w"])
self.assertIsNone(readings["production_ac_w"])
def test_boiler_failure_is_unknown_and_does_not_lose_energy(self):
responses = deepcopy(RESPONSES)
responses["/api/status/emrs"] = TransportError("HTTP 404")
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
self.assertIsNone(result["readings"]["boiler_enabled"])
self.assertEqual(result["readings"]["production_w"], 2100)
self.assertEqual(result["errors"], {"boiler": "HTTP 404"})
def test_false_is_a_valid_boiler_state(self):
responses = deepcopy(RESPONSES)
responses["/api/status/emrs"]["pin1"]["state"] = False
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
self.assertIs(result["readings"]["boiler_enabled"], False)
self.assertNotIn("boiler", result["errors"])
def test_solar_api_application_error_does_not_accept_old_values(self):
responses = deepcopy(RESPONSES)
responses["/solar_api/v1/GetPowerFlowRealtimeData.fcgi"]["Head"]["Status"]["Code"] = 12
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
self.assertIsNone(result["readings"]["production_w"])
self.assertIn("powerflow", result["errors"])
self.assertTrue(result["readings"]["boiler_enabled"])
def test_load_branch_meter_cannot_be_mislabeled_as_grid_energy(self):
responses = deepcopy(RESPONSES)
responses["/solar_api/v1/GetMeterRealtimeData.cgi"]["Body"]["Data"]["0"]["Meter_Location_Current"] = 1
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
self.assertIsNone(result["readings"]["grid_import_total_wh"])
self.assertIn("meter", result["errors"])
def test_meter_measurement_timestamp_is_preserved_instead_of_request_time(self):
responses = deepcopy(RESPONSES)
responses["/solar_api/v1/GetMeterRealtimeData.cgi"]["Body"]["Data"]["0"]["TimeStamp"] = 1793534100
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
self.assertEqual(result["source_timestamps"]["meter"], "2026-11-01T11:55:00.000Z")
def test_missing_power_fields_are_a_source_error(self):
responses = deepcopy(RESPONSES)
responses["/solar_api/v1/GetPowerFlowRealtimeData.fcgi"]["Body"]["Data"]["Site"] = {}
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
self.assertIn("powerflow", result["errors"])
self.assertIsNone(result["readings"]["production_w"])
class ArchiveTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.path = Path(self.directory.name) / "archive"
self.store = Store(self.path)
def test_restart_and_acknowledgement_preserve_archive(self):
first = sample()
self.store.add(first)
Store(self.path).acknowledge([first["id"]])
reopened = Store(self.path)
self.assertEqual(reopened.pending(100), [])
self.assertEqual(list(reopened.records()), [first])
self.assertEqual(reopened.status()["samples"], 1)
if os.name == "posix":
self.assertEqual(self.path.stat().st_mode & 0o777, 0o700)
self.assertEqual(next(self.path.glob("date=*/samples.parquet")).stat().st_mode & 0o777, 0o600)
def test_local_date_export_and_replay_across_dst(self):
start = date_bound("2026-10-25", "Europe/Zurich")
until = date_bound("2026-10-26", "Europe/Zurich")
self.assertEqual((datetime.fromisoformat(until) - datetime.fromisoformat(start)).total_seconds(), 25 * 3600)
items = []
for timestamp in ["2026-10-24T21:59:59.000Z", start, "2026-10-25T12:00:00.000Z", until]:
item = sample()
item["observed_at"] = timestamp
items.append(item)
self.store.add(item)
self.store.acknowledge([item["id"] for item in items])
self.assertEqual(list(self.store.records(start, until)), items[1:3])
self.assertEqual(self.store.replay(start, until), 2)
self.assertEqual(self.store.pending(100), items[1:3])
self.assertEqual(self.store.status()["samples"], 4)
class DeliveryTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.store = Store(Path(self.directory.name) / "archive")
def test_real_http_collection_delivery_and_lost_ack_retry(self):
with server() as (httpd, base):
client = FroniusClient(InverterConfig(base_url=base, min_request_interval_seconds=0))
reading = client.collect("test")
self.assertEqual(reading["errors"], {})
self.store.add(reading)
uploader = Uploader(UploadConfig(url=base + "/ingest"), self.store, token="test-token")
httpd.fail_once = True
with self.assertRaises(UploadError):
uploader.upload_once()
self.assertEqual(self.store.status()["pending"], 1)
self.assertEqual(uploader.upload_once(), 1)
self.assertEqual(self.store.status()["pending"], 0)
self.assertEqual(self.store.status()["samples"], 1)
self.assertEqual(httpd.requests[0], httpd.requests[1])
self.assertEqual(httpd.received_ids, {reading["id"]})
self.assertEqual(httpd.authorizations, ["Bearer test-token", "Bearer test-token"])
def test_plain_http_success_is_not_an_acknowledgement(self):
with server() as (httpd, base):
self.store.add(sample())
uploader = Uploader(UploadConfig(url=base), self.store, token="test-token")
httpd.ack_override = {"success": True}
with self.assertRaises(UploadError):
uploader.upload_once()
self.assertEqual(self.store.status()["pending"], 1)
def test_partial_ack_only_marks_the_accepted_sample(self):
with server() as (httpd, base):
first, second = sample(), sample()
self.store.add(first)
self.store.add(second)
httpd.ack_override = {"accepted_ids": [first["id"]]}
with self.assertRaises(UploadError):
Uploader(UploadConfig(url=base), self.store, token="test-token").upload_once()
self.assertEqual(self.store.pending(100), [second])
self.assertEqual(self.store.status()["samples"], 2)
def test_unknown_ack_cannot_clear_the_queue(self):
with server() as (httpd, base):
self.store.add(sample())
httpd.ack_override = {"accepted_ids": ["unknown-id"]}
with self.assertRaises(UploadError):
Uploader(UploadConfig(url=base), self.store, token="test-token").upload_once()
self.assertEqual(self.store.status()["pending"], 1)
def test_slow_upload_does_not_lock_recording(self):
entered, release = threading.Event(), threading.Event()
class BlockingHttp:
def request(self, url, *, payload, token):
entered.set()
release.wait(timeout=3)
return {"accepted_ids": [s["id"] for s in payload["samples"]]}
self.store.add(sample())
uploader = Uploader(UploadConfig(url="https://example.test/ingest"), self.store, token="test-token", http=BlockingHttp())
result = []
worker = threading.Thread(target=lambda: result.append(uploader.upload_once()))
worker.start()
try:
self.assertTrue(entered.wait(timeout=2))
second = sample()
self.store.add(second)
self.assertEqual(self.store.status()["samples"], 2)
finally:
release.set()
worker.join(timeout=4)
self.assertEqual(result, [1])
self.assertEqual(self.store.pending(100), [second])
def test_redirects_and_invalid_json_numbers_are_rejected(self):
with server() as (httpd, base):
http = JsonHttp(timeout=2)
with self.assertRaises(TransportError):
http.request(base + "/redirect", token="test-token")
self.assertFalse(httpd.redirect_followed)
with self.assertRaises(TransportError):
http.request(base + "/nan")
def test_missing_upload_token_fails_before_delivery(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(ValueError, "SOLAR_RELAY_TOKEN"):
Uploader(UploadConfig(url="https://example.test/ingest"), self.store)
def test_cli_sample_and_export_use_the_persistent_archive(self):
with server() as (_, base):
config = Path(self.directory.name) / "config.toml"
config.write_text(
'[collector]\nsource_id="cli-test"\narchive="cli-archive"\n'
f'\n[inverter]\nbase_url="{base}"\n'
)
command = [sys.executable, "-m", "solar_relay", "--config", str(config)]
cwd = Path(__file__).resolve().parents[1]
result = subprocess.run(command + ["sample"], cwd=cwd, capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stderr)
normalized = json.loads(result.stdout)
result = subprocess.run(command + ["export"], cwd=cwd, capture_output=True, text=True, timeout=5)
self.assertEqual(result.returncode, 0, result.stderr)
archived = json.loads(result.stdout)
self.assertEqual(normalized["id"], archived["id"])
self.assertEqual(archived["raw"]["powerflow"], POWER)
class ConfigTests(unittest.TestCase):
def load(self, extra=""):
with tempfile.TemporaryDirectory() as name:
path = Path(name) / "config.toml"
path.write_text('[collector]\nsource_id="home"\narchive="state/archive"\n' + extra)
return load_config(path), path.parent
def test_archive_is_relative_to_config(self):
config, directory = self.load()
self.assertEqual(config.collector.archive, directory / "state/archive")
self.assertEqual(config.collector.poll_interval_seconds, 300)
self.assertEqual(config.collector.retention_days, 30)
self.assertEqual(config.upload.url, "")
def test_remote_upload_requires_https(self):
with self.assertRaisesRegex(ValueError, "HTTPS"):
self.load('\n[upload]\nurl="http://example.test/ingest"\n')
config, _ = self.load('\n[upload]\nurl="http://127.0.0.1:8000/ingest"\n')
self.assertTrue(config.upload.url)
def test_mistyped_configuration_is_rejected(self):
with self.assertRaisesRegex(ValueError, "Unknown inverter"):
self.load('\n[inverter]\nboiler_pni="pin2"\n')
with self.assertRaises(ValueError):
self.load('\n[inverter]\nboiler_pin="pin9"\n')
def test_status_listener_defaults_and_loopback_binding(self):
config, _ = self.load()
self.assertEqual(config.http.port, 8765)
self.assertEqual(config.http.bind, "127.0.0.1")
with self.assertRaisesRegex(ValueError, "127.0.0.1"):
self.load('\n[http]\nbind="0.0.0.0"\n')
def test_status_settings_can_disable_http_and_reject_invalid_paths(self):
config, _ = self.load('\n[http]\nenabled=false\n')
self.assertFalse(config.http.enabled)
with self.assertRaises(ValueError):
self.load('\n[http]\nbase_path="/../config"\n')
if __name__ == "__main__":
unittest.main()