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
8 changes: 6 additions & 2 deletions apps/predbat/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1468,9 +1468,13 @@ def update_status(self, minutes_now, quiet=False):
charge_start_time = time_string_to_stamp(self.base.get_arg("charge_start_time", index=self.id))
charge_end_time = time_string_to_stamp(self.base.get_arg("charge_end_time", index=self.id))
else:
# Neither a data source nor a fallback config value is available - log the specific

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure of the rationale here, this is detecting when the inverter has not been configured (charge_start_time is not set at all in the args). The only time this is temporary is when you depend on auto-config of a cloud component, but why pretend to make a plan when you can't actually deliver it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fair challenge, and I think it points at something the fix as written glossed over: the else branch actually covers two different situations, not one.

  1. self.rest_api is configured, but self.rest_data is empty this cycle - e.g. the original bug report's actual trigger, GivEnergy cloud returning "no devices" or Fox returning no data on a fresh start. This genuinely is transient - the data source is legitimate, it just hasn't successfully returned anything yet.
  2. Neither self.rest_api nor charge_start_time is configured at all - this is the case you're pointing at, and you're right that it's not temporary. Retrying every cycle forever doesn't fix a config gap that isn't going to close itself.

The fix collapsed both into the same "safe defaults + retry" path, which is defensible for (1) but not really for (2).

Given that raising here crashes the whole plan calculation (not just this one inverter) - which was the actual harm in the original report - I don't think going back to a hard raise for case (2) is right either. But I take your point that silently retrying with a "will retry next update" message is misleading when there's nothing to wait for. Would something like: keep the graceful retry for case (1), but for case (2) still avoid crashing the plan while making the status persistently loud (an ongoing Error-level status, not a message implying transience) so it doesn't get lost in the log - does that match what you had in mind, or would you rather it fail harder for that specific case?

# reason here, but let the shared handling below (which also covers a source that
# returned an unusable value) set the status entity and safe defaults, so this
# doesn't hard-crash the whole plan for what may just be a temporarily empty feed.
self.log("Error: Inverter {} unable to read charge window time as neither REST, charge_start_time or charge_start_hour are set".format(self.id))
self.base.record_status("Error: Inverter {} unable to read charge window time as neither REST, charge_start_time or charge_start_hour are set".format(self.id), had_errors=True)
raise ValueError
charge_start_time = None
charge_end_time = None

if charge_start_time is None or charge_end_time is None:
self.log("Warn: Inverter {} unable to read charge window time as charge_start_time or charge_end_time is None, will retry next update".format(self.id))
Expand Down
64 changes: 64 additions & 0 deletions apps/predbat/tests/test_inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,66 @@ def test_charge_window_none_value(test_name, my_predbat, dummy_items):
return failed


def test_charge_window_no_source_configured(test_name, my_predbat, dummy_items):
"""
Test charge window handling when neither REST data nor a charge_start_time/charge_end_time
config source is available at all (issue #4179) - previously a bare `raise ValueError` that
crashed the whole plan with an unhelpful "Error: Exception raised" status. Should now fall
through to the same safe-defaults/retry-next-update handling as an unusable configured value.
"""
failed = False
print(f"**** Running Test: {test_name} ****")

inv = Inverter(my_predbat, 0)
inv.sleep = dummy_sleep
inv.inv_has_charge_enable_time = True
inv.rest_api = None
inv.rest_data = None

# Remove charge_start_time/charge_end_time from config entirely, so neither the REST nor the
# config-arg branch can produce a value
original_charge_start_time = my_predbat.args.pop("charge_start_time", None)
original_charge_end_time = my_predbat.args.pop("charge_end_time", None)
dummy_items["switch.scheduled_charge_enable"] = "on"

try:
inv.update_status(my_predbat.minutes_now)
except ValueError as e:
print(f"ERROR: {test_name} - update_status should not raise when no charge window source is configured, got ValueError({e})")
failed = True
# Restore config before returning so this doesn't affect later tests
if original_charge_start_time is not None:
my_predbat.args["charge_start_time"] = original_charge_start_time
if original_charge_end_time is not None:
my_predbat.args["charge_end_time"] = original_charge_end_time
return failed

# Should set the same safe defaults as the "value is None" case
if inv.charge_enable_time != False:
print(f"ERROR: {test_name} - charge_enable_time should be False, got {inv.charge_enable_time}")
failed = True
if inv.charge_start_time_minutes != my_predbat.forecast_minutes:
print(f"ERROR: {test_name} - charge_start_time_minutes should be {my_predbat.forecast_minutes}, got {inv.charge_start_time_minutes}")
failed = True
if inv.charge_end_time_minutes != my_predbat.forecast_minutes:
print(f"ERROR: {test_name} - charge_end_time_minutes should be {my_predbat.forecast_minutes}, got {inv.charge_end_time_minutes}")
failed = True
if inv.track_charge_start != "00:00:00":
print(f"ERROR: {test_name} - track_charge_start should be '00:00:00', got {inv.track_charge_start}")
failed = True
if inv.track_charge_end != "00:00:00":
print(f"ERROR: {test_name} - track_charge_end should be '00:00:00', got {inv.track_charge_end}")
failed = True

