Reshape boiler data
Deploy Solar Relay / deploy (push) Successful in 1m7s

This commit is contained in:
franzz committed 2026-11-03 01:48:01 +01:00
1 parent 9e0230731b
commit e7905c667d
10 files changed
+224 -27

No files matched your search

+81 -1
View File
@@ -14,7 +14,7 @@ from unittest.mock import Mock, 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.fronius import FroniusClient, boiler_threshold, powerflow_readings
from solar_relay.http import JsonHttp, TransportError
from solar_relay.storage import Store
from solar_relay.upload import Uploader, UploadError
@@ -36,10 +36,12 @@ METER = api({"0": {
"EnergyReal_WAC_Sum_Consumed": 50000, "EnergyReal_WAC_Sum_Produced": 80000,
}})
BOILER = {"pin1": {"state": True, "reason": "PSTR_EMRS_REASON_3"}}
BOILER_CONFIG = {"rules": {"pin1": {"mode": "pgrid", "thresholdOn": -2000, "thresholdOff": -500}}}
RESPONSES = {
"/solar_api/v1/GetPowerFlowRealtimeData.fcgi": POWER,
"/solar_api/v1/GetMeterRealtimeData.cgi": METER,
"/api/status/emrs": BOILER,
"/api/config/emrs": BOILER_CONFIG,
}
@@ -138,6 +140,8 @@ class NormalizationTests(unittest.TestCase):
self.assertEqual(readings["grid_export_w"], 0)
self.assertEqual(readings["grid_import_total_wh"], 50000)
self.assertTrue(readings["boiler_enabled"])
self.assertEqual(readings["boiler_threshold_w"], 2000)
self.assertEqual(readings["boiler_threshold_source"], "api")
self.assertEqual(result["errors"], {})
self.assertEqual(result["raw"]["powerflow"], POWER)
self.assertEqual(result["source_timestamps"]["powerflow"], "2026-11-01T12:00:00.000Z")
@@ -167,6 +171,27 @@ class NormalizationTests(unittest.TestCase):
self.assertIs(result["readings"]["boiler_enabled"], False)
self.assertNotIn("boiler", result["errors"])
def test_configured_threshold_is_used_when_settings_require_authentication(self):
responses = deepcopy(RESPONSES)
responses["/api/config/emrs"] = TransportError("HTTP 401")
result = FroniusClient(InverterConfig(min_request_interval_seconds=0, boiler_threshold_w=2500), http=FakeHttp(responses)).collect("test")
self.assertTrue(result["readings"]["boiler_enabled"])
self.assertEqual(result["readings"]["boiler_threshold_w"], 2500)
self.assertEqual(result["readings"]["boiler_threshold_source"], "config")
self.assertEqual(result["errors"], {})
def test_settings_are_matched_to_the_configured_pin_and_bad_values_use_fallback(self):
for value in (None, 0, True, "2000", float("nan"), 100_000_000):
with self.subTest(value=value):
responses = deepcopy(RESPONSES)
responses["/api/config/emrs"]["rules"]["pin1"]["thresholdOn"] = value
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
self.assertEqual(result["readings"]["boiler_threshold_w"], 2000)
self.assertEqual(result["readings"]["boiler_threshold_source"], "config")
settings = {"rules": {"pin1": {"mode": "pgrid", "thresholdOn": -1500},
"pin2": {"mode": "ppv", "thresholdOn": 3200}}}
self.assertEqual(boiler_threshold(settings, "pin2"), 3200)
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
@@ -265,6 +290,53 @@ class DeliveryTests(unittest.TestCase):
uploader.upload_once()
self.assertEqual(self.store.status()["pending"], 1)
def test_solar_json_result_envelope_acknowledges_the_samples(self):
with server() as (httpd, base):
reading = sample()
self.store.add(reading)
httpd.ack_override = {"result": "success", "data": {"accepted_ids": [reading["id"]]},
"desc_lang_id": "", "desc_lang_params": []}
uploader = Uploader(UploadConfig(url=base, token="test-token"), self.store)
self.assertEqual(uploader.upload_once(), 1)
self.assertEqual(self.store.status()["pending"], 0)
def test_failed_or_malformed_solar_result_never_clears_the_queue(self):
with server() as (httpd, base):
reading = sample()
self.store.add(reading)
uploader = Uploader(UploadConfig(url=base, token="test-token"), self.store)
accepted = {"accepted_ids": [reading["id"]]}
for response in [
{"result": "error", "data": accepted},
{"result": "error", **accepted},
{"result": True, "data": accepted},
{"result": "success", "data": None},
{"result": "success", "data": []},
{"result": "success", "data": {}, **accepted},
]:
with self.subTest(response=response):
httpd.ack_override = response
with self.assertRaises(UploadError):
uploader.upload_once()
self.assertEqual(self.store.status()["pending"], 1)
def test_legacy_samples_receive_config_fallback_without_replacing_recorded_thresholds(self):
legacy = sample()
del legacy["readings"]["boiler_threshold_w"]
del legacy["readings"]["boiler_threshold_source"]
current = sample()
current["readings"]["boiler_threshold_w"] = 3100
self.store.add(legacy)
self.store.add(current)
with server() as (httpd, base):
uploader = Uploader(UploadConfig(url=base, token="test-token"), self.store, boiler_threshold_w=2500)
self.assertEqual(uploader.upload_once(), 2)
delivered = {s["id"]: s for s in httpd.requests[0]["samples"]}
self.assertEqual(delivered[legacy["id"]]["readings"]["boiler_threshold_w"], 2500)
self.assertEqual(delivered[legacy["id"]]["readings"]["boiler_threshold_source"], "config")
self.assertEqual(delivered[current["id"]]["readings"]["boiler_threshold_w"], 3100)
self.assertEqual(list(self.store.records()), [legacy, current])
def test_partial_ack_only_marks_the_accepted_sample(self):
with server() as (httpd, base):
first, second = sample(), sample()
@@ -376,6 +448,14 @@ class ConfigTests(unittest.TestCase):
self.assertEqual(config.collector.poll_interval_seconds, 300)
self.assertEqual(config.collector.retention_days, 30)
self.assertEqual(config.upload.url, "")
self.assertEqual(config.inverter.boiler_threshold_w, 2000)
def test_boiler_threshold_fallback_is_configurable_and_must_be_positive_watts(self):
config, _ = self.load('\n[inverter]\nboiler_threshold_w=2750\n')
self.assertEqual(config.inverter.boiler_threshold_w, 2750)
for value in ('0', '-100', 'true', '"2000"', 'nan', 'inf', '100000000'):
with self.subTest(value=value), self.assertRaises(ValueError):
self.load(f'\n[inverter]\nboiler_threshold_w={value}\n')
def test_remote_upload_requires_https(self):
with self.assertRaisesRegex(ValueError, "HTTPS"):