Skip to content

Commit c1df583

Browse files
fix(manual-api): stop rates_import_override/rates_export_override from colliding without an explicit index
dict_list overrides (rates_import_override, rates_export_override) hold several simultaneous no-index time windows, but the manual API deduped stored commands by name, so a new no-index window silently replaced the previous one instead of coexisting (#4405). api_select_update() and get_arg() are now index-aware: a no-index dict_list override only collides with an exact duplicate, while explicit-index and scalar overrides keep replace-on-resend behaviour. The stored index only dedupes at write time - it has no meaning as an output list position. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent cd84fc9 commit c1df583

2 files changed

Lines changed: 125 additions & 16 deletions

File tree

apps/predbat/tests/test_manual_api.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,92 @@ def run_test_manual_api(my_predbat):
150150
my_predbat.api_select("manual_api", "off")
151151
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
152152

153+
# --- api_select_update()'s storage-layer dedup rules (#4405) ---
154+
my_predbat.api_select("manual_api", "rates_import_override?date=2026-08-21&start=00:00:00&end=01:00:00&rate=0")
155+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
156+
my_predbat.api_select("manual_api", "rates_import_override?date=2026-08-22&start=00:00:00&end=01:00:00&rate=0")
157+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
158+
my_predbat.api_select("manual_api", "rates_import_override?date=2026-08-22&start=00:00:00&end=01:00:00&rate=0")
159+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
160+
items = my_predbat.get_manual_api("rates_import_override")
161+
expected = [
162+
{"index": None, "value": {"date": "2026-08-21", "start": "00:00:00", "end": "01:00:00", "rate": "0"}},
163+
{"index": None, "value": {"date": "2026-08-22", "start": "00:00:00", "end": "01:00:00", "rate": "0"}},
164+
]
165+
if items != expected:
166+
print("ERROR: T17 Expecting distinct no-index entries to coexist and an exact-duplicate resend to be a no-op, got {}".format(items))
167+
failed = 1
168+
169+
my_predbat.api_select("manual_api", "off")
170+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
171+
my_predbat.api_select("manual_api", "rates_import_override(0)?date=2026-08-21&start=00:00:00&end=01:00:00&rate=0")
172+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
173+
my_predbat.api_select("manual_api", "rates_import_override(0)?date=2026-08-21&start=00:00:00&end=01:00:00&rate=5")
174+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
175+
my_predbat.api_select("manual_api", "rates_import_override(1)?date=2026-08-22&start=00:00:00&end=01:00:00&rate=9")
176+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
177+
items = my_predbat.get_manual_api("rates_import_override")
178+
expected = [
179+
{"index": 0, "value": {"date": "2026-08-21", "start": "00:00:00", "end": "01:00:00", "rate": "5"}},
180+
{"index": 1, "value": {"date": "2026-08-22", "start": "00:00:00", "end": "01:00:00", "rate": "9"}},
181+
]
182+
if items != expected:
183+
print("ERROR: T18 Expecting a same-index resend to replace and distinct indices to coexist, got {}".format(items))
184+
failed = 1
185+
186+
my_predbat.api_select("manual_api", "off")
187+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
188+
my_predbat.api_select("manual_api", "inverter_limit=1000")
189+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
190+
my_predbat.api_select("manual_api", "inverter_limit=2000")
191+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
192+
items = my_predbat.get_manual_api("inverter_limit")
193+
expected = [{"index": None, "value": "2000"}]
194+
if items != expected:
195+
print("ERROR: T19 Expecting a scalar (non-dict_list) no-index override to still replace on resend, got {}".format(items))
196+
failed = 1
197+
198+
# --- get_arg()/basic_rates() merge behaviour built on top of that storage (#4405) ---
199+
my_predbat.api_select("manual_api", "off")
200+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
201+
my_predbat.args["rates_import_override"] = []
202+
my_predbat.api_select("manual_api", "rates_import_override?start=01:00:00&end=02:00:00&rate=5")
203+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
204+
my_predbat.api_select("manual_api", "rates_import_override?start=03:00:00&end=04:00:00&rate=9")
205+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
206+
info = my_predbat.get_arg("rates_import_override", [], indirect=False)
207+
expected = [
208+
{"start": "01:00:00", "end": "02:00:00", "rate": "5"},
209+
{"start": "03:00:00", "end": "04:00:00", "rate": "9"},
210+
]
211+
if info != expected:
212+
print("ERROR: T20 Expecting get_arg to merge both no-index dict_list overrides, got {}".format(info))
213+
failed = 1
214+
215+
rates = my_predbat.basic_rates(info, "rates_import_override")
216+
if rates.get(75) != 5 or rates.get(195) != 9:
217+
print("ERROR: T20 Expecting minutes 75/195 to take rates 5/9 from the two windows, got {}/{}".format(rates.get(75), rates.get(195)))
218+
failed = 1
219+
220+
my_predbat.api_select("manual_api", "rates_import_override(0)?date=2026-08-22&start=00:00:00&end=01:00:00&rate=5")
221+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
222+
info = my_predbat.get_arg("rates_import_override", [], indirect=False)
223+
if len(info) != 3:
224+
print("ERROR: T21 Expecting no-index and explicit-index overrides to all survive together, got {}".format(info))
225+
failed = 1
226+
227+
# --- A malformed index must not crash api_select_update ---
228+
my_predbat.api_select("manual_api", "off")
229+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
230+
try:
231+
my_predbat.api_select("manual_api", "rates_import_override(x)?date=2026-08-21&start=00:00:00&end=01:00:00&rate=0")
232+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
233+
except ValueError as e:
234+
print("ERROR: T22 Expecting a malformed index to not crash api_select_update, got {}".format(e))
235+
failed = 1
236+
237+
my_predbat.api_select("manual_api", "off")
238+
my_predbat.manual_api = my_predbat.api_select_update("manual_api")
239+
del my_predbat.args["rates_import_override"]
240+
153241
return failed

apps/predbat/userinterface.py

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
PREDBAT_MODE_OPTIONS,
3030
PREDBAT_MODE_MONITOR,
3131
)
32-
from config import CONFIG_API_OVERRIDE
32+
from config import APPS_SCHEMA, CONFIG_API_OVERRIDE
3333
from predbat import THIS_VERSION
3434

