From c1df583c22d8911fe98611abbb2d6f424ed440fc Mon Sep 17 00:00:00 2001 From: Sandbox6168 <2820492+Sandbox6168@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:37:03 +0100 Subject: [PATCH] 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 --- apps/predbat/tests/test_manual_api.py | 88 +++++++++++++++++++++++++++ apps/predbat/userinterface.py | 53 +++++++++++----- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/apps/predbat/tests/test_manual_api.py b/apps/predbat/tests/test_manual_api.py index d3bd70530..ae9c188ee 100644 --- a/apps/predbat/tests/test_manual_api.py +++ b/apps/predbat/tests/test_manual_api.py @@ -150,4 +150,92 @@ def run_test_manual_api(my_predbat): my_predbat.api_select("manual_api", "off") my_predbat.manual_api = my_predbat.api_select_update("manual_api") + # --- api_select_update()'s storage-layer dedup rules (#4405) --- + my_predbat.api_select("manual_api", "rates_import_override?date=2026-08-21&start=00:00:00&end=01:00:00&rate=0") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + my_predbat.api_select("manual_api", "rates_import_override?date=2026-08-22&start=00:00:00&end=01:00:00&rate=0") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + my_predbat.api_select("manual_api", "rates_import_override?date=2026-08-22&start=00:00:00&end=01:00:00&rate=0") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + items = my_predbat.get_manual_api("rates_import_override") + expected = [ + {"index": None, "value": {"date": "2026-08-21", "start": "00:00:00", "end": "01:00:00", "rate": "0"}}, + {"index": None, "value": {"date": "2026-08-22", "start": "00:00:00", "end": "01:00:00", "rate": "0"}}, + ] + if items != expected: + print("ERROR: T17 Expecting distinct no-index entries to coexist and an exact-duplicate resend to be a no-op, got {}".format(items)) + failed = 1 + + my_predbat.api_select("manual_api", "off") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + my_predbat.api_select("manual_api", "rates_import_override(0)?date=2026-08-21&start=00:00:00&end=01:00:00&rate=0") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + my_predbat.api_select("manual_api", "rates_import_override(0)?date=2026-08-21&start=00:00:00&end=01:00:00&rate=5") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + my_predbat.api_select("manual_api", "rates_import_override(1)?date=2026-08-22&start=00:00:00&end=01:00:00&rate=9") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + items = my_predbat.get_manual_api("rates_import_override") + expected = [ + {"index": 0, "value": {"date": "2026-08-21", "start": "00:00:00", "end": "01:00:00", "rate": "5"}}, + {"index": 1, "value": {"date": "2026-08-22", "start": "00:00:00", "end": "01:00:00", "rate": "9"}}, + ] + if items != expected: + print("ERROR: T18 Expecting a same-index resend to replace and distinct indices to coexist, got {}".format(items)) + failed = 1 + + my_predbat.api_select("manual_api", "off") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + my_predbat.api_select("manual_api", "inverter_limit=1000") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + my_predbat.api_select("manual_api", "inverter_limit=2000") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + items = my_predbat.get_manual_api("inverter_limit") + expected = [{"index": None, "value": "2000"}] + if items != expected: + print("ERROR: T19 Expecting a scalar (non-dict_list) no-index override to still replace on resend, got {}".format(items)) + failed = 1 + + # --- get_arg()/basic_rates() merge behaviour built on top of that storage (#4405) --- + my_predbat.api_select("manual_api", "off") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + my_predbat.args["rates_import_override"] = [] + my_predbat.api_select("manual_api", "rates_import_override?start=01:00:00&end=02:00:00&rate=5") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + my_predbat.api_select("manual_api", "rates_import_override?start=03:00:00&end=04:00:00&rate=9") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + info = my_predbat.get_arg("rates_import_override", [], indirect=False) + expected = [ + {"start": "01:00:00", "end": "02:00:00", "rate": "5"}, + {"start": "03:00:00", "end": "04:00:00", "rate": "9"}, + ] + if info != expected: + print("ERROR: T20 Expecting get_arg to merge both no-index dict_list overrides, got {}".format(info)) + failed = 1 + + rates = my_predbat.basic_rates(info, "rates_import_override") + if rates.get(75) != 5 or rates.get(195) != 9: + print("ERROR: T20 Expecting minutes 75/195 to take rates 5/9 from the two windows, got {}/{}".format(rates.get(75), rates.get(195))) + failed = 1 + + my_predbat.api_select("manual_api", "rates_import_override(0)?date=2026-08-22&start=00:00:00&end=01:00:00&rate=5") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + info = my_predbat.get_arg("rates_import_override", [], indirect=False) + if len(info) != 3: + print("ERROR: T21 Expecting no-index and explicit-index overrides to all survive together, got {}".format(info)) + failed = 1 + + # --- A malformed index must not crash api_select_update --- + my_predbat.api_select("manual_api", "off") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + try: + my_predbat.api_select("manual_api", "rates_import_override(x)?date=2026-08-21&start=00:00:00&end=01:00:00&rate=0") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + except ValueError as e: + print("ERROR: T22 Expecting a malformed index to not crash api_select_update, got {}".format(e)) + failed = 1 + + my_predbat.api_select("manual_api", "off") + my_predbat.manual_api = my_predbat.api_select_update("manual_api") + del my_predbat.args["rates_import_override"] + return failed diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index b5977c424..a244cb1b4 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -29,7 +29,7 @@ PREDBAT_MODE_OPTIONS, PREDBAT_MODE_MONITOR, ) -from config import CONFIG_API_OVERRIDE +from config import APPS_SCHEMA, CONFIG_API_OVERRIDE from predbat import THIS_VERSION DEBUG_EXCLUDE_LIST = [ @@ -189,7 +189,13 @@ def get_arg(self, arg, default=None, indirect=True, combine=False, attribute=Non overrides = self.get_manual_api(arg) if isinstance(default, list): value = self.get_arg(arg, default=default, indirect=indirect, combine=combine, attribute=attribute, index=index, domain=domain, can_override=False) + is_dict_list = self.is_multi_instance_override(arg) for override in overrides: + # dict_list index only dedupes at write time, it has no output position (#4405) + if is_dict_list: + value.append(override.get("value", None)) + self.log("Note: API Overridden arg {} value {} appended".format(arg, value)) + continue override_index = override.get("index", 0) if override_index is None: override_index = 0 @@ -1190,6 +1196,22 @@ def split_command_index(self, command): command_index = int(command_split[1]) return command, command_index + def is_multi_instance_override(self, command): + """ + True if the command is a dict_list override (e.g. rates_import_override) + """ + return APPS_SCHEMA.get(command, {}).get("type") == "dict_list" + + def strip_command_args(self, command): + """ + Strip the ?args or =value suffix from a manual API command string + """ + if "?" in command: + return command.split("?")[0] + elif "=" in command: + return command.split("=")[0] + return command + def get_manual_api(self, command_type): """ Get the manual API command @@ -1383,21 +1405,20 @@ def api_select_update(self, config_item, new_value=None): for value in values_list: if value == "off": continue - for prev in time_overrides[:]: - if "=" in prev: - prev_no_eq = prev.split("=")[0] - elif "?" in prev: - prev_no_eq = prev.split("?")[0] - else: - prev_no_eq = prev - if "=" in value: - value_no_eq = value.split("=")[0] - elif "?" in value: - value_no_eq = value.split("?")[0] - else: - value_no_eq = value - if prev_no_eq == value_no_eq: - time_overrides.remove(prev) + value_no_eq = self.strip_command_args(value) + has_index = "(" in value_no_eq + value_command = value_no_eq.split("(")[0] if has_index else value_no_eq + + # No-index dict_list commands only dedupe against an exact repeat, not by name (#4405) + is_multi_instance = not has_index and self.is_multi_instance_override(value_command) + + if is_multi_instance: + if value in time_overrides: + time_overrides.remove(value) + else: + for prev in time_overrides[:]: + if self.strip_command_args(prev) == value_no_eq: + time_overrides.remove(prev) time_overrides.append(value) values = ",".join(time_overrides)