Skip to content

Commit 73eeb33

Browse files
fix(solis): guard battery_scaling against a documented 0% SOH API response (#4500)
* fix(solis): guard battery_scaling against a documented 0% SOH API response (#4494) A Solis Cloud API batteryHealthSoh of 0 is a documented, valid response (not "no battery"), but was previously treated as falsy in automatic_config()'s battery detection and, once bound to the live SOH sensor, fed straight into soc_max = nominal_capacity * battery_scaling - collapsing soc_max to 0 and making best_soc_keep permanently unreachable, forcing continuous grid charging regardless of price. Reported with full root-cause trace by @jibbej. - inverter.py: a battery_scaling reading of 0 or negative is ambiguous (flaky API vs a genuinely unhealthy battery), so rather than asserting either extreme, retain the last value that was actually read as valid (mirrors the existing soc_max_nominal fallback pattern), falling back to 1.0 only if nothing valid has ever been read. - solis.py automatic_config(): a battery_soh of 0 no longer excludes the inverter from configuration the same way a genuinely missing field does, which previously caused automatic_config() to abandon configuration entirely (load_today/charge_start_time left unset) and crash-loop. * feat(components): warn once when auto-discovery overrides an apps.yaml value (#4494 follow-up) Reviewer feedback on PR #4500 (jibbej): battery_scaling being silently discarded was fixed, but the underlying discoverability problem is generic - automatic_config() (in any cloud integration) always wins over an explicit apps.yaml value via set_arg(), with zero indication to the user that their setting was ignored. Adds ComponentBase.set_arg_auto(), a drop-in replacement for set_arg() intended for auto-discovery code: if the key had a different value in the user's raw apps.yaml (snapshotted at the very start of PredBat.initialize(), before Predbat's own defaulting or any component touches self.args), logs a one-time note naming both values, then applies the auto-discovered value exactly as before - precedence is unchanged, only the visibility of the override is new. Wired up for all of solis.py's automatic_config() bindings, since the same silent-override behaviour applies equally to every key it touches, not just battery_scaling. Other integrations are untouched for now - the helper lives on the shared ComponentBase so adopting it elsewhere is a follow-up, not a redesign.
1 parent 457cf0a commit 73eeb33

7 files changed

Lines changed: 243 additions & 32 deletions

File tree

apps/predbat/component_base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,22 @@ def set_arg(self, arg, value):
9393
"""
9494
return self.base.set_arg(arg, value)
9595

96+
def set_arg_auto(self, arg, value):
97+
"""
98+
Like set_arg(), but for auto-discovery code (typically automatic_config()) binding an
99+
apps.yaml key to an auto-discovered entity/value. Auto-discovery still always wins - this
100+
does not change that - but if the user had already set this key explicitly in apps.yaml,
101+
silently discarding it left no way to notice (issue #4494 follow-up discussion, PR #4500).
102+
Logs a one-time note per key when that happens, then behaves exactly like set_arg().
103+
"""
104+
raw_args = getattr(self.base, "args_from_apps_yaml", None) or {}
105+
raw_value = raw_args.get(arg)
106+
warned = getattr(self.base, "apps_yaml_override_warned", None)
107+
if raw_value is not None and raw_value != value and warned is not None and arg not in warned:
108+
warned.add(arg)
109+
self.log(f"Note: apps.yaml sets '{arg}: {raw_value}' but auto-discovery is using '{value}' instead - auto-discovery always wins currently; remove the apps.yaml entry to avoid this message")
110+
return self.set_arg(arg, value)
111+
96112
def get_arg(self, arg, default=None, indirect=True, combine=False, attribute=None, index=None, domain=None, can_override=True, required_unit=None):
97113
"""
98114
Retrieve a configuration argument from the base system.

apps/predbat/inverter.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,18 @@ def __init__(self, base, id=0, quiet=False, rest_postCommand=None, rest_getData=
154154
self.reserve_percent = self.base.get_arg("battery_min_soc", default=4.0, index=self.id, required_unit="%")
155155
self.reserve_percent_current = self.base.get_arg("battery_min_soc", default=4.0, index=self.id, required_unit="%")
156156
self.battery_scaling = self.base.get_arg("battery_scaling", default=1.0, index=self.id)
157+
if not self.battery_scaling or self.battery_scaling <= 0:
158+
# A parsed value of exactly 0 (or negative) isn't safe to interpret either way - it could
159+
# be a flaky/unavailable API response (e.g. Solis Cloud API returning batteryHealthSoh: 0
160+
# during an outage) or a genuinely unhealthy battery, and asserting either "fully healthy"
161+
# (1.0) or "no capacity" (0) would be guessing. Retain the last value that was actually
162+
# read as valid, rather than inventing one; only fall back to 1.0 if nothing valid has
163+
# ever been read for this inverter.
164+
last_known = self.base.get_arg("battery_scaling_last_known", default=1.0, index=self.id)
165+
self.log("Warn: Inverter {} battery_scaling read as {} which is not a valid scaling factor, retaining last known value {} for this cycle".format(self.id, self.battery_scaling, last_known))
166+
self.battery_scaling = last_known
167+
else:
168+
self.base.set_arg("battery_scaling_last_known", self.battery_scaling, index=self.id)
157169
self.battery_scaling_config = self.battery_scaling
158170

159171
self.reserve_max = 100

apps/predbat/predbat.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1603,6 +1603,12 @@ def initialize(self):
16031603
"""
16041604
Setup the app, called once each time the app starts
16051605
"""
1606+
# Snapshot of apps.yaml exactly as the user wrote it, before Predbat's own defaulting
1607+
# (auto_config/load_user_config) or any component's automatic_config() touches self.args -
1608+
# lets ComponentBase.set_arg_auto() tell "user explicitly configured this" apart from
1609+
# "Predbat defaulted it" or "another component already overwrote it" (issue #4494 follow-up).
1610+
self.args_from_apps_yaml = copy.deepcopy(self.args)
1611+
self.apps_yaml_override_warned = set() # {arg} already warned about via set_arg_auto()
16061612
self.pool = None
16071613
self.log("Predbat: Startup {}".format(__name__))
16081614
self.update_time(print=False)

apps/predbat/solis.py

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,7 +1241,9 @@ async def automatic_config(self):
12411241
battery_soh = float(battery_soh) / 100.0
12421242
except (ValueError, TypeError):
12431243
battery_soh = None
1244-
if battery_soh:
1244+
# A battery_soh of exactly 0 is a documented, valid Solis Cloud API response (not "no
1245+
# battery") - only a missing/unparseable field should exclude the inverter here.
1246+
if battery_soh is not None:
12451247
batteries.append(inverter_sn)
12461248

12471249
num_inverters = len(batteries)
@@ -1254,50 +1256,50 @@ async def automatic_config(self):
12541256
devices = [sn.lower() for sn in batteries]
12551257

12561258
# Configure base Predbat settings
1257-
self.set_arg("inverter_type", ["SolisCloud" for _ in range(num_inverters)])
1258-
self.set_arg("num_inverters", num_inverters)
1259+
self.set_arg_auto("inverter_type", ["SolisCloud" for _ in range(num_inverters)])
1260+
self.set_arg_auto("num_inverters", num_inverters)
12591261

12601262
# Battery and inverter entities
1261-
self.set_arg("soc_percent", [f"sensor.{self.prefix}_solis_{device}_battery_soc" for device in devices])
1262-
self.set_arg("battery_scaling", [f"sensor.{self.prefix}_solis_{device}_battery_soh" for device in devices])
1263-
self.set_arg("battery_power", [f"sensor.{self.prefix}_solis_{device}_battery_power" for device in devices])
1264-
self.set_arg("battery_power_invert", [f"True" for device in devices])
1265-
self.set_arg("grid_power", [f"sensor.{self.prefix}_solis_{device}_grid_power" for device in devices])
1266-
self.set_arg("battery_voltage", [f"sensor.{self.prefix}_solis_{device}_battery_voltage" for device in devices])
1263+
self.set_arg_auto("soc_percent", [f"sensor.{self.prefix}_solis_{device}_battery_soc" for device in devices])
1264+
self.set_arg_auto("battery_scaling", [f"sensor.{self.prefix}_solis_{device}_battery_soh" for device in devices])
1265+
self.set_arg_auto("battery_power", [f"sensor.{self.prefix}_solis_{device}_battery_power" for device in devices])
1266+
self.set_arg_auto("battery_power_invert", [f"True" for device in devices])
1267+
self.set_arg_auto("grid_power", [f"sensor.{self.prefix}_solis_{device}_grid_power" for device in devices])
1268+
self.set_arg_auto("battery_voltage", [f"sensor.{self.prefix}_solis_{device}_battery_voltage" for device in devices])
12671269
# self.set_arg("battery_temperature", [f"sensor.{self.prefix}_solis_{device}_battery_temperature" for device in devices])
12681270

12691271
# if solis_cloud_pv_load_ignore is set to true, override Solis cloud sensors and use load/pv_today/power entries defined in apps.yaml
12701272
if not self.get_arg("solis_cloud_pv_load_ignore", default=False):
1271-
self.set_arg("load_today", [f"sensor.{self.prefix}_solis_{device}_total_load_energy" for device in devices])
1272-
self.set_arg("pv_today", [f"sensor.{self.prefix}_solis_{device}_pv_energy_total" for device in devices])
1273-
self.set_arg("load_power", [f"sensor.{self.prefix}_solis_{device}_load_power" for device in devices])
1274-
self.set_arg("pv_power", [f"sensor.{self.prefix}_solis_{device}_pv_power" for device in devices])
1275-
self.set_arg("import_today", [f"sensor.{self.prefix}_solis_{device}_today_import_energy" for device in devices])
1276-
self.set_arg("export_today", [f"sensor.{self.prefix}_solis_{device}_today_export_energy" for device in devices])
1273+
self.set_arg_auto("load_today", [f"sensor.{self.prefix}_solis_{device}_total_load_energy" for device in devices])
1274+
self.set_arg_auto("pv_today", [f"sensor.{self.prefix}_solis_{device}_pv_energy_total" for device in devices])
1275+
self.set_arg_auto("load_power", [f"sensor.{self.prefix}_solis_{device}_load_power" for device in devices])
1276+
self.set_arg_auto("pv_power", [f"sensor.{self.prefix}_solis_{device}_pv_power" for device in devices])
1277+
self.set_arg_auto("import_today", [f"sensor.{self.prefix}_solis_{device}_today_import_energy" for device in devices])
1278+
self.set_arg_auto("export_today", [f"sensor.{self.prefix}_solis_{device}_today_export_energy" for device in devices])
12771279

12781280
# Battery capacity and limits from cached details
12791281
# XXX: This is currently broken, user must set manually in apps.yaml
12801282
# self.set_arg("soc_max", [f"sensor.{self.prefix}_solis_{device}_battery_capacity" for device in devices])
12811283

12821284
# Reserve and limits
1283-
self.set_arg("reserve", [f"number.{self.prefix}_solis_{device}_over_discharge_soc" for device in devices])
1284-
self.set_arg("battery_min_soc", [f"number.{self.prefix}_solis_{device}_over_discharge_soc" for device in devices])
1285+
self.set_arg_auto("reserve", [f"number.{self.prefix}_solis_{device}_over_discharge_soc" for device in devices])
1286+
self.set_arg_auto("battery_min_soc", [f"number.{self.prefix}_solis_{device}_over_discharge_soc" for device in devices])
12851287

12861288
# Charge/discharge controls - using slot 1 for Predbat primary control
1287-
self.set_arg("charge_start_time", [f"select.{self.prefix}_solis_{device}_charge_slot1_start_time" for device in devices])
1288-
self.set_arg("charge_end_time", [f"select.{self.prefix}_solis_{device}_charge_slot1_end_time" for device in devices]) # Same selector, parsed
1289-
self.set_arg("charge_limit", [f"number.{self.prefix}_solis_{device}_charge_slot1_soc" for device in devices])
1290-
self.set_arg("charge_rate", [f"number.{self.prefix}_solis_{device}_charge_slot1_power" for device in devices])
1291-
self.set_arg("scheduled_charge_enable", [f"switch.{self.prefix}_solis_{device}_charge_slot1_enable" for device in devices])
1292-
1293-
self.set_arg("discharge_start_time", [f"select.{self.prefix}_solis_{device}_discharge_slot1_start_time" for device in devices])
1294-
self.set_arg("discharge_end_time", [f"select.{self.prefix}_solis_{device}_discharge_slot1_end_time" for device in devices])
1295-
self.set_arg("discharge_target_soc", [f"number.{self.prefix}_solis_{device}_discharge_slot1_soc" for device in devices])
1296-
self.set_arg("discharge_rate", [f"number.{self.prefix}_solis_{device}_discharge_slot1_power" for device in devices])
1297-
self.set_arg("scheduled_discharge_enable", [f"switch.{self.prefix}_solis_{device}_discharge_slot1_enable" for device in devices])
1298-
self.set_arg("battery_rate_max", [f"number.{self.prefix}_solis_{device}_max_charge_power" for device in devices])
1299-
self.set_arg("inverter_limit", [f"sensor.{self.prefix}_solis_{device}_inverter_size" for device in devices])
1300-
self.set_arg("export_limit", [f"number.{self.prefix}_solis_{device}_max_export_power" for device in devices])
1289+
self.set_arg_auto("charge_start_time", [f"select.{self.prefix}_solis_{device}_charge_slot1_start_time" for device in devices])
1290+
self.set_arg_auto("charge_end_time", [f"select.{self.prefix}_solis_{device}_charge_slot1_end_time" for device in devices]) # Same selector, parsed
1291+
self.set_arg_auto("charge_limit", [f"number.{self.prefix}_solis_{device}_charge_slot1_soc" for device in devices])
1292+
self.set_arg_auto("charge_rate", [f"number.{self.prefix}_solis_{device}_charge_slot1_power" for device in devices])
1293+
self.set_arg_auto("scheduled_charge_enable", [f"switch.{self.prefix}_solis_{device}_charge_slot1_enable" for device in devices])
1294+
1295+
self.set_arg_auto("discharge_start_time", [f"select.{self.prefix}_solis_{device}_discharge_slot1_start_time" for device in devices])
1296+
self.set_arg_auto("discharge_end_time", [f"select.{self.prefix}_solis_{device}_discharge_slot1_end_time" for device in devices])
1297+
self.set_arg_auto("discharge_target_soc", [f"number.{self.prefix}_solis_{device}_discharge_slot1_soc" for device in devices])
1298+
self.set_arg_auto("discharge_rate", [f"number.{self.prefix}_solis_{device}_discharge_slot1_power" for device in devices])
1299+
self.set_arg_auto("scheduled_discharge_enable", [f"switch.{self.prefix}_solis_{device}_discharge_slot1_enable" for device in devices])
1300+
self.set_arg_auto("battery_rate_max", [f"number.{self.prefix}_solis_{device}_max_charge_power" for device in devices])
1301+
self.set_arg_auto("inverter_limit", [f"sensor.{self.prefix}_solis_{device}_inverter_size" for device in devices])
1302+
self.set_arg_auto("export_limit", [f"number.{self.prefix}_solis_{device}_max_export_power" for device in devices])
13011303

13021304
self.log("Solis API: Automatic configuration complete")
13031305

@@ -1517,7 +1519,11 @@ async def publish_entities(self):
15171519
app="solis"
15181520
)
15191521

1520-
# Battery state of health
1522+
# Battery state of health - published as-is, including a literal 0 (issue #4494): a 0%
1523+
# reading here can be a flaky/unavailable API response as well as a genuinely unhealthy
1524+
# battery, and we don't know which, so it's reported honestly rather than guessed at.
1525+
# Inverter.__init__ is where battery_scaling itself is protected from a 0 or negative
1526+
# reading (retains the last known-good value rather than collapsing soc_max).
15211527
battery_soh = detail.get("batteryHealthSoh")
15221528
try:
15231529
battery_soh = float(battery_soh) / 100.0

apps/predbat/tests/test_component_base.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,50 @@ async def run_test():
361361
return asyncio.run(run_test())
362362

363363

364+
def test_component_base_set_arg_auto(my_predbat):
365+
"""
366+
Test ComponentBase.set_arg_auto() (issue #4494 follow-up, PR #4500 review): warns once when
367+
it overwrites a key the user had explicitly set in apps.yaml, otherwise behaves exactly like
368+
set_arg() - auto-discovery always wins either way, this only makes the override discoverable.
369+
"""
370+
print("\n*** Test: ComponentBase.set_arg_auto warns once on apps.yaml override ***")
371+
372+
base = MockBase()
373+
base.args_from_apps_yaml = {"battery_scaling": [0.9]}
374+
base.apps_yaml_override_warned = set()
375+
set_calls = {}
376+
base.set_arg = lambda arg, value: set_calls.__setitem__(arg, value)
377+
378+
component = TestComponent(base)
379+
380+
# apps.yaml had a different value - warn once, auto-discovered value still applied
381+
component.set_arg_auto("battery_scaling", ["sensor.predbat_battery_soh"])
382+
assert set_calls.get("battery_scaling") == ["sensor.predbat_battery_soh"], "Auto-discovered value should be applied"
383+
assert any("apps.yaml sets 'battery_scaling: [0.9]'" in msg for msg in base.log_messages), "Should warn about the override"
384+
385+
# Second call for the same key must not repeat the warning
386+
component.set_arg_auto("battery_scaling", ["sensor.predbat_battery_soh"])
387+
warn_count = sum(1 for msg in base.log_messages if "apps.yaml sets 'battery_scaling" in msg)
388+
assert warn_count == 1, f"Warning should not repeat, got {warn_count}"
389+
390+
# A key never present in apps.yaml at all - no warning, behaves like plain set_arg
391+
component.set_arg_auto("num_inverters", 1)
392+
assert set_calls.get("num_inverters") == 1, "Should still set the value for an unconfigured key"
393+
assert not any("num_inverters" in msg for msg in base.log_messages), "Should not warn for a key the user never configured"
394+
395+
# Base with no args_from_apps_yaml snapshot at all (e.g. component created outside
396+
# PredBat.initialize(), as in most unit tests) must not raise, and must not warn
397+
bare_base = MockBase()
398+
bare_set_calls = {}
399+
bare_base.set_arg = lambda arg, value: bare_set_calls.__setitem__(arg, value)
400+
bare_component = TestComponent(bare_base)
401+
bare_component.set_arg_auto("battery_scaling", ["sensor.predbat_battery_soh"])
402+
assert bare_set_calls.get("battery_scaling") == ["sensor.predbat_battery_soh"], "Should still work without an apps_yaml snapshot"
403+
404+
print("PASS: set_arg_auto warns once on a genuine override, stays silent otherwise, and is safe without a snapshot")
405+
return False
406+
407+
364408
def test_component_base_all(my_predbat):
365409
"""Run all component_base tests"""
366410
tests = [
@@ -372,6 +416,7 @@ def test_component_base_all(my_predbat):
372416
("exception_handling", test_component_base_exception_handling, "Component handles exceptions with backoff"),
373417
("run_timeout", test_component_base_run_timeout, "Hung run() triggers timeout, stack trace, and error count"),
374418
("first_cleared_preset", test_component_base_first_cleared_when_run_presets_api_started, "first flag clears even when run() pre-sets api_started"),
419+
("set_arg_auto", test_component_base_set_arg_auto, "set_arg_auto warns once on an apps.yaml override, silent otherwise"),
375420
]
376421

377422
failed = []

apps/predbat/tests/test_inverter.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2109,6 +2109,54 @@ def test_input_datetime_charge_window(test_name, ha, inv, dummy_rest, direction,
21092109
return failed
21102110

21112111

2112+
def test_battery_scaling_invalid_value_clamped(test_name, my_predbat):
2113+
"""
2114+
Verify Inverter.__init__ guards against a zero or negative battery_scaling read from args
2115+
rather than letting it propagate into soc_max = nominal_capacity * battery_scaling.
2116+
A cloud API legitimately reporting 0% battery State of Health (e.g. Solis, issue #4494) must
2117+
not silently collapse soc_max to zero - and since 0 is ambiguous (could mean a flaky API
2118+
response or a genuinely unhealthy battery), Predbat must not guess a value either; it should
2119+
retain the last value that was actually read as valid, falling back to 1.0 only when nothing
2120+
valid has ever been read.
2121+
"""
2122+
failed = False
2123+
print("**** Running Test: {} ****".format(test_name))
2124+
2125+
# A prior test in this run may have left givtcp_rest pointing at a dummy REST URL, which
2126+
# would otherwise make this plain construction attempt (and retry) a real REST read.
2127+
my_predbat.args["givtcp_rest"] = None
2128+
if "battery_scaling_last_known" in my_predbat.args:
2129+
del my_predbat.args["battery_scaling_last_known"]
2130+
2131+
# No prior valid reading exists yet - falls back to 1.0
2132+
for bad_scaling in (0.0, -0.5):
2133+
my_predbat.args["battery_scaling"] = [bad_scaling]
2134+
inv = Inverter(my_predbat, 0)
2135+
if inv.battery_scaling != 1.0:
2136+
print("ERROR: battery_scaling should fall back to 1.0 when source reads {} and no prior value exists, got {}".format(bad_scaling, inv.battery_scaling))
2137+
failed = True
2138+
2139+
# A valid reading passes through unchanged, and is remembered
2140+
my_predbat.args["battery_scaling"] = [0.72]
2141+
inv = Inverter(my_predbat, 0)
2142+
if inv.battery_scaling != 0.72:
2143+
print("ERROR: battery_scaling should pass a valid value through unchanged, got {}".format(inv.battery_scaling))
2144+
failed = True
2145+
2146+
# A subsequent invalid reading retains the last known-good value (0.72), not 1.0 - it must
2147+
# not be assumed the battery is now "fully healthy" just because the reading is unusable
2148+
for bad_scaling in (0.0, -0.5):
2149+
my_predbat.args["battery_scaling"] = [bad_scaling]
2150+
inv = Inverter(my_predbat, 0)
2151+
if inv.battery_scaling != 0.72:
2152+
print("ERROR: battery_scaling should retain last known-good value 0.72 when source reads {}, got {}".format(bad_scaling, inv.battery_scaling))
2153+
failed = True
2154+
2155+
del my_predbat.args["battery_scaling"]
2156+
del my_predbat.args["battery_scaling_last_known"]
2157+
return failed
2158+
2159+
21122160
def test_rest_battery_capacity_fallback(test_name, my_predbat):
21132161
"""
21142162
Verify that when V3 REST data omits Battery_Capacity_kWh and battery_nominal_capacity,
@@ -2454,6 +2502,8 @@ def run_inverter_tests(my_predbat_dummy):
24542502
if failed:
24552503
return failed
24562504

2505+
failed |= test_battery_scaling_invalid_value_clamped("battery_scaling_invalid_value_clamped", my_predbat)
2506+
24572507
failed |= test_rest_battery_capacity_fallback("rest_capacity_fallback", my_predbat)
24582508
if failed:
24592509
return failed

0 commit comments

Comments
 (0)