3535
DEBUG_EXCLUDE_LIST = [
@@ -189,7 +189,13 @@ def get_arg(self, arg, default=None, indirect=True, combine=False, attribute=Non
189189
overrides = self.get_manual_api(arg)
190190
if isinstance(default, list):
191191
value = self.get_arg(arg, default=default, indirect=indirect, combine=combine, attribute=attribute, index=index, domain=domain, can_override=False)
192+
is_dict_list = self.is_multi_instance_override(arg)
192193
for override in overrides:
194+
# dict_list index only dedupes at write time, it has no output position (#4405)
195+
if is_dict_list:
196+
value.append(override.get("value", None))
197+
self.log("Note: API Overridden arg {} value {} appended".format(arg, value))
198+
continue
193199
override_index = override.get("index", 0)
194200
if override_index is None:
195201
override_index = 0
@@ -1190,6 +1196,22 @@ def split_command_index(self, command):
11901196
command_index = int(command_split[1])
11911197
return command, command_index
11921198

1199+
def is_multi_instance_override(self, command):
1200+
"""
1201+
True if the command is a dict_list override (e.g. rates_import_override)
1202+
"""
1203+
return APPS_SCHEMA.get(command, {}).get("type") == "dict_list"
1204+
1205+
def strip_command_args(self, command):
1206+
"""
1207+
Strip the ?args or =value suffix from a manual API command string
1208+
"""
1209+
if "?" in command:
1210+
return command.split("?")[0]
1211+
elif "=" in command:
1212+
return command.split("=")[0]
1213+
return command
1214+
11931215
def get_manual_api(self, command_type):
11941216
"""
11951217
Get the manual API command
@@ -1383,21 +1405,20 @@ def api_select_update(self, config_item, new_value=None):
13831405
for value in values_list:
13841406
if value == "off":
13851407
continue
1386-
for prev in time_overrides[:]:
1387-
if "=" in prev:
1388-
prev_no_eq = prev.split("=")[0]
1389-
elif "?" in prev:
1390-
prev_no_eq = prev.split("?")[0]
1391-
else:
1392-
prev_no_eq = prev
1393-
if "=" in value:
1394-
value_no_eq = value.split("=")[0]
1395-
elif "?" in value:
1396-
value_no_eq = value.split("?")[0]
1397-
else:
1398-
value_no_eq = value
1399-
if prev_no_eq == value_no_eq:
1400-
time_overrides.remove(prev)
1408+
value_no_eq = self.strip_command_args(value)
1409+
has_index = "(" in value_no_eq
1410+
value_command = value_no_eq.split("(")[0] if has_index else value_no_eq
1411+
1412+
# No-index dict_list commands only dedupe against an exact repeat, not by name (#4405)
1413+
is_multi_instance = not has_index and self.is_multi_instance_override(value_command)
1414+
1415+
if is_multi_instance:
1416+
if value in time_overrides:
1417+
time_overrides.remove(value)
1418+
else:
1419+
for prev in time_overrides[:]:
1420+
if self.strip_command_args(prev) == value_no_eq:
1421+
time_overrides.remove(prev)
14011422
time_overrides.append(value)
14021423

14031424
values = ",".join(time_overrides)

0 commit comments

Comments
 (0)