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

+1
View File
@@ -0,0 +1 @@
+76
View File
@@ -0,0 +1,76 @@
"""Exercise the workflow's real rsync script with a mocked service manager."""
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
import tempfile
import textwrap
import unittest
@unittest.skipUnless(shutil.which("rsync") and shutil.which("flock"), "deployment tools are not installed")
class DeploymentTests(unittest.TestCase):
def test_deploy_preserves_runtime_and_archive_and_restarts_service(self):
workflow = (Path(__file__).resolve().parents[1] / ".gitea/workflows/deploy.yml").read_text()
deploy_step = workflow.split("- name: Deploy and restart\n", 1)[1]
script = textwrap.dedent(deploy_step.split("run: |\n", 1)[1])
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
checkout, deployment, binaries = [root / name for name in ("checkout", "deployment", "bin")]
for path in (checkout, deployment, binaries):
path.mkdir()
(checkout / "solar_relay").mkdir()
(checkout / "solar_relay" / "new.py").write_text("# new release\n")
(checkout / "requirements.txt").write_text("pyarrow==26.0.0\n")
(deployment / "obsolete.py").write_text("# obsolete\n")
protected = {
"config.toml": "private configuration",
".env": "private token",
"data/archive/date=2026-11-01/samples.parquet": "durable parquet observations",
"archive/date=2026-11-01/samples.parquet": "archived observations",
".venv/keep.txt": "runtime venv",
".git/keep.txt": "existing checkout metadata",
}
for relative, content in protected.items():
target = deployment / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
source = checkout / relative
source.parent.mkdir(parents=True, exist_ok=True)
source.write_text("must not replace local state")
runtime = deployment / ".venv/bin/python"
runtime.parent.mkdir(parents=True)
runtime.write_text('#!/bin/sh\nif [ "$1" = "-m" ] && [ "$2" = pip ]; then printf "pip %s\\n" "$*" >> "$SOLAR_DEPLOY_TEST_LOG"; exit 0; fi\nexec ' + shlex.quote(sys.executable) + ' "$@"\n')
runtime.chmod(0o755)
mocks = {
"sudo": '#!/bin/sh\n[ "$1" = "-n" ] || exit 99\nshift\nexec "$@"\n',
"systemctl": '#!/bin/sh\nprintf "%s\\n" "$*" >> "$SOLAR_DEPLOY_TEST_LOG"\nif [ "$1" = show ]; then printf "0\\n"; fi\n',
"sleep": "#!/bin/sh\nexit 0\n",
"curl": '#!/bin/sh\nprintf "curl %s\\n" "$*" >> "$SOLAR_DEPLOY_TEST_LOG"\n',
}
for name, content in mocks.items():
target = binaries / name
target.write_text(content)
target.chmod(0o755)
log = root / "systemctl.log"
env = {**os.environ, "DEPLOY_PATH": str(deployment), "SERVICE": "solar-relay.service",
"PATH": str(binaries) + os.pathsep + os.environ["PATH"], "SOLAR_DEPLOY_TEST_LOG": str(log),
"HEALTH_URL": "https://rpioffice.nest.lutran.ch/solar-relay/healthz"}
result = subprocess.run(["bash", "-c", script], cwd=checkout, env=env, capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((deployment / "solar_relay/new.py").exists())
self.assertFalse((deployment / "obsolete.py").exists())
for relative, content in protected.items():
self.assertEqual((deployment / relative).read_text(), content, relative)
self.assertIn("restart solar-relay.service", log.read_text())
self.assertIn("is-active --quiet solar-relay.service", log.read_text())
self.assertIn("pip -m pip install --only-binary=pyarrow -r", log.read_text())
self.assertLess(log.read_text().index("pip -m pip"), log.read_text().index("restart solar-relay.service"))
self.assertIn(env["HEALTH_URL"], log.read_text())
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from pathlib import Path
import tempfile
import unittest
import pyarrow.parquet as pq
from urllib.parse import parse_qs, urlsplit
from solar_relay.config import HistoryConfig, utc_text
from solar_relay.history import CHANNELS, HistoryRecovery, history_sample
from solar_relay.http import TransportError
from solar_relay.storage import Store
from tests.test_status import observed
SYSTEM = "73733052-6f10-47a5-9746-7f7e76ebcb8c"
def entry(when):
values = {"production": 100, "consumption": 120, "self_consumption": 80, "bought": 40, "sold": 20}
return {"logDateTime": utc_text(when), "logDuration": 300,
"channels": [{"channelName": channel, "channelType": "Energy", "unit": "Wh", "value": values[field]}
for field, channel in CHANNELS.items()]}
class FakeHistoryHttp:
def __init__(self, entries):
self.entries, self.requests = entries, []
def request(self, url, **kwargs):
self.requests.append((url, kwargs))
return {"pvSystemId": SYSTEM, "data": deepcopy(self.entries)}
class HistoryTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.store = Store(Path(self.directory.name) / "archive")
self.when = datetime(2026, 10, 30, 10, tzinfo=timezone.utc)
self.source = "test-house"
self.http = FakeHistoryHttp([entry(self.when + timedelta(minutes=offset)) for offset in [0, 5, 10]])
self.config = HistoryConfig(enabled=True, pv_system_id=SYSTEM)
self.recovery = HistoryRecovery(self.config, self.source, self.store, http=self.http, credentials=("id-test", "value-test"))
def recover(self):
return self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(minutes=15)), now=self.when + timedelta(hours=4))
def test_wifi_gap_recovers_only_missing_energy_without_inventing_boiler(self):
for offset in [0, 10]:
self.store.add(observed(self.when + timedelta(minutes=offset)))
self.assertEqual(self.recover(), 1)
records = list(self.store.records())
recovered = [s for s in records if "history_interval" in s]
self.assertEqual(len(recovered), 1)
self.assertEqual(recovered[0]["history_interval"]["energy_wh"]["production"], 100)
self.assertIsNone(recovered[0]["readings"]["boiler_enabled"])
self.assertNotIn("boiler", recovered[0]["source_timestamps"])
self.assertIn("boiler", recovered[0]["errors"])
url, kwargs = self.http.requests[0]
self.assertEqual(urlsplit(url).hostname, "swqapi.solarweb.com")
self.assertEqual(kwargs["extra_headers"], {"AccessKeyId": "id-test", "AccessKeyValue": "value-test"})
self.assertNotIn("value-test", url)
self.assertEqual(self.recover(), 0)
self.assertEqual(len(self.http.requests), 1)
def test_cloud_energy_does_not_mark_boiler_or_meter_history_complete(self):
self.assertEqual(self.recover(), 3)
rows = self.store.coverage(self.source, "UTC", 1, self.when + timedelta(days=1))
day = rows[1]
self.assertEqual(day["energy_complete"], 3)
self.assertEqual(day["boiler_complete"], 0)
self.assertEqual(day["meter_complete"], 0)
self.assertEqual(day["complete"], 0)
row = pq.ParquetFile(next(self.store.path.glob("date=*/samples.parquet"))).read().to_pylist()[0]
self.assertEqual(row["record_kind"], "history")
self.assertEqual(row["interval_energy_wh"]["production"], 100)
def test_partial_cloud_records_remain_missing_for_retry(self):
self.http.entries[1]["channels"][0]["value"] = None
self.assertEqual(self.recover(), 2)
self.assertEqual(self.recover(), 0)
self.http.entries[1] = entry(self.when + timedelta(minutes=5))
self.assertEqual(self.recover(), 1)
def test_recovered_identifiers_are_stable_for_duplicate_delivery(self):
first = history_sample(entry(self.when), self.source, SYSTEM)
second = history_sample(entry(self.when), self.source, SYSTEM)
self.assertEqual(first, second)
def test_non_five_minute_and_bad_units_are_rejected(self):
for change in [{"logDuration": 599}, {"logDateTime": utc_text(self.when + timedelta(seconds=1))}]:
value = entry(self.when)
value.update(change)
with self.assertRaises(ValueError):
history_sample(value, self.source, SYSTEM)
value = entry(self.when)
value["channels"][0]["unit"] = "W"
with self.assertRaises(ValueError):
history_sample(value, self.source, SYSTEM)
def test_missing_private_credentials_fail_before_requests(self):
with self.assertRaisesRegex(ValueError, "SOLARWEB_ACCESS_KEY"):
HistoryRecovery(self.config, self.source, self.store, credentials=("", ""))
def test_requests_are_split_at_utc_midnight_even_across_dst(self):
self.http.entries = []
self.recovery.recover("2026-10-24T22:00:00Z", "2026-10-26T00:00:00Z", now=datetime(2026, 10, 27, tzinfo=timezone.utc))
self.assertEqual(len(self.http.requests), 2)
for url, _ in self.http.requests:
query = parse_qs(urlsplit(url).query)
start, end = [datetime.fromisoformat(query[name][0]) for name in ("from", "to")]
self.assertLessEqual((end - start).total_seconds(), 86400)
def test_recent_intervals_wait_for_the_hourly_cloud_upload(self):
self.http.entries = []
self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(hours=4)), now=self.when + timedelta(hours=4))
query = parse_qs(urlsplit(self.http.requests[0][0]).query)
self.assertEqual(query["to"], [utc_text(self.when + timedelta(hours=2))])
if __name__ == "__main__":
unittest.main()
+107
View File
@@ -0,0 +1,107 @@
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import pyarrow.parquet as pq
from solar_relay.cli import next_poll_at
from solar_relay.storage import Store, StoreError
from tests.test_status import observed
class ParquetTests(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)
self.now = datetime(2026, 11, 1, 12, tzinfo=timezone.utc)
def test_real_parquet_has_typed_readings_and_original_payload(self):
value = observed(self.now)
value["readings"]["boiler_enabled"] = False
self.store.add(value)
path = next(self.path.glob("date=*/samples.parquet"))
self.assertEqual(path.read_bytes()[:4], b"PAR1")
row = pq.ParquetFile(path).read().to_pylist()[0]
self.assertEqual(row["production_ac_w"], 2000)
self.assertIs(row["boiler_enabled"], False)
self.assertIsNone(row["uploaded_at"])
self.assertEqual(json.loads(row["payload"]), value)
self.assertEqual(self.store.status()["format"], "parquet")
def test_retention_uses_ack_time_and_never_deletes_pending(self):
items = [observed(self.now - timedelta(days=90, minutes=index)) for index in range(4)]
for item in items:
self.store.add(item)
self.store.acknowledge([items[0]["id"]], now=self.now - timedelta(days=31))
self.store.acknowledge([items[1]["id"]], now=self.now - timedelta(days=30))
self.store.acknowledge([items[2]["id"]], now=self.now)
self.assertEqual(self.store.prune(now=self.now), 1)
self.assertEqual({s["id"] for s in self.store.records()}, {s["id"] for s in items[1:]})
self.assertEqual(self.store.pending(100), [items[3]])
self.assertEqual(self.store.prune(now=self.now + timedelta(days=31)), 2)
self.assertEqual(list(self.store.records()), [items[3]])
def test_replay_cancels_expiry_until_the_next_acknowledgement(self):
value = observed(self.now)
self.store.add(value)
self.store.acknowledge([value["id"]], now=self.now - timedelta(days=40))
self.store.replay()
self.assertEqual(self.store.prune(now=self.now), 0)
self.store.acknowledge([value["id"]], now=self.now)
self.assertEqual(self.store.prune(now=self.now), 0)
def test_interrupted_atomic_write_preserves_the_previous_partition(self):
first, second = observed(self.now), observed(self.now + timedelta(minutes=5))
self.store.add(first)
with patch("solar_relay.storage.os.replace", side_effect=OSError("simulated write failure")):
with self.assertRaises(OSError):
self.store.add(second)
self.assertEqual(list(Store(self.path).records()), [first])
self.assertEqual(list(self.path.glob("date=*/.samples-*.tmp")), [])
def test_two_store_instances_cannot_lose_concurrent_records(self):
other = Store(self.path)
values = [observed(self.now + timedelta(minutes=index * 5)) for index in range(12)]
def add(pair):
index, value = pair
(other if index % 2 else self.store).add(value)
with ThreadPoolExecutor(max_workers=2) as workers:
list(workers.map(add, enumerate(values)))
self.assertEqual({value["id"] for value in self.store.records()}, {value["id"] for value in values})
def test_pruning_an_entire_day_removes_its_parquet_file(self):
value = observed(self.now - timedelta(days=60))
self.store.add(value)
self.store.acknowledge([value["id"]], now=self.now - timedelta(days=31))
self.assertEqual(self.store.prune(now=self.now), 1)
self.assertEqual(list(self.path.glob("date=*/samples.parquet")), [])
self.assertEqual(self.store.status()["samples"], 0)
def test_corrupt_archive_fails_without_silently_dropping_records(self):
value = observed(self.now)
self.store.add(value)
next(self.path.glob("date=*/samples.parquet")).write_bytes(b"broken")
with self.assertRaises(StoreError):
self.store.pending(100)
def test_archive_path_requires_a_directory(self):
occupied = self.path.parent / "existing-file"
occupied.write_bytes(b"existing data")
with self.assertRaises(StoreError):
Store(occupied)
self.assertEqual(occupied.read_bytes(), b"existing data")
def test_next_poll_is_aligned_to_the_five_minute_clock(self):
self.assertEqual(next_poll_at(301, 300), 600)
self.assertEqual(next_poll_at(599.5, 300), 600)
self.assertEqual(next_poll_at(600, 300), 900)
if __name__ == "__main__":
unittest.main()
+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()
+114
View File
@@ -0,0 +1,114 @@
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from urllib.parse import urlsplit
from solar_relay.cli import _run
from solar_relay.config import CollectorConfig, Config, HttpConfig, InverterConfig, UploadConfig, utc_text
from solar_relay.fronius import FroniusClient
from solar_relay.http import TransportError
from solar_relay.storage import Store
from tests.test_relay import RESPONSES, server
from tests.test_schedule import Clock
POWER_PATH = "/solar_api/v1/GetPowerFlowRealtimeData.fcgi"
METER_PATH = "/solar_api/v1/GetMeterRealtimeData.cgi"
BOILER_PATH = "/api/status/emrs"
class ScriptedHttp:
def __init__(self, clock):
self.clock = clock
self.requests = []
self.failures = {POWER_PATH: [503, 503, 503]}
def request(self, url):
path = urlsplit(url).path
self.requests.append((self.clock.now, path))
failures = self.failures.get(path, [])
if failures:
raise TransportError(f"HTTP {failures.pop(0)}")
response = deepcopy(RESPONSES[path])
if "Head" in response:
response["Head"]["Timestamp"] = utc_text(datetime.fromtimestamp(self.clock.now, timezone.utc))
return response
class RetryTests(unittest.TestCase):
def test_http_errors_retry_only_the_failed_endpoint_and_stop_on_success(self):
for retries in (1, 3):
with self.subTest(retries=retries), server() as (httpd, base):
httpd.get_failures[POWER_PATH] = [503] * retries
client = FroniusClient(InverterConfig(base_url=base, min_request_interval_seconds=0))
with self.assertLogs("solar_relay.fronius", level="WARNING") as messages:
sample = client.collect("test-house")
self.assertEqual(sample["errors"], {})
self.assertEqual(sample["readings"]["production_ac_w"], 2000)
self.assertEqual(httpd.get_counts, {POWER_PATH: retries + 1, METER_PATH: 1, BOILER_PATH: 1})
self.assertEqual(len(messages.output), retries)
def test_giving_up_after_three_retries_preserves_other_sources(self):
for failures in ([500, 502, 503, 504, 503], [404] * 5):
with self.subTest(statuses=failures), server() as (httpd, base):
httpd.get_failures[BOILER_PATH] = list(failures)
client = FroniusClient(InverterConfig(base_url=base, min_request_interval_seconds=0))
with self.assertLogs("solar_relay.fronius", level="WARNING"):
sample = client.collect("test-house")
self.assertEqual(httpd.get_counts, {POWER_PATH: 1, METER_PATH: 1, BOILER_PATH: 4})
self.assertEqual(len(httpd.get_failures[BOILER_PATH]), 1)
self.assertEqual(sample["errors"], {"boiler": f"HTTP {failures[3]}"})
self.assertIsNone(sample["readings"]["boiler_enabled"])
self.assertEqual(sample["readings"]["production_ac_w"], 2000)
self.assertEqual(sample["readings"]["grid_import_total_wh"], 50000)
def test_retry_attempts_keep_the_four_second_request_spacing(self):
clock = Clock(0)
http = ScriptedHttp(clock)
client = FroniusClient(InverterConfig(), http=http)
with patch("solar_relay.fronius.time.monotonic", side_effect=lambda: clock.now), \
patch("solar_relay.fronius.time.sleep", side_effect=clock.wait), \
self.assertLogs("solar_relay.fronius", level="WARNING"):
result = client.collect("test-house")
self.assertEqual(result["errors"], {})
self.assertEqual([stamp for stamp, _ in http.requests], [0, 4, 8, 12, 16, 20])
def test_retries_preserve_clock_boundaries_and_one_saved_observation_per_poll(self):
origin = datetime(2026, 11, 1, 12, tzinfo=timezone.utc).timestamp()
clock = Clock(origin + 180) # Start at 12:03; first observation must be at 12:05.
http = ScriptedHttp(clock)
client = FroniusClient(InverterConfig(), http=http)
with tempfile.TemporaryDirectory() as directory:
store = Store(Path(directory) / "archive")
config = Config(CollectorConfig("test-house", store.path), InverterConfig(), UploadConfig(), HttpConfig(enabled=False))
added = []
original_add = store.add
def add(sample):
original_add(sample)
added.append(sample)
if len(added) == 3:
clock.set()
with patch("solar_relay.cli.threading.Event", return_value=clock), \
patch("solar_relay.cli.time.time", side_effect=lambda: clock.now), \
patch("solar_relay.cli.time.monotonic", side_effect=lambda: clock.now), \
patch("solar_relay.fronius.time.sleep", side_effect=clock.wait), \
patch("solar_relay.fronius.datetime", wraps=datetime) as dates, \
patch("solar_relay.cli.signal.signal"), \
patch("solar_relay.cli.FroniusClient", return_value=client), \
patch.object(store, "add", side_effect=add), \
self.assertLogs("solar_relay.fronius", level="WARNING"):
dates.now.side_effect = lambda zone: datetime.fromtimestamp(clock.now, zone)
self.assertEqual(_run(config, store), 0)
records = list(store.records())
self.assertEqual(len(records), 3)
self.assertEqual([datetime.fromisoformat(s["observed_at"]).timestamp() for s in records],
[origin + 300, origin + 600, origin + 900])
self.assertTrue(all(not record["errors"] for record in records))
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -0,0 +1,91 @@
"""Exercise the real recording loop with an advancing clock instead of real waits."""
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
from solar_relay.cli import _run
from solar_relay.config import CollectorConfig, Config, HttpConfig, InverterConfig, UploadConfig, load_config
class Clock:
def __init__(self, now):
self.now = now
self.stopped = False
self.waits = []
def is_set(self):
return self.stopped
def set(self):
self.stopped = True
def wait(self, delay):
self.waits.append((self.now, delay))
if not self.stopped:
self.now += delay
return self.stopped
class ScheduleTests(unittest.TestCase):
def run_collector(self, start, count=3, duration=8, cancel=False):
clock = Clock(start)
recorded = []
config = Config(CollectorConfig("test-house", Path("unused")), InverterConfig(), UploadConfig(), HttpConfig(enabled=False))
store = Mock()
store.prune.return_value = 0
def record(*args):
recorded.append(clock.now)
clock.now += duration
if len(recorded) == count:
clock.set()
def cancel_wait(delay):
clock.set()
return True
if cancel:
clock.wait = cancel_wait
with patch("solar_relay.cli.threading.Event", return_value=clock), \
patch("solar_relay.cli.time.time", side_effect=lambda: clock.now), \
patch("solar_relay.cli.time.monotonic", side_effect=lambda: clock.now), \
patch("solar_relay.cli.signal.signal"), \
patch("solar_relay.cli.FroniusClient"), \
patch("solar_relay.cli._record", side_effect=record):
self.assertEqual(_run(config, store), 0)
return recorded, clock
def test_startup_waits_then_records_once_on_each_boundary(self):
recorded, clock = self.run_collector(301)
self.assertEqual(recorded, [600, 900, 1200])
self.assertEqual(clock.waits[0], (301, 299))
self.assertTrue(all(stamp % 300 == 0 for stamp in recorded))
def test_restart_in_the_same_interval_adds_no_extra_observation(self):
before, _ = self.run_collector(590, count=1)
after, _ = self.run_collector(615, count=2)
self.assertEqual(before + after, [600, 900, 1200])
def test_slow_collection_skips_missed_boundaries_without_catching_up(self):
recorded, _ = self.run_collector(301, count=2, duration=370)
self.assertEqual(recorded, [600, 1200])
def test_shutdown_while_waiting_does_not_take_a_final_observation(self):
recorded, _ = self.run_collector(301, cancel=True)
self.assertEqual(recorded, [])
def test_configuration_requires_exactly_five_minutes(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "config.toml"
for interval in (30, 60, 299, 301, 600):
path.write_text(f"[collector]\npoll_interval_seconds = {interval}\n")
with self.assertRaisesRegex(ValueError, "must be 300"):
load_config(path)
path.write_text("[collector]\npoll_interval_seconds = 300\n")
self.assertEqual(load_config(path).collector.poll_interval_seconds, 300)
if __name__ == "__main__":
unittest.main()
+196
View File
@@ -0,0 +1,196 @@
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
import json
import socket
import subprocess
import sys
import tempfile
import time
import unittest
from urllib.error import HTTPError, URLError
from urllib.request import ProxyHandler, Request, build_opener
from zoneinfo import ZoneInfo
from solar_relay.config import Config, CollectorConfig, HttpConfig, InverterConfig, UploadConfig, utc_text
from solar_relay.coverage import daily_coverage, sample_coverage
from solar_relay.http import NoRedirects
from solar_relay.status import StatusServer
from solar_relay.storage import Store
from tests.test_relay import sample, server
def observed(when):
value = sample()
value["observed_at"] = utc_text(when)
value["source_timestamps"] = {source: value["observed_at"] for source in ("powerflow", "meter", "boiler")}
return value
def rows_for_day(day):
start = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("Europe/Zurich"))
end = start + timedelta(days=1)
first, last = round(start.timestamp() * 1000), round(end.timestamp() * 1000)
return [(stamp, stamp, 1, stamp, 1, stamp, 1) for stamp in range(first, last + 1, 300_000)], end
class CoverageTests(unittest.TestCase):
def test_full_day_includes_all_288_intervals(self):
rows, end = rows_for_day("2026-10-30")
result = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))
yesterday = result[1]
self.assertEqual(yesterday["expected"], 288)
self.assertEqual(yesterday["complete"], 288)
self.assertEqual(yesterday["status"], "complete")
self.assertEqual(result[0]["elapsed"], 0)
self.assertTrue(all(mask == -1 for mask in result[0]["slots"]))
def test_dst_days_expect_276_and_300_intervals(self):
for date, expected in [("2026-03-29", 276), ("2026-10-25", 300)]:
with self.subTest(date=date):
rows, end = rows_for_day(date)
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
self.assertEqual(yesterday["expected"], expected)
self.assertEqual(yesterday["complete"], expected)
def test_missing_five_minute_extractions_remain_missing(self):
rows, end = rows_for_day("2026-10-30")
del rows[10:13]
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
self.assertEqual(yesterday["complete"], 285)
self.assertEqual(yesterday["missing"], 3)
self.assertEqual(yesterday["status"], "partial")
def test_energy_failure_does_not_erase_meter_or_boiler_coverage(self):
rows, end = rows_for_day("2026-10-30")
stamp = rows[1][0]
rows[1] = (stamp, stamp, 0, stamp, 1, stamp, 1)
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
self.assertEqual(yesterday["slots"][1], 6)
self.assertEqual(yesterday["energy_complete"], 287)
self.assertEqual(yesterday["meter_complete"], 288)
self.assertEqual(yesterday["boiler_complete"], 288)
self.assertEqual(yesterday["missing"], 1)
def test_source_errors_stale_readings_and_false_boiler_state(self):
when = datetime(2026, 10, 30, tzinfo=timezone.utc)
value = observed(when)
value["readings"]["boiler_enabled"] = False
self.assertEqual(sample_coverage(value)[-1], 1)
value["errors"] = {"boiler": "unavailable"}
self.assertEqual(sample_coverage(value)[-1], 0)
value["source_timestamps"]["powerflow"] = utc_text(when - timedelta(seconds=120))
self.assertEqual(sample_coverage(value)[4], 0)
def test_parquet_coverage_uses_the_selected_source_and_stored_extractions(self):
with tempfile.TemporaryDirectory() as directory:
archive = Store(Path(directory) / "archive")
start = datetime(2026, 10, 30, tzinfo=ZoneInfo("Europe/Zurich"))
for seconds in range(0, 301, 30):
value = observed(start + timedelta(seconds=seconds))
archive.add(value)
result = archive.coverage(value["source_id"], "Europe/Zurich", 1, start + timedelta(days=1, minutes=3))
self.assertEqual(result[1]["complete"], 2)
other = archive.coverage("another-source", "Europe/Zurich", 1, start + timedelta(days=1, minutes=3))
self.assertEqual(other[1]["complete"], 0)
class StatusHttpTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.store = Store(Path(self.directory.name) / "archive")
self.config = Config(CollectorConfig("test-house", self.store.path), InverterConfig(), UploadConfig(), HttpConfig(port=0))
self.status = StatusServer(self.config, self.store)
self.status.start()
self.addCleanup(self.status.close)
self.base = "http://127.0.0.1:" + str(self.status.address[1])
self.http = build_opener(ProxyHandler({}), NoRedirects())
def get(self, path, *, method="GET"):
try:
result = self.http.open(Request(self.base + path, method=method), timeout=3)
except HTTPError as error:
result = error
with result:
return result.code, result.headers, result.read()
def test_page_assets_prefix_and_health_routes(self):
for path in ["/", "/solar-relay/", "/assets/status.css", "/solar-relay/assets/status.js"]:
status, _, body = self.get(path)
self.assertEqual(status, 200, path)
self.assertTrue(body)
status, headers, body = self.get("/solar-relay")
self.assertEqual(status, 308)
self.assertEqual(headers["Location"], "/solar-relay/")
self.assertEqual(json.loads(self.get("/solar-relay/healthz")[2])["status"], "ok")
def test_status_reports_extraction_and_backlog_without_raw_data_or_secrets(self):
value = observed(datetime.now(timezone.utc))
value["readings"]["boiler_enabled"] = False
self.store.add(value)
status, headers, body = self.get("/solar-relay/api/status?days=7")
result = json.loads(body)
self.assertEqual(status, 200)
self.assertEqual(headers["Cache-Control"], "no-store")
self.assertEqual(result["status"], "ok")
self.assertEqual(result["collector"]["last_attempt"], value["observed_at"])
self.assertEqual(result["collector"]["last_success"], value["observed_at"])
self.assertEqual(result["archive"]["pending"], 1)
self.assertEqual(len(result["days"]), 8)
for forbidden in ["production_w", "raw", "database", self.directory.name]:
self.assertNotIn(forbidden, body.decode())
def test_source_errors_do_not_expose_exception_messages(self):
value = observed(datetime.now(timezone.utc))
value["errors"] = {"boiler": "secret canary must not appear in HTTP"}
self.store.add(value)
result = json.loads(self.get("/api/status")[2])
self.assertEqual(result["status"], "partial")
self.assertEqual(result["collector"]["source_errors"], ["boiler"])
self.assertNotIn("secret canary", json.dumps(result))
def test_only_explicit_read_only_routes_are_available(self):
for path in ["/config.toml", "/.env", "/data/archive/date=2026-11-01/samples.parquet", "/../config.toml", "/solar-relay/.git/config"]:
self.assertEqual(self.get(path)[0], 404, path)
self.assertEqual(self.get("/api/status?days=91")[0], 400)
self.assertEqual(self.get("/api/status?days=1&days=2")[0], 400)
self.assertEqual(self.get("/api/status", method="POST")[0], 405)
status, headers, body = self.get("/solar-relay/", method="HEAD")
self.assertEqual(status, 200)
self.assertGreater(int(headers["Content-Length"]), 0)
self.assertEqual(body, b"")
def test_run_command_starts_and_stops_the_http_listener(self):
with server() as (_, inverter):
with socket.socket() as free:
free.bind(("127.0.0.1", 0))
port = free.getsockname()[1]
path = Path(self.directory.name) / "config.toml"
path.write_text(f'[collector]\nsource_id="run-test"\narchive="run-archive"\n[inverter]\nbase_url="{inverter}"\n[http]\nport={port}\n')
# Keep the first scheduled poll in the future regardless of the real
# clock. Scheduling itself is exercised with a simulated clock separately.
entry = 'import sys; from solar_relay import cli; cli.next_poll_at = lambda now, interval: now + 300; sys.exit(cli.main())'
process = subprocess.Popen([sys.executable, "-c", entry, "--config", str(path), "run"], cwd=Path(__file__).resolve().parents[1], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
for attempt in range(30):
try:
with self.http.open(f"http://127.0.0.1:{port}/healthz", timeout=1) as response:
self.assertEqual(response.code, 200)
break
except URLError:
time.sleep(0.1)
else:
self.fail("Status listener did not start")
with self.http.open(f"http://127.0.0.1:{port}/api/status", timeout=1) as response:
snapshot = json.load(response)
self.assertEqual(snapshot["status"], "waiting")
self.assertEqual(snapshot["archive"]["samples"], 0)
process.terminate()
_, stderr = process.communicate(timeout=5)
self.assertEqual(process.returncode, 0, stderr)
self.assertEqual(Store(path.parent / "run-archive").status()["samples"], 0)
finally:
if process.poll() is None:
process.kill()
process.communicate(timeout=5)