Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions apps/predbat/tests/test_manual_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 37 additions & 16 deletions apps/predbat/userinterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down