# Restore config for later tests
if original_charge_start_time is not None:
my_predbat.args["charge_start_time"] = original_charge_start_time
if original_charge_end_time is not None:
my_predbat.args["charge_end_time"] = original_charge_end_time

return failed


def test_discharge_window_none_illegal_time(test_name, my_predbat, dummy_items):
"""
Test discharge window handling when time is illegal (e.g., 'unknown')
Expand Down Expand Up @@ -2633,6 +2693,10 @@ def run_inverter_tests(my_predbat_dummy):
if failed:
return failed

failed |= test_charge_window_no_source_configured("charge_window_no_source_configured", my_predbat, dummy_items)
if failed:
return failed

# Test discharge window None handling
failed |= test_discharge_window_none_illegal_time("discharge_window_illegal_time", my_predbat, dummy_items)
if failed:
Expand Down
49 changes: 49 additions & 0 deletions apps/predbat/tests/test_metrics_dashboard_soc_refresh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# -----------------------------------------------------------------------------
# Predbat Home Battery System
# Copyright Trefor Southwell 2026 - All Rights Reserved
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------
# fmt off
# pylint: disable=consider-using-f-string
# pylint: disable=line-too-long
# pylint: disable=attribute-defined-outside-init

"""
Test that the Metrics Dashboard's battery SoC doughnut chart center text reads live data on
refresh rather than being frozen at whatever it was on first page load - see issue #4151.

There's no JS execution/browser test infra in this codebase, so this can't run the embedded
JavaScript and check actual canvas output. Instead it checks the generated JS source itself:
the afterDraw plugin (Chart.js) must read the chart's own current data array and the always-
current MD_DATA global, not the pct/d values that were closed over when the chart was first
created in mdInitSOCChart - mdUpdateSOCChart (called on every 30s refresh once the chart
already exists) only mutates the chart's data array, it never recreates the chart, so a closure
over the original values would freeze the on-screen text at the first-load reading forever.
"""

from web_metrics_dashboard import get_metrics_dashboard_body


def test_soc_chart_center_text_reads_live_data(my_predbat):
"""
The afterDraw plugin body must not reference the pct/d parameters closed over at chart
creation time for the values it draws - it must read chart.data.datasets[0].data (updated
by mdUpdateSOCChart on every refresh) and the MD_DATA global (reassigned on every refresh).
"""
print("**** test_soc_chart_center_text_reads_live_data ****")

body = get_metrics_dashboard_body("{}")

# Isolate the md-center-text plugin's afterDraw function body, up to its closing "}]"
marker = "id: 'md-center-text'"
assert marker in body, "md-center-text plugin not found in dashboard body"
start = body.index(marker)
end = body.index("}]", start) + 2
plugin_block = body[start:end]

assert "chart.data.datasets[0].data[0]" in plugin_block, "afterDraw should read the live percentage from the chart's own current data, not a closed-over value"
assert "MD_DATA.battery_soc_kwh" in plugin_block, "afterDraw should read the live kWh figure from the MD_DATA global, not a closed-over value"

print("✓ afterDraw plugin reads live chart data and MD_DATA, not stale closure values")
print("✓ Test passed")
return False
39 changes: 39 additions & 0 deletions apps/predbat/tests/test_web_chart_currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# -----------------------------------------------------------------------------
# Predbat Home Battery System
# Copyright Trefor Southwell 2026 - All Rights Reserved
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------
# fmt off
# pylint: disable=consider-using-f-string
# pylint: disable=line-too-long
# pylint: disable=attribute-defined-outside-init

"""
Test that the Rates chart's per-series legend names (WebInterface.get_chart, web.py) use the
configured currency's minor unit rather than a hardcoded "p/kWh" - see issue #4153, where a
user configured for NZ dollars/cents saw "p/kWh" (pence) in the chart legend regardless.
"""


def test_rates_chart_series_names_use_currency_symbol(my_predbat):
"""
Directly mirrors the series-name formatting used in WebInterface.get_chart's "Rates"
branch (web.py) - get_chart itself requires a fully computed plan (soc_kw_best populated)
before it reaches this branch, so this tests the formatting logic directly rather than
standing up that heavier machinery.
Comment on lines +18 to +23
"""
print("**** test_rates_chart_series_names_use_currency_symbol ****")

for currency_symbols, expected_minor in [("£p", "p"), ("$c", "c"), ("€c", "c")]:
my_predbat.currency_symbols = currency_symbols
hourly_name = "Hourly {}/kWh".format(my_predbat.currency_symbols[1])
today_name = "Today {}/kWh".format(my_predbat.currency_symbols[1])

assert hourly_name == "Hourly {}/kWh".format(expected_minor), f"Expected 'Hourly {expected_minor}/kWh', got '{hourly_name}'"
assert today_name == "Today {}/kWh".format(expected_minor), f"Expected 'Today {expected_minor}/kWh', got '{today_name}'"
if expected_minor != "p":
assert "p/kWh" not in hourly_name and "p/kWh" not in today_name, f"Series name should not be hardcoded to pence for currency_symbols={currency_symbols}"

print("✓ Rates chart series names correctly follow currency_symbols[1] (£p, $c, €c all verified)")
print("✓ Test passed")
return False
Comment on lines +25 to +39
4 changes: 4 additions & 0 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
from tests.test_hainterface_lifecycle import run_hainterface_lifecycle_tests
from tests.test_hainterface_websocket import run_hainterface_websocket_tests
from tests.test_web_if import run_test_web_if
from tests.test_web_chart_currency import test_rates_chart_series_names_use_currency_symbol
from tests.test_metrics_dashboard_soc_refresh import test_soc_chart_center_text_reads_live_data
from tests.test_web_functions import run_web_functions_tests
from tests.test_window import run_window_sort_tests, run_intersect_window_tests
from tests.test_find_charge_rate import test_find_charge_rate, test_find_charge_rate_string_temperature, test_find_charge_rate_string_charge_curve
Expand Down Expand Up @@ -275,6 +277,8 @@ def main():
("manual_times", run_test_manual_times, "Manual times tests", False),
("manual_select", run_test_manual_select, "Manual select tests", False),
("web_if", run_test_web_if, "Web interface tests", False),
("web_chart_currency", test_rates_chart_series_names_use_currency_symbol, "Rates chart series names follow currency_symbols tests", False),
("metrics_dashboard_soc_refresh", test_soc_chart_center_text_reads_live_data, "Metrics dashboard SoC chart live-refresh tests", False),
("web_functions", run_web_functions_tests, "Web function unit tests", False),
("nordpool", run_nordpool_test, "Nordpool tests", False),
("futurerate_auto", test_futurerate_auto, "FutureRate auto Agile detection tests", False),
Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -2916,8 +2916,8 @@ def get_chart(self, chart):
{"name": "Import", "data": rates, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "stepline"},
{"name": "Export", "data": rates_export, "opacity": "0.2", "stroke_width": "2", "stroke_curve": "stepline", "chart_type": "area"},
{"name": "Gas", "data": rates_gas, "opacity": "0.2", "stroke_width": "2", "stroke_curve": "stepline", "chart_type": "area"},
{"name": "Hourly p/kWh", "data": cost_pkwh_hour, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "stepline"},
{"name": "Today p/kWh", "data": cost_pkwh_today, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "stepline"},
{"name": "Hourly {}/kWh".format(self.currency_symbols[1]), "data": cost_pkwh_hour, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "stepline"},
{"name": "Today {}/kWh".format(self.currency_symbols[1]), "data": cost_pkwh_today, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "stepline"},
]
text += self.render_chart(series_data, self.currency_symbols[1], "Energy Rates", now_str)
elif chart == "InDay":
Expand Down
9 changes: 7 additions & 2 deletions apps/predbat/web_metrics_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,16 +209,21 @@ def get_metrics_dashboard_body(data_json):
options: { cutout: '72%', responsive: false, plugins: { legend: {display:false}, tooltip: {enabled:false} } },
plugins: [{
id: 'md-center-text',
// Read live values (chart's own current data, and the always-current MD_DATA global) rather
// than the pct/d closed over at chart creation time - mdUpdateSOCChart only mutates the
// chart's data array on refresh, it doesn't recreate the chart, so a closure over the
// original values here would freeze this text at whatever it was on first load.
afterDraw: function (chart) {
var w = chart.width, h = chart.height, c2 = chart.ctx;
var livePct = chart.data.datasets[0].data[0] || 0;
c2.save();
c2.fillStyle = mdVar('--md-text');
c2.font = 'bold 2rem sans-serif';
c2.textAlign = 'center'; c2.textBaseline = 'middle';
c2.fillText(mdFmt(pct, 0) + '%', w / 2, h / 2 - 10);
c2.fillText(mdFmt(livePct, 0) + '%', w / 2, h / 2 - 10);
c2.font = '0.85rem sans-serif';
c2.fillStyle = mdVar('--md-muted');
c2.fillText(mdFmt(d.battery_soc_kwh, 1) + ' / ' + mdFmt(d.battery_max_kwh, 1) + ' kWh', w / 2, h / 2 + 18);
c2.fillText(mdFmt(MD_DATA.battery_soc_kwh, 1) + ' / ' + mdFmt(MD_DATA.battery_max_kwh, 1) + ' kWh', w / 2, h / 2 + 18);
c2.restore();
}
}]
Expand Down
Loading