diff --git a/apps/predbat/config.py b/apps/predbat/config.py
index fe483bbf6..ef305c747 100644
--- a/apps/predbat/config.py
+++ b/apps/predbat/config.py
@@ -990,6 +990,16 @@
"enable": "num_cars",
"enable_condition": "num_cars > 0",
},
+ {
+ "name": "car_charging_plan_date",
+ "friendly_name": "Car charging planned ready date",
+ "type": "select",
+ "options": ["Default"],
+ "icon": "mdi:calendar-end",
+ "default": "Default",
+ "enable": "num_cars",
+ "enable_condition": "num_cars > 0",
+ },
{
"name": "mode",
"friendly_name": "Predbat mode",
diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py
index b4150e0b3..d2ce1d171 100644
--- a/apps/predbat/fetch.py
+++ b/apps/predbat/fetch.py
@@ -1849,6 +1849,7 @@ def get_car_charging_planned(self):
self.car_charging_plan_smart = [False for c in range(self.num_cars)]
self.car_charging_plan_max_price = [0 for c in range(self.num_cars)]
self.car_charging_plan_time = ["07:00:00" for c in range(self.num_cars)]
+ self.car_charging_plan_date = ["Default" for c in range(self.num_cars)]
self.car_charging_battery_size = [100.0 for c in range(self.num_cars)]
self.car_charging_limit = [100.0 for c in range(self.num_cars)]
self.car_charging_rate = [7.4 for c in range(max(self.num_cars, 1))]
@@ -1884,9 +1885,10 @@ def get_car_charging_planned(self):
self.car_charging_now[car_n] = charging_now
# Other car related configuration
- self.car_charging_plan_smart[car_n] = self.get_arg("car_charging_plan_smart", False)
- self.car_charging_plan_max_price[car_n] = self.get_arg("car_charging_plan_max_price", 0.0)
- self.car_charging_plan_time[car_n] = self.get_arg("car_charging_plan_time", "07:00:00")
+ self.car_charging_plan_smart[car_n] = self.get_arg("car_charging_plan_smart", False, index=car_n)
+ self.car_charging_plan_max_price[car_n] = self.get_arg("car_charging_plan_max_price", 0.0, index=car_n)
+ self.car_charging_plan_time[car_n] = self.get_arg("car_charging_plan_time", "07:00:00", index=car_n)
+ self.car_charging_plan_date[car_n] = self.get_arg("car_charging_plan_date", "Default", index=car_n)
self.car_charging_battery_size[car_n] = dp2(float(self.get_arg("car_charging_battery_size", 100.0, index=car_n)))
car_postfix = "" if car_n == 0 else "_" + str(car_n)
self.car_charging_rate[car_n] = float(self.get_arg("car_charging_rate" + car_postfix))
@@ -2335,6 +2337,8 @@ def fetch_config_options(self):
self.manual_freeze_charge_times = self.manual_times("manual_freeze_charge")
self.manual_freeze_export_times = self.manual_times("manual_freeze_export")
self.manual_demand_times = self.manual_times("manual_demand")
+ if self.num_cars > 0:
+ self.car_plan_date_options()
self.manual_all_times = self.manual_charge_times + self.manual_export_times + self.manual_demand_times + self.manual_freeze_charge_times + self.manual_freeze_export_times
self.manual_api = self.api_select_update("manual_api")
self.manual_import_rates = self.manual_rates("manual_import_rates", default_rate=self.get_arg("manual_import_value"))
diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py
index da575048b..b37398d5f 100644
--- a/apps/predbat/plan.py
+++ b/apps/predbat/plan.py
@@ -4180,8 +4180,19 @@ def plan_car_charging(self, car_n, low_rates):
ready_minutes = ready_time.hour * 60 + ready_time.minute
- # Ready minutes wrap?
- if ready_minutes < self.minutes_now:
+ # Optional ready-by date (multi-day plan window). When the user has selected
+ # a future date in select.predbat_car_charging_plan_date, anchor the deadline
+ # to that absolute date plus the time-of-day above. "Default", an empty
+ # value, or a date <= today fall through to the existing 24-hour wrap.
+ plan_date_str = self.car_charging_plan_date[car_n] if car_n < len(self.car_charging_plan_date) else "Default"
+ plan_date = self.parse_car_plan_date(plan_date_str)
+ today = self.midnight_utc.date()
+
+ if plan_date and plan_date > today:
+ days_offset = (plan_date - today).days
+ ready_minutes += days_offset * 24 * 60
+ elif ready_minutes < self.minutes_now:
+ # Ready minutes wrap?
ready_minutes += 24 * 60
# Car charging now override
diff --git a/apps/predbat/tests/test_car_charging_plan_date.py b/apps/predbat/tests/test_car_charging_plan_date.py
new file mode 100644
index 000000000..332acf39d
--- /dev/null
+++ b/apps/predbat/tests/test_car_charging_plan_date.py
@@ -0,0 +1,196 @@
+# -----------------------------------------------------------------------------
+# 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
+"""Tests for the multi-day car_charging_plan_date dropdown and plan engine."""
+from datetime import timedelta
+
+from tests.test_infra import reset_inverter, reset_rates2
+
+
+def _format_date(my_predbat, day_offset):
+ """Format ``midnight_utc + day_offset`` using the userinterface CAR_PLAN_DATE_FORMAT."""
+ target = (my_predbat.midnight_utc + timedelta(days=day_offset)).date()
+ return target.strftime(my_predbat.CAR_PLAN_DATE_FORMAT)
+
+
+def _setup_single_car(my_predbat, plan_time="07:00:00", plan_date="Default"):
+ """Configure a single-car plan_car_charging scenario with shared defaults."""
+ my_predbat.car_charging_battery_size = [100.0]
+ my_predbat.car_charging_limit = [100.0]
+ my_predbat.car_charging_soc = [0.0]
+ my_predbat.car_charging_soc_next = [None]
+ my_predbat.car_charging_rate = [10.0]
+ my_predbat.car_charging_loss = 1.0
+ my_predbat.car_charging_plan_max_price = [99]
+ my_predbat.car_charging_plan_smart = [True]
+ my_predbat.car_charging_plan_time = [plan_time]
+ my_predbat.car_charging_plan_date = [plan_date]
+ my_predbat.car_charging_now = [False]
+ my_predbat.num_cars = 1
+
+
+def _test_default_preserves_wrap(my_predbat):
+ """Default sentinel preserves the existing 24-hour wrap behaviour."""
+ failed = False
+ print("**** Running Test: plan_date_default_preserves_wrap ****")
+ _setup_single_car(my_predbat, plan_time="07:00:00", plan_date="Default")
+ slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)
+ if not slots:
+ print("ERROR: Default plan_date should produce charging slots within 24h")
+ failed = True
+ return failed
+
+
+def _test_future_date_extends_window(my_predbat):
+ """A plan_date one day in the future doubles the planning window for the car."""
+ failed = False
+ print("**** Running Test: plan_date_future_extends_window ****")
+ _setup_single_car(my_predbat, plan_time="07:00:00", plan_date="Default")
+ default_slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)
+ default_kwh = sum(slot["kwh"] for slot in default_slots)
+
+ _setup_single_car(my_predbat, plan_time="07:00:00", plan_date=_format_date(my_predbat, 1))
+ future_slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)
+ future_kwh = sum(slot["kwh"] for slot in future_slots)
+
+ if future_kwh < default_kwh:
+ print("ERROR: Future plan_date should not reduce charging energy ({} < {})".format(future_kwh, default_kwh))
+ failed = True
+ return failed
+
+
+def _test_today_date_falls_through(my_predbat):
+ """A plan_date of today falls through to the existing wrap (treated as Default)."""
+ failed = False
+ print("**** Running Test: plan_date_today_falls_through ****")
+ _setup_single_car(my_predbat, plan_time="07:00:00", plan_date=_format_date(my_predbat, 0))
+ today_slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)
+
+ _setup_single_car(my_predbat, plan_time="07:00:00", plan_date="Default")
+ default_slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)
+
+ today_kwh = sum(slot["kwh"] for slot in today_slots)
+ default_kwh = sum(slot["kwh"] for slot in default_slots)
+ if today_kwh != default_kwh:
+ print("ERROR: Today plan_date should match Default behaviour ({} != {})".format(today_kwh, default_kwh))
+ failed = True
+ return failed
+
+
+def _test_invalid_date_falls_through(my_predbat):
+ """A malformed plan_date string is treated as Default rather than raising."""
+ failed = False
+ print("**** Running Test: plan_date_invalid_falls_through ****")
+ _setup_single_car(my_predbat, plan_time="07:00:00", plan_date="not a date")
+ parsed = my_predbat.parse_car_plan_date("not a date")
+ if parsed is not None:
+ print("ERROR: Invalid plan_date should parse as None, got {}".format(parsed))
+ failed = True
+ slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)
+ if not slots:
+ print("ERROR: Invalid plan_date should still produce charging slots via fallback")
+ failed = True
+ return failed
+
+
+def _test_stale_past_date_resets_to_default(my_predbat):
+ """A previously-selected date that has now passed is reset to Default in the dropdown."""
+ failed = False
+ print("**** Running Test: plan_date_stale_past_date_resets_to_default ****")
+ item = my_predbat.config_index.get("car_charging_plan_date")
+ if item is None:
+ print("ERROR: car_charging_plan_date config item missing")
+ return True
+
+ # Simulate a stored value from before today (parses cleanly but is in the past).
+ yesterday = (my_predbat.midnight_utc - timedelta(days=2)).date()
+ stale = yesterday.strftime(my_predbat.CAR_PLAN_DATE_FORMAT)
+ item["value"] = stale
+
+ my_predbat.forecast_plan_hours = 96
+ my_predbat.num_cars = 1
+ my_predbat.car_plan_date_options()
+
+ if item["value"] != my_predbat.CAR_PLAN_DATE_DEFAULT:
+ print("ERROR: Stale past date should reset to Default, got {}".format(item["value"]))
+ failed = True
+ if stale in item["options"]:
+ print("ERROR: Stale past date should be removed from options after reset, but {} still present".format(stale))
+ failed = True
+ return failed
+
+
+def _test_options_helper_respects_horizon(my_predbat):
+ """car_plan_date_options caps the dropdown at min(forecast_plan_hours, 96)//24 days."""
+ failed = False
+ print("**** Running Test: plan_date_options_respect_horizon ****")
+ item = my_predbat.config_index.get("car_charging_plan_date")
+ if item is None:
+ print("ERROR: car_charging_plan_date config item missing")
+ return True
+
+ my_predbat.forecast_plan_hours = 24
+ my_predbat.num_cars = 1
+ my_predbat.car_plan_date_options()
+ options_24h = list(item["options"])
+
+ my_predbat.forecast_plan_hours = 96
+ my_predbat.car_plan_date_options()
+ options_96h = list(item["options"])
+
+ if "Default" not in options_24h or "Default" not in options_96h:
+ print("ERROR: Default sentinel must always be present in options")
+ failed = True
+ if len(options_96h) <= len(options_24h):
+ print("ERROR: 96h horizon should expose more date options than 24h ({} <= {})".format(len(options_96h), len(options_24h)))
+ failed = True
+ return failed
+
+
+def _test_parse_round_trips(my_predbat):
+ """Formatted dates round-trip through parse_car_plan_date back to a date."""
+ failed = False
+ print("**** Running Test: plan_date_parse_round_trips ****")
+ for offset in range(0, 4):
+ formatted = _format_date(my_predbat, offset)
+ parsed = my_predbat.parse_car_plan_date(formatted)
+ expected = (my_predbat.midnight_utc + timedelta(days=offset)).date()
+ if parsed != expected:
+ print("ERROR: round-trip failed for offset {} ({} -> {} != {})".format(offset, formatted, parsed, expected))
+ failed = True
+
+ if my_predbat.parse_car_plan_date("Default") is not None:
+ print("ERROR: Default sentinel must parse as None")
+ failed = True
+ if my_predbat.parse_car_plan_date("") is not None:
+ print("ERROR: Empty string must parse as None")
+ failed = True
+ return failed
+
+
+def run_car_charging_plan_date_tests(my_predbat):
+ """Run the full car_charging_plan_date test suite."""
+ failed = False
+ reset_inverter(my_predbat)
+
+ print("**** Running Car Charging Plan Date tests ****")
+ import_rate = 10.0
+ export_rate = 5.0
+ reset_rates2(my_predbat, import_rate, export_rate)
+ my_predbat.low_rates, _, _ = my_predbat.rate_scan_window(my_predbat.rate_import, 5, my_predbat.rate_import_cost_threshold, False)
+
+ failed |= _test_parse_round_trips(my_predbat)
+ failed |= _test_default_preserves_wrap(my_predbat)
+ failed |= _test_today_date_falls_through(my_predbat)
+ failed |= _test_invalid_date_falls_through(my_predbat)
+ failed |= _test_future_date_extends_window(my_predbat)
+ failed |= _test_options_helper_respects_horizon(my_predbat)
+ failed |= _test_stale_past_date_resets_to_default(my_predbat)
+
+ return failed
diff --git a/apps/predbat/tests/test_fetch_config_options.py b/apps/predbat/tests/test_fetch_config_options.py
index 52eb59e9f..08988c601 100644
--- a/apps/predbat/tests/test_fetch_config_options.py
+++ b/apps/predbat/tests/test_fetch_config_options.py
@@ -69,10 +69,10 @@ def mock_get_state_wrapper(entity_id, default=None, attribute=None):
def mock_update_save_restore_list():
pass
- # Mock expose_config
+ # Mock expose_config (accept any kwargs the real implementation uses, e.g. force=True)
exposed_config_calls = []
- def mock_expose_config(key, value):
+ def mock_expose_config(key, value, *args, **kwargs):
exposed_config_calls.append((key, value))
# Apply mocks
diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py
index 5d03b84af..120ffc912 100644
--- a/apps/predbat/unit_test.py
+++ b/apps/predbat/unit_test.py
@@ -34,6 +34,7 @@
from tests.test_nordpool import run_nordpool_test
from tests.test_futurerate_auto import test_futurerate_auto
from tests.test_car_charging_smart import run_car_charging_smart_tests
+from tests.test_car_charging_plan_date import run_car_charging_plan_date_tests
from tests.test_plugin_startup import test_plugin_startup_order
from tests.test_optimise_levels import run_optimise_levels_tests
from tests.test_energydataservice import run_energydataservice_tests
@@ -243,6 +244,7 @@ def main():
("solax", run_solax_tests, "SolaX API tests", False),
("iboost_smart", run_iboost_smart_tests, "iBoost smart tests", False),
("car_charging_smart", run_car_charging_smart_tests, "Car charging smart tests", False),
+ ("car_charging_plan_date", run_car_charging_plan_date_tests, "Car charging plan_date multi-day tests", False),
("intersect_window", run_intersect_window_tests, "Intersect window tests", False),
("inverter_multi", run_inverter_multi_tests, "Inverter multi tests", False),
("octopus_free", test_octopus_free, "Octopus free electricity tests", False),
diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py
index d2617f4dc..768f1f990 100644
--- a/apps/predbat/userinterface.py
+++ b/apps/predbat/userinterface.py
@@ -1479,6 +1479,67 @@ def manual_times(self, config_item, exclude=[], new_value=None):
time_txt.append(self.time_abs_str(minute))
return time_overrides
+ CAR_PLAN_DATE_DEFAULT = "Default"
+ CAR_PLAN_DATE_FORMAT = "%a %d %b %Y"
+
+ def car_plan_date_options(self):
+ """
+ Refresh the car_charging_plan_date dropdown.
+
+ Builds the option list for ``select.predbat_car_charging_plan_date`` from
+ today out to the planning horizon (capped at the 96-hour engine ceiling),
+ mutates ``item["options"]`` and pushes the result to Home Assistant via
+ ``expose_config``. Mirrors the ``manual_times`` pattern.
+ """
+ item = self.config_index.get("car_charging_plan_date")
+ if item is None:
+ return
+
+ # Engine horizon ceiling: forecast_plan_hours is itself clamped to forecast_hours
+ # (see fetch.py self.forecast_plan_hours = max(min(get_arg("forecast_plan_hours"), forecast_hours), 8)),
+ # so we just read it back and cap at the 96-hour hard ceiling.
+ plan_hours = self.forecast_plan_hours if hasattr(self, "forecast_plan_hours") else 24
+ max_days_visible = max(1, min(int(plan_hours), 96) // 24)
+
+ today = self.midnight_utc.date()
+ options = [self.CAR_PLAN_DATE_DEFAULT]
+ for day_offset in range(max_days_visible + 1):
+ d = today + timedelta(days=day_offset)
+ options.append(d.strftime(self.CAR_PLAN_DATE_FORMAT))
+
+ # Auto-decay: if the saved selection is a date that has already passed,
+ # reset to "Default" so the dropdown reflects the user-facing promise that
+ # "when the selected date passes the entity reverts to Default" rather
+ # than carrying the stale option forward forever.
+ current = item.get("value", self.CAR_PLAN_DATE_DEFAULT)
+ parsed = self.parse_car_plan_date(current)
+ if parsed and parsed <= today:
+ current = self.CAR_PLAN_DATE_DEFAULT
+ elif current and current not in options:
+ # Future date held over from a previously-larger forecast horizon —
+ # keep it visible until the user changes it or the date itself decays.
+ options.append(current)
+
+ item["options"] = options
+ self.expose_config("car_charging_plan_date", current, force=True)
+
+ def parse_car_plan_date(self, value):
+ """
+ Parse a car_charging_plan_date option string into a ``datetime.date``.
+
+ Returns ``None`` for the "Default" sentinel, an empty string, or any
+ value that fails to parse. The plan engine treats ``None`` as "fall
+ through to the existing wrap-around behaviour".
+ """
+ if not value or value == self.CAR_PLAN_DATE_DEFAULT:
+ return None
+ try:
+ from datetime import datetime as _dt
+
+ return _dt.strptime(value, self.CAR_PLAN_DATE_FORMAT).date()
+ except (ValueError, TypeError):
+ return None
+
async def update_event(self, event, data, kwargs):
"""
Update event.
diff --git a/docs/car-charging.md b/docs/car-charging.md
index 7c11e43f1..fa1d81ce1 100644
--- a/docs/car-charging.md
+++ b/docs/car-charging.md
@@ -341,6 +341,11 @@ NB2: If you have **car_charging_soc** set and working for your car SoC sensor in
- Set **select.predbat_car_charging_plan_time** to the time you want the car charging to be completed by
+- Set **select.predbat_car_charging_plan_date** to pick the day the car needs to be ready by. The default is "Default" which means within the next 24 hours; pick a specific date (e.g. "Fri 09 May 2026") if the car will not be needed for several days. Once the chosen date passes, the entity automatically reverts to "Default".
+This setting only applies to Predbat-managed car charging. With Octopus Intelligent Go, the ready-by date is managed by Octopus and the dropdown has no effect.
+The list of selectable dates comes from your planning horizon, **input_number.predbat_forecast_plan_hours** (*expert mode*). On the default 24-hour horizon you will only see today; raise it (up to 96 hours) to see more days.
+Predbat needs import-rate data to plan ahead. Beyond the rates published by your tariff (typically 24 hours), it assumes the same daily rate pattern repeats. That works well for fixed tariffs and is a best-guess for variable tariffs such as Octopus Agile. See **input_number.predbat_metric_future_rate_offset_import** (*expert mode*) if you want to add pessimism to those assumed rates.
+
- Turn On **switch.predbat_car_charging_plan_smart** if you want to use the cheapest slots only. When disabled (turned Off) all low-rate slots will be used in time order.
Low-rate slots are time periods where the import rate is below the threshold determined by **input_number.predbat_rate_low_threshold** (*expert mode*).
By default this threshold is calculated automatically based upon future import rates - see [Battery margins and metrics options](customisation.md#battery-margins-and-metrics-options) for details of configuring this threshold.