Skip to content

Commit 69d5a70

Browse files
fix(inverter): also prefer Control.Discharge_Target_SOC_1 when deciding whether to write
adjust_force_export() still decided whether a discharge target write was needed by reading raw.invertor.discharge_target_soc_1 alone - the same slow, self_run-poll-refreshed field #4492 moved away from as the primary signal inside rest_setDischargeTarget() itself. On hardware where that field never catches up, the write looked permanently "needed" and fired every cycle even when nothing had changed (#4517). rest_readDischargeTarget() centralises the same Control-first/raw-fallback read used for write verification, so the caller now correctly skips the write once it's actually landed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 6477e28 commit 69d5a70

2 files changed

Lines changed: 82 additions & 13 deletions

File tree

apps/predbat/inverter.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2511,19 +2511,13 @@ def adjust_force_export(self, force_export, new_start_time=None, new_end_time=No
25112511
if force_export:
25122512
target_soc = int(self.reserve_percent)
25132513
if self.rest_data and self.rest_v3:
2514-
if "raw" in self.rest_data and "invertor" in self.rest_data["raw"] and "discharge_target_soc_1" in self.rest_data["raw"]["invertor"]:
2515-
current = self.rest_data["raw"]["invertor"]["discharge_target_soc_1"]
2516-
try:
2517-
current = float(current)
2518-
except (ValueError, TypeError) as e:
2519-
current = None
2520-
2521-
if current is None:
2522-
self.log("Inverter {} No current discharge target to read, export target not written".format(self.id))
2523-
elif current != target_soc:
2524-
self.rest_setDischargeTarget(target_soc)
2525-
else:
2526-
self.log("Inverter {} Current discharge target is already set to {}".format(self.id, current))
2514+
current = self.rest_readDischargeTarget()
2515+
if current is None:
2516+
self.log("Inverter {} No current discharge target to read, export target not written".format(self.id))
2517+
elif current != target_soc:
2518+
self.rest_setDischargeTarget(target_soc)
2519+
else:
2520+
self.log("Inverter {} Current discharge target is already set to {}".format(self.id, current))
25272521
elif "discharge_target_soc" in self.base.args:
25282522
current = self.base.get_arg("discharge_target_soc", index=self.id, required_unit="%")
25292523
try:
@@ -3399,6 +3393,30 @@ def rest_setChargeSlot1(self, start, finish):
33993393
self.base.record_status("Warn: Inverter {} REST failed to setChargeSlot1".format(self.id), had_errors=True)
34003394
return False
34013395

3396+
def rest_readDischargeTarget(self):
3397+
"""
3398+
Read GivTCP's currently applied discharge target percent, or None if it can't be read.
3399+
3400+
Mirrors rest_setDischargeTarget()'s own preference order: Control.Discharge_Target_SOC_1 is
3401+
GivTCP's synchronous write-time signal, updated the moment a write is accepted, so it's
3402+
checked first. raw.invertor.discharge_target_soc_1 is a fallback for GivTCP setups where
3403+
Control doesn't expose the key - but on its own it's unreliable as a "did this actually
3404+
change" signal, since it only refreshes on GivTCP's separate background self_run poll cycle,
3405+
not synchronously with any write. A caller that reads raw.invertor alone to decide whether a
3406+
write is even needed can end up re-writing every cycle on hardware where that field never
3407+
catches up (#4421, #4517).
3408+
"""
3409+
try:
3410+
result = int(float(self.rest_data.get("Control", {}).get("Discharge_Target_SOC_1", None)))
3411+
except (ValueError, TypeError):
3412+
result = None
3413+
if result is None:
3414+
try:
3415+
result = int(float(self.rest_data.get("raw", {}).get("invertor", {}).get("discharge_target_soc_1", None)))
3416+
except (ValueError, TypeError):
3417+
result = None
3418+
return result
3419+
34023420
def rest_setDischargeTarget(self, target):
34033421
"""
34043422
Configure discharge to percent via REST

apps/predbat/tests/test_inverter.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1882,6 +1882,51 @@ def test_discharge_target_control_signal(test_name, ha, inv, dummy_rest):
18821882
return failed
18831883

18841884

1885+
def test_discharge_target_read_prefers_control(test_name, ha, inv):
1886+
"""
1887+
Regression test for issue #4517: a discharge target write kept firing every cycle even when
1888+
unchanged, because the caller that decides whether to write at all (adjust_force_export) read
1889+
only raw.invertor.discharge_target_soc_1 - the same slow, self_run-poll-refreshed field #4492
1890+
moved away from as the primary signal inside rest_setDischargeTarget() itself. On hardware where
1891+
that field never catches up, the caller saw a permanent mismatch and re-wrote on every cycle.
1892+
1893+
rest_readDischargeTarget() is the fix: a shared helper both the caller and (potentially)
1894+
rest_setDischargeTarget() can use, checking Control.Discharge_Target_SOC_1 (GivTCP's synchronous
1895+
write-time signal) first and falling back to raw.invertor only if Control doesn't have it.
1896+
"""
1897+
failed = False
1898+
print("Test: {}".format(test_name))
1899+
1900+
saved_rest_data = inv.rest_data
1901+
1902+
try:
1903+
# Control has the real, current value - stale raw must not override it (the core of #4517:
1904+
# the old caller ignored Control entirely and would have seen "0", not "20", here).
1905+
inv.rest_data = {"Control": {"Discharge_Target_SOC_1": "20"}, "raw": {"invertor": {"discharge_target_soc_1": "0"}}}
1906+
result = inv.rest_readDischargeTarget()
1907+
if result != 20:
1908+
print("ERROR: {}: expected Control's value 20, got {}".format(test_name, result))
1909+
failed = True
1910+
1911+
# Control missing the key entirely - falls back to raw.
1912+
inv.rest_data = {"Control": {}, "raw": {"invertor": {"discharge_target_soc_1": "15"}}}
1913+
result = inv.rest_readDischargeTarget()
1914+
if result != 15:
1915+
print("ERROR: {}: expected raw fallback value 15, got {}".format(test_name, result))
1916+
failed = True
1917+
1918+
# Neither present - no crash, just None (matches "No current discharge target to read" path).
1919+
inv.rest_data = {"Control": {}, "raw": {"invertor": {}}}
1920+
result = inv.rest_readDischargeTarget()
1921+
if result is not None:
1922+
print("ERROR: {}: expected None when neither field is present, got {}".format(test_name, result))
1923+
failed = True
1924+
finally:
1925+
inv.rest_data = saved_rest_data
1926+
1927+
return failed
1928+
1929+
18851930
def test_force_export_unchanged_times_HM_format(test_name, ha, inv):
18861931
"""
18871932
Regression test for GS_fb00 (Solis) 'count register writes 0' bug.
@@ -3074,5 +3119,11 @@ def run_inverter_tests(my_predbat_dummy):
30743119
if failed:
30753120
return failed
30763121

3122+
# Regression test for issue #4517: the caller deciding whether to write at all must also prefer
3123+
# Control.Discharge_Target_SOC_1 over the slow raw.invertor fallback, or it re-writes every cycle
3124+
failed |= test_discharge_target_read_prefers_control("discharge_target_read_prefers_control", ha, inv)
3125+
if failed:
3126+
return failed
3127+
30773128
failed |= test_inverter_self_test("self_test1", my_predbat)
30783129
return failed

0 commit comments

Comments
 (0)