Swap to private solarweb access
Deploy Solar Relay / deploy (push) Successful in 56s

This commit is contained in:
franzz committed 2026-11-02 18:06:01 +01:00
1 parent 646ba3be81
commit aae5b73581
14 files changed
+883 -142

No files matched your search

+142 -30
View File
@@ -1,14 +1,16 @@
from copy import deepcopy
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
import pyarrow.parquet as pq
from urllib.parse import parse_qs, urlsplit
from zoneinfo import ZoneInfo
from solar_relay.config import HistoryConfig, utc_text
from solar_relay.history import CHANNELS, HistoryRecovery, history_sample
from solar_relay.history import CHANNELS, HistoryRecovery, chart_entries, history_sample
from solar_relay.http import TransportError
from solar_relay.solarweb import ChartDataError
from solar_relay.storage import Store
from tests.test_status import observed
@@ -22,13 +24,16 @@ def entry(when):
for field, channel in CHANNELS.items()]}
class FakeHistoryHttp:
def __init__(self, entries):
self.entries, self.requests = entries, []
class FakeCharts:
def __init__(self, when):
self.points = {dataset: {int((when + timedelta(minutes=offset)).timestamp() * 1000): watts
for offset in [0, 5, 10]}
for dataset, watts in [("production", 1200), ("consumption", 1440)]}
self.requests = []
def request(self, url, **kwargs):
self.requests.append((url, kwargs))
return {"pvSystemId": SYSTEM, "data": deepcopy(self.entries)}
def get_chart(self, system_id, dataset, date):
self.requests.append((system_id, dataset, date))
return dict(self.points[dataset])
class HistoryTests(unittest.TestCase):
@@ -38,9 +43,9 @@ class HistoryTests(unittest.TestCase):
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.client = FakeCharts(self.when)
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"))
self.recovery = HistoryRecovery(self.config, self.source, self.store, client=self.client)
def recover(self):
return self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(minutes=15)), now=self.when + timedelta(hours=4))
@@ -56,12 +61,12 @@ class HistoryTests(unittest.TestCase):
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.client.requests, [(SYSTEM, dataset, self.when.date())
for dataset in ("production", "consumption")])
self.assertEqual(recovered[0]["raw"]["solarweb_history"]["chart_power_w"],
{"production": 1200, "consumption": 1440})
self.assertEqual(self.recover(), 0)
self.assertEqual(len(self.http.requests), 1)
self.assertEqual(len(self.client.requests), 2)
def test_cloud_energy_does_not_mark_boiler_or_meter_history_complete(self):
self.assertEqual(self.recover(), 3)
@@ -76,10 +81,11 @@ class HistoryTests(unittest.TestCase):
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
stamp = int((self.when + timedelta(minutes=5)).timestamp() * 1000)
del self.client.points["production"][stamp]
self.assertEqual(self.recover(), 2)
self.assertEqual(self.recover(), 0)
self.http.entries[1] = entry(self.when + timedelta(minutes=5))
self.client.points["production"][stamp] = 1200
self.assertEqual(self.recover(), 1)
def test_recovered_identifiers_are_stable_for_duplicate_delivery(self):
@@ -99,23 +105,129 @@ class HistoryTests(unittest.TestCase):
history_sample(value, self.source, SYSTEM)
def test_missing_private_credentials_fail_before_requests(self):
with self.assertRaisesRegex(ValueError, "SOLARWEB_ACCESS_KEY"):
with self.assertRaisesRegex(ValueError, "SOLARWEB_USERNAME and SOLARWEB_PASSWORD"):
HistoryRecovery(self.config, self.source, self.store, credentials=("", ""))
def test_requests_are_split_at_utc_midnight_even_across_dst(self):
self.http.entries = []
def test_config_credentials_take_precedence_over_environment(self):
config = replace(self.config, username="private@example.test", password="synthetic password é &")
with patch("solar_relay.history.os.environ", {"SOLARWEB_USERNAME": "env@example.test", "SOLARWEB_PASSWORD": "env-password"}), \
patch("solar_relay.history.SolarWebClient", return_value=self.client) as client:
recovery = HistoryRecovery(config, self.source, self.store)
self.assertEqual(recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(minutes=15)),
now=self.when + timedelta(hours=4)), 3)
client.assert_called_once_with("private@example.test", "synthetic password é &", timeout=config.request_timeout_seconds)
def test_empty_config_credentials_use_environment_fallback(self):
config = replace(self.config, username_env="BACKUP_USER", password_env="BACKUP_PASSWORD")
with patch("solar_relay.history.os.environ", {"BACKUP_USER": "env@example.test", "BACKUP_PASSWORD": "env-password"}), \
patch("solar_relay.history.SolarWebClient", return_value=self.client) as client:
HistoryRecovery(config, self.source, self.store)
client.assert_called_once_with("env@example.test", "env-password", timeout=config.request_timeout_seconds)
def test_partial_config_credentials_never_mix_with_environment(self):
for changes in [{"username": "private@example.test"}, {"password": "private-password"}]:
with self.subTest(changes=changes), \
patch("solar_relay.history.os.environ", {"SOLARWEB_USERNAME": "env@example.test", "SOLARWEB_PASSWORD": "env-password"}), \
patch("solar_relay.history.SolarWebClient") as client:
with self.assertRaisesRegex(ValueError, "history.username and history.password"):
HistoryRecovery(replace(self.config, **changes), self.source, self.store)
client.assert_not_called()
def test_requests_use_local_dates_across_dst(self):
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)
self.assertEqual([date.isoformat() for _, dataset, date in self.client.requests if dataset == "production"],
["2026-10-25", "2026-10-26"])
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))])
for points in self.client.points.values():
points.clear()
for offset in [0, 115, 120, 235]:
points[int((self.when + timedelta(minutes=offset)).timestamp() * 1000)] = 1200
self.assertEqual(self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(hours=4)),
now=self.when + timedelta(hours=4)), 2)
def test_automatic_recovery_checks_retention_before_first_observation_and_respects_cutoff(self):
now = datetime(2026, 11, 2, 12, 2, 17, tzinfo=timezone.utc)
cutoff = datetime(2026, 10, 3, 12, 5, tzinfo=timezone.utc)
latest = datetime(2026, 11, 2, 10, tzinfo=timezone.utc)
recoverable = [cutoff, now.replace(minute=0, second=0) - timedelta(days=25), latest - timedelta(minutes=5)]
points = {int(when.timestamp() * 1000): 1200 for when in
[cutoff - timedelta(minutes=5), *recoverable, latest]}
self.client.points = {"production": points, "consumption": points}
self.store.add(observed(now.replace(minute=0, second=0)))
self.assertEqual(self.recovery.recover(now=now), 3)
recovered = [record for record in self.store.records() if "history_interval" in record]
self.assertEqual([record["observed_at"] for record in recovered], [utc_text(when) for when in recoverable])
self.assertEqual(min(day for _, _, day in self.client.requests), cutoff.date())
def test_automatic_recovery_checks_entirely_empty_days_and_archives(self):
self.store.retention_days = 2
self.assertEqual(self.recovery.recover(now=self.when + timedelta(days=2)), 3)
self.assertEqual({day for _, _, day in self.client.requests},
{(self.when + timedelta(days=offset)).date() for offset in range(3)})
def test_automatic_recovery_supports_retention_longer_than_manual_limit(self):
self.store.retention_days = 45
now = self.when + timedelta(days=40)
self.assertEqual(self.recovery.recover(now=now), 3)
self.assertEqual(min(day for _, _, day in self.client.requests), (now - timedelta(days=45)).date())
with self.assertRaisesRegex(ValueError, "at most 30 days"):
self.recovery.recover(utc_text(now - timedelta(days=45)), utc_text(now), now=now)
def test_complete_retention_window_does_not_request_solarweb(self):
now = self.when + timedelta(days=1)
lower = int(self.when.timestamp())
upper = int((now - timedelta(seconds=self.config.lag_seconds)).timestamp())
store = Mock(retention_days=1)
store.energy_slots.return_value = set(range(lower, upper, 300))
recovery = HistoryRecovery(self.config, self.source, store, client=self.client)
self.assertEqual(recovery.recover(now=now), 0)
self.assertEqual(self.client.requests, [])
store.add.assert_not_called()
def test_unavailable_cloud_day_does_not_block_newer_missing_days(self):
self.store.retention_days = 2
available = self.when + timedelta(days=1)
self.client.points = {dataset: {int(available.timestamp() * 1000): 1200}
for dataset in ("production", "consumption")}
get_chart = self.client.get_chart
def charts(system, dataset, day):
if day == self.when.date():
raise ChartDataError("synthetic secret response must not be logged")
return get_chart(system, dataset, day)
with patch.object(self.client, "get_chart", side_effect=charts), self.assertLogs("solar_relay.history", level="WARNING") as logs:
self.assertEqual(self.recovery.recover(now=self.when + timedelta(days=2)), 1)
self.assertNotIn("synthetic secret response", " ".join(logs.output))
self.assertEqual(next(self.store.records())["observed_at"], utc_text(available))
def test_login_or_transport_failure_stops_the_scan_without_repeated_attempts(self):
with patch.object(self.client, "get_chart", side_effect=TransportError("Login failed")) as client:
with self.assertRaisesRegex(TransportError, "Login failed"):
self.recovery.recover(now=self.when + timedelta(days=2))
self.assertEqual(client.call_count, 1)
def test_chart_conversion_matches_solar_and_retains_provenance(self):
for generated, consumed in [(1200, 1440), (2400, 1200), (0, 0)]:
with self.subTest(generated=generated, consumed=consumed):
stamp = int(self.when.timestamp() * 1000)
record = next(chart_entries({stamp: generated}, {stamp: consumed}))
values = history_sample(record, self.source, SYSTEM)["history_interval"]["energy_wh"]
self.assertEqual(values, {"production": generated / 12, "consumption": consumed / 12,
"self_consumption": min(generated, consumed) / 12,
"bought": max(0, consumed - generated) / 12,
"sold": max(0, generated - consumed) / 12})
self.assertEqual(record["energy_method"], "constant_power_over_five_minutes")
def test_dst_days_recover_every_actual_five_minute_slot(self):
for day, expected in [("2026-03-29", 276), ("2026-10-25", 300)]:
with self.subTest(day=day):
start = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("Europe/Zurich"))
end = start + timedelta(days=1)
points = {stamp: 1200 for stamp in range(int(start.timestamp() * 1000), int(end.timestamp() * 1000), 300_000)}
self.client.points = {"production": points, "consumption": points}
self.assertEqual(self.recovery.recover(utc_text(start), utc_text(end), now=end + timedelta(days=1)), expected)
if __name__ == "__main__":