From 9b5e1b4125a510436b052f7df347941aa504ca78 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 17 Aug 2026 08:20:15 +0100 Subject: [PATCH] fix(solis): initialise capacity_voltage_warned so publish_entities stops crashing #4502 added a once-per-inverter warning for the estimated battery capacity voltage, guarded by self.capacity_voltage_warned, but only initialised that set in MockSolisAPI - never in SolisAPI.initialize(). Any install without solis_nominal_voltage set in apps.yaml (i.e. almost all of them) therefore takes the fallback branch on the first publish_entities() run and raises "'SolisAPI' object has no attribute 'capacity_voltage_warned'", aborting the update cycle so no plan is ever produced. MockSolisAPI replaces __init__ wholesale, which is why the whole test suite passed against a component that could not survive a single real poll. Add a parity guard that constructs the real component and asserts it sets every attribute the mock stubs, so this class of divergence fails in CI rather than on a live inverter. Drop storage_modes from the mock while doing so - it is referenced nowhere in solis.py. Co-Authored-By: Claude Opus 5 (1M context) --- apps/predbat/predbat.py | 2 +- apps/predbat/solis.py | 1 + apps/predbat/tests/test_solis.py | 26 +++++++++++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 13ddd192e..2fdd4b427 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -35,7 +35,7 @@ import pytz import asyncio -THIS_VERSION = "v8.48.4" +THIS_VERSION = "v8.48.5" from download import predbat_update_move, predbat_update_download, check_install, DEFAULT_PREDBAT_REPOSITORY from const import MINUTE_WATT diff --git a/apps/predbat/solis.py b/apps/predbat/solis.py index 48b91b003..b8797d76f 100644 --- a/apps/predbat/solis.py +++ b/apps/predbat/solis.py @@ -334,6 +334,7 @@ def initialize(self, api_key=None, api_secret=None, inverter_sn=None, automatic= # Tracking self.slots_reset = set() # Track which inverters had slots reset + self.capacity_voltage_warned = set() # Inverters already warned about an estimated capacity voltage self.log(f"Solis API: Initialised with inverter_sn={self.configured_inverter_sn}, automatic={automatic}") diff --git a/apps/predbat/tests/test_solis.py b/apps/predbat/tests/test_solis.py index 26b5ffd93..5587b72b3 100644 --- a/apps/predbat/tests/test_solis.py +++ b/apps/predbat/tests/test_solis.py @@ -66,7 +66,6 @@ def __init__(self, prefix="predbat"): # Cache structures self.cached_values = {} self.inverter_details = {} - self.storage_modes = {} self.parallel_battery_count = {} self.max_charge_current = {} self.max_discharge_current = {} @@ -778,6 +777,30 @@ def test_solis_activated_with_api_key(): return failed +def test_initialize_attribute_parity_with_mock(): + """Every attribute MockSolisAPI stubs must also be set by the real SolisAPI.initialize(). + + MockSolisAPI replaces __init__ wholesale, so an attribute added to the mock but forgotten in + the real initialize() passes every unit test here and then raises AttributeError against a + live inverter — which is exactly how capacity_voltage_warned shipped broken in #4502. + """ + failed = False + # Attributes that exist only to drive the test harness and have no production counterpart. + test_only = {"_test_now_utc_exact", "log_messages", "dashboard_items", "read_and_write_cid_calls", "set_storage_mode_calls"} + component = _init_solis_component({"solis_api_key": "k", "solis_api_secret": "s"}) + if component is None: + print("ERROR: Solis should activate with api_key + api_secret") + return True + mock_attrs = set(vars(MockSolisAPI()).keys()) - test_only + missing = sorted(attr for attr in mock_attrs if not hasattr(component, attr)) + if missing: + print("ERROR: SolisAPI.initialize() does not set attributes stubbed by MockSolisAPI: {}".format(missing)) + failed = True + if not failed: + print("PASSED: SolisAPI.initialize() sets every attribute MockSolisAPI stubs") + return failed + + def test_solis_activated_with_oauth_token(): """Solis activates in OAuth mode when auth_method=oauth + access_token are configured (no api_key).""" failed = False @@ -1018,6 +1041,7 @@ def run_solis_tests(my_predbat): failed |= test_solis_not_activated_without_credentials() failed |= test_solis_activated_with_api_key() failed |= test_solis_activated_with_oauth_token() + failed |= test_initialize_attribute_parity_with_mock() failed |= asyncio.run(test_oauth_execute_request_refreshes_before_call()) failed |= asyncio.run(test_oauth_endpoint_namespace_translation()) failed |= asyncio.run(test_oauth_execute_request_aborts_when_token_missing())