diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 24ded5201..5a58057c7 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -71,6 +71,7 @@ byok cadata calcsize calib +calvind Cantarell cbat CDLL @@ -81,6 +82,7 @@ ceyyyy CGNAT Chae Chaf +charg chargedischarge chargeenergytotal chargefreeze @@ -113,6 +115,7 @@ creds crosscharge ctrdis ctypes +cubone customauthorizer customisation Customise @@ -213,6 +216,7 @@ etok euids EVAC EVDC +evenodd evergen evilspringfall evse @@ -359,6 +363,8 @@ libc libyaml LIFEPO linebreak +linecap +linejoin linestyle loadml loadmlpower @@ -450,6 +456,7 @@ notgithub notsn nrgheat numpy +nums OCPP octo octoplus @@ -478,6 +485,7 @@ oversizing overvoltage ownerapi parseable +partlycloudy pbat pbgw pcharging @@ -641,6 +649,7 @@ Strømligning subclassing submessage substep +subvalue sunspec sunsynk supabase @@ -681,12 +690,14 @@ tmpfs tmrw tojson topbar +TOTL tottime trapezoidally trapz Trefor treforsiphone treforsouthwell +TRGT triggerable tunables twinx @@ -727,6 +738,7 @@ Vallery varh venv Victron +VIEWBOX visibilitychange vvwwxxabcdefyyzzuu waf @@ -749,6 +761,7 @@ xaxistooltip xlabel xlim xload +Xmark xrtkq xticks xxabcdefyy diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 6aef46e77..aaeb5eee6 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -30,6 +30,7 @@ { "name": "version", "friendly_name": "Predbat Core Update", + "description": "Shows the installed Predbat version and provides the Home Assistant update entity for installing a newer core release.", "type": "update", "title": "Predbat", "installed_version": THIS_VERSION, @@ -41,18 +42,21 @@ { "name": "expert_mode", "friendly_name": "Expert Mode", + "description": "Shows advanced configuration controls that most installations do not normally need to change.", "type": "switch", "default": False, }, { "name": "performance_tweaks", "friendly_name": "Performance Tweaks", + "description": "Shows performance-related optimisation controls that can trade planning accuracy for faster calculation.", "type": "switch", "default": False, }, { "name": "active", "friendly_name": "Predbat Active", + "description": "Shows when Predbat is actively running; turning it on can also request an immediate Predbat update.", "type": "switch", "default": False, "restore": False, @@ -60,6 +64,7 @@ { "name": "compare_active", "friendly_name": "Predbat Compare Active", + "description": "Shows when Predbat is currently running a tariff or comparison calculation.", "type": "switch", "default": False, "restore": False, @@ -67,6 +72,7 @@ { "name": "pv_metric10_weight", "friendly_name": "Metric 10 Weight", + "description": "Controls how much influence the pessimistic PV10 solar forecast has on planning; higher values make the plan more cautious about solar generation.", "type": "input_number", "min": 0, "max": 1.0, @@ -78,6 +84,7 @@ { "name": "pv_scaling", "friendly_name": "PV Scaling", + "description": "Multiplies the solar forecast used by Predbat; 1.0 leaves the forecast unchanged, lower values reduce it and higher values increase it.", "type": "input_number", "min": 0, "max": 2.0, @@ -89,6 +96,7 @@ { "name": "load_scaling", "friendly_name": "Load Scaling", + "description": "Multiplies predicted household consumption; values above 1.0 make Predbat assume the house will use more energy.", "type": "input_number", "min": 0, "max": 2.0, @@ -100,6 +108,7 @@ { "name": "load_scaling10", "friendly_name": "Load Scaling PV10%", + "description": "Applies an additional pessimistic household-load scaling factor to the PV10 planning scenario.", "type": "input_number", "min": 0, "max": 2.0, @@ -111,6 +120,7 @@ { "name": "charge_scaling10", "friendly_name": "Charge Scaling PV10%", + "description": "Reduces the assumed battery charging capability in the pessimistic PV10 scenario; this affects planning only, not the inverter charge-rate setting.", "type": "input_number", "min": 0.1, "max": 1.0, @@ -123,6 +133,7 @@ { "name": "pv_metric90_weight", "friendly_name": "Metric 90 Weight", + "description": "Controls how much influence the optimistic PV90 solar forecast has on planning; higher values give more weight to better-than-expected solar.", "type": "input_number", "min": 0, "max": 1.0, @@ -135,6 +146,7 @@ { "name": "load_scaling90", "friendly_name": "Load Scaling PV90%", + "description": "Applies a lower household-load scaling factor to the optimistic PV90 planning scenario.", "type": "input_number", "min": 0, "max": 2.0, @@ -147,6 +159,7 @@ { "name": "load_scaling_saving", "friendly_name": "Load Scaling for saving sessions", + "description": "Adjusts predicted household consumption during Saving Sessions to account for deliberate demand reduction.", "type": "input_number", "min": 0, "max": 2.0, @@ -158,6 +171,7 @@ { "name": "load_scaling_free", "friendly_name": "Load Scaling for free sessions", + "description": "Adjusts predicted household consumption during free-electricity sessions to account for deliberately increased usage.", "type": "input_number", "min": 0, "max": 2.0, @@ -169,6 +183,7 @@ { "name": "battery_rate_max_scaling", "friendly_name": "Battery rate max scaling charge", + "description": "Scales the maximum battery charge rate used by Predbat when modelling the inverter; use it to correct an inaccurate nominal charge limit.", "type": "input_number", "min": 0, "max": 2.0, @@ -180,6 +195,7 @@ { "name": "battery_rate_max_scaling_discharge", "friendly_name": "Battery rate max scaling discharge", + "description": "Scales the maximum battery discharge rate used by Predbat when modelling the inverter; use it to correct an inaccurate nominal discharge limit.", "type": "input_number", "min": 0, "max": 2.0, @@ -191,6 +207,7 @@ { "name": "battery_loss", "friendly_name": "Battery loss charge ", + "description": "Models the fraction of energy lost while charging the battery.", "type": "input_number", "min": 0, "max": 1.0, @@ -202,6 +219,7 @@ { "name": "battery_loss_discharge", "friendly_name": "Battery loss discharge", + "description": "Models the fraction of stored energy lost while discharging the battery.", "type": "input_number", "min": 0, "max": 1.0, @@ -213,6 +231,7 @@ { "name": "inverter_loss", "friendly_name": "Inverter Loss", + "description": "Models energy lost through inverter AC/DC conversion.", "type": "input_number", "min": 0, "max": 1.0, @@ -224,12 +243,14 @@ { "name": "inverter_hybrid", "friendly_name": "Inverter Hybrid", + "description": "Enable for a DC-coupled hybrid inverter; disable for an AC-coupled battery so Predbat includes the appropriate conversion losses.", "type": "switch", "default": True, }, { "name": "inverter_soc_reset", "friendly_name": "Inverter SoC Reset", + "description": "Resets the inverter charge target to 100% outside charge windows where required to avoid unintentionally limiting solar charging.", "type": "switch", "enable": "expert_mode", "default": True, @@ -237,6 +258,7 @@ { "name": "inverter_set_charge_before", "friendly_name": "Inverter Set charge window before start", + "description": "Programs an upcoming charge window into the inverter before it starts instead of waiting until the start of the slot.", "type": "switch", "enable": "expert_mode", "default": True, @@ -244,6 +266,7 @@ { "name": "battery_capacity_nominal", "friendly_name": "Use the Battery Capacity Nominal size", + "description": "Uses the inverter-reported nominal battery capacity where supported instead of the normal usable-capacity value.", "type": "switch", "enable": "expert_mode", "default": False, @@ -251,6 +274,7 @@ { "name": "car_charging_energy_scale", "friendly_name": "Car charging energy scale", + "description": "Scales the energy reported by the configured EV charging sensor to correct its units or calibration.", "type": "input_number", "min": 0, "max": 1.0, @@ -262,6 +286,7 @@ { "name": "car_charging_threshold", "friendly_name": "Car charging threshold", + "description": "Power threshold above which Predbat considers the EV to be actively charging.", "type": "input_number", "min": 4, "max": 24, @@ -273,6 +298,7 @@ { "name": "car_charging_rate", "friendly_name": "Car charging rate (Car 0)", + "description": "Expected charging power for EV/charger 0, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -286,6 +312,7 @@ { "name": "car_charging_rate_1", "friendly_name": "Car charging rate (Car 1)", + "description": "Expected charging power for EV/charger 1, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -299,6 +326,7 @@ { "name": "car_charging_rate_2", "friendly_name": "Car charging rate (Car 2)", + "description": "Expected charging power for EV/charger 2, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -312,6 +340,7 @@ { "name": "car_charging_rate_3", "friendly_name": "Car charging rate (Car 3)", + "description": "Expected charging power for EV/charger 3, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -325,6 +354,7 @@ { "name": "car_charging_rate_4", "friendly_name": "Car charging rate (Car 4)", + "description": "Expected charging power for EV/charger 4, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -338,6 +368,7 @@ { "name": "car_charging_rate_5", "friendly_name": "Car charging rate (Car 5)", + "description": "Expected charging power for EV/charger 5, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -351,6 +382,7 @@ { "name": "car_charging_rate_6", "friendly_name": "Car charging rate (Car 6)", + "description": "Expected charging power for EV/charger 6, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -364,6 +396,7 @@ { "name": "car_charging_rate_7", "friendly_name": "Car charging rate (Car 7)", + "description": "Expected charging power for EV/charger 7, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -377,6 +410,7 @@ { "name": "car_charging_loss", "friendly_name": "Car charging loss", + "description": "Models charging losses between energy drawn by the EV charger and energy stored in the vehicle battery.", "type": "input_number", "min": 0, "max": 1.0, @@ -390,6 +424,7 @@ { "name": "best_soc_min", "friendly_name": "Best SoC Min", + "description": "Sets a lower battery state-of-charge target used by the optimiser when selecting charge and export targets.", "type": "input_number", "min": 0, "max": 100.0, @@ -402,6 +437,7 @@ { "name": "best_soc_max", "friendly_name": "Best SoC Max", + "description": "Caps the battery state of charge Predbat will plan to charge to; 0 disables the cap.", "type": "input_number", "min": 0, "max": 100.0, @@ -414,6 +450,7 @@ { "name": "best_soc_keep", "friendly_name": "Best SoC Keep", + "description": "Adds a soft preference to retain at least this much energy in the home battery for later use.", "type": "input_number", "min": 0, "max": 100.0, @@ -425,6 +462,7 @@ { "name": "best_soc_keep_weight", "friendly_name": "Best SoC Keep Weighting", + "description": "Controls how strongly Predbat values retaining the Best SoC Keep energy; higher values make it more reluctant to use that reserve.", "type": "input_number", "min": 0.1, "max": 5, @@ -437,6 +475,7 @@ { "name": "metric_min_improvement_plan", "friendly_name": "Metric Min Improvement Plan", + "description": "Minimum improvement required before Predbat considers a newly calculated plan sufficiently better than the previous plan.", "type": "input_number", "min": -50, "max": 250.0, @@ -449,6 +488,7 @@ { "name": "metric_min_improvement", "friendly_name": "Metric Min Improvement Charge", + "description": "Minimum financial improvement required before Predbat changes battery state of charge by adding or altering a charge action.", "type": "input_number", "min": -50, "max": 50.0, @@ -462,6 +502,7 @@ "name": "metric_min_improvement_export", "oldname": "metric_min_improvement_discharge", "friendly_name": "Metric Min Improvement Export", + "description": "Minimum financial improvement required before Predbat adds a forced battery export to the plan.", "type": "input_number", "min": -50, "max": 50.0, @@ -474,6 +515,7 @@ { "name": "metric_min_improvement_swap", "friendly_name": "Metric Min Improvement Swap", + "description": "Controls how much improvement is required before Predbat moves an export to a different time; small negative values can favour later exports.", "type": "input_number", "min": -50, "max": 50.0, @@ -486,6 +528,7 @@ { "name": "metric_min_improvement_export_freeze", "friendly_name": "Metric Min Improvement Export Freeze", + "description": "Minimum improvement required before Predbat uses an export-freeze action instead of normal operation.", "type": "input_number", "min": -50, "max": 50.0, @@ -498,6 +541,7 @@ { "name": "metric_battery_cycle", "friendly_name": "Metric Battery Cycle Cost", + "description": "Adds a virtual cost for charging and discharging the battery so Predbat avoids marginal battery cycling.", "type": "input_number", "min": -50, "max": 50.0, @@ -510,6 +554,7 @@ { "name": "metric_battery_value_scaling", "friendly_name": "Metric Battery Value Scaling", + "description": "Changes how valuable Predbat considers energy left in the battery at the end of the forecast; higher values encourage retaining more energy.", "type": "input_number", "min": 0, "max": 2.0, @@ -522,6 +567,7 @@ { "name": "metric_battery_value_export_scaling", "friendly_name": "Metric Battery Value Scaling when export is worthless", + "description": "Discounts the end-of-plan value of stored battery energy when surplus energy cannot later be exported for a useful price.", "type": "input_number", "min": 0, "max": 1.0, @@ -534,6 +580,7 @@ { "name": "metric_future_rate_offset_import", "friendly_name": "Metric Future Rate Offset Import", + "description": "Adjustment added to future import prices that have not yet been published, useful when modelling variable tariffs.", "type": "input_number", "min": -50, "max": 50.0, @@ -546,6 +593,7 @@ { "name": "metric_future_rate_offset_export", "friendly_name": "Metric Future Rate Offset Export", + "description": "Adjustment added to future export prices that have not yet been published.", "type": "input_number", "min": -50, "max": 50.0, @@ -558,6 +606,7 @@ { "name": "metric_inday_adjust_damping", "friendly_name": "In-day adjustment damping factor", + "description": "Controls how strongly Predbat applies the difference between today’s actual and forecast consumption to the remaining in-day load forecast.", "type": "input_number", "min": 0.5, "max": 2.0, @@ -570,6 +619,7 @@ { "name": "metric_cloud_enable", "friendly_name": "Enable Cloud Model", + "description": "Adds realistic short-term peaks and dips to the solar forecast to model passing clouds while keeping overall forecast energy similar.", "type": "switch", "default": True, "enable": "expert_mode", @@ -577,6 +627,7 @@ { "name": "metric_load_divergence_enable", "friendly_name": "Enable Load Divergence Model", + "description": "Adds short-term variation to predicted household demand instead of assuming perfectly smooth consumption.", "type": "switch", "default": True, "enable": "expert_mode", @@ -584,6 +635,7 @@ { "name": "metric_pv_calibration_enable", "friendly_name": "Enable use of Calibrated PV data", + "description": "Uses historical actual-versus-forecast solar production to calibrate future PV forecasts.", "type": "switch", "default": True, "enable": "expert_mode", @@ -591,12 +643,14 @@ { "name": "metric_dynamic_load_adjust", "friendly_name": "Dynamic Load Adjust", + "description": "Temporarily adjusts the current and near-term load forecast when live consumption is unusually high or low.", "type": "switch", "default": False, }, { "name": "metric_self_sufficiency", "friendly_name": "Metric Self Sufficiency", + "description": "Adds a virtual penalty to grid imports so the optimiser favours self-consumption even where importing would be slightly cheaper.", "type": "input_number", "min": 0, "max": 100, @@ -609,6 +663,7 @@ { "name": "set_reserve_min", "friendly_name": "Set Reserve Min (Inverter 1)", + "description": "Minimum battery percentage Predbat allows inverter 1 to discharge to; it protects reserve capacity but does not itself force charging to that level.", "type": "input_number", "min": 0, "max": 100, @@ -621,6 +676,7 @@ { "name": "set_reserve_min_1", "friendly_name": "Set Reserve Min (Inverter 2)", + "description": "Minimum battery percentage Predbat allows inverter 2 to discharge to; it protects reserve capacity but does not itself force charging to that level.", "type": "input_number", "min": 0, "max": 100, @@ -635,6 +691,7 @@ { "name": "set_reserve_min_2", "friendly_name": "Set Reserve Min (Inverter 3)", + "description": "Minimum battery percentage Predbat allows inverter 3 to discharge to; it protects reserve capacity but does not itself force charging to that level.", "type": "input_number", "min": 0, "max": 100, @@ -649,6 +706,7 @@ { "name": "set_reserve_min_3", "friendly_name": "Set Reserve Min (Inverter 4)", + "description": "Minimum battery percentage Predbat allows inverter 4 to discharge to; it protects reserve capacity but does not itself force charging to that level.", "type": "input_number", "min": 0, "max": 100, @@ -663,6 +721,7 @@ { "name": "rate_low_threshold", "friendly_name": "Rate Low Threshold", + "description": "Controls which import prices qualify as possible charging periods relative to future prices; 0 lets Predbat determine the threshold automatically.", "type": "input_number", "min": 0.00, "max": 2.00, @@ -675,6 +734,7 @@ { "name": "rate_high_threshold", "friendly_name": "Rate High Threshold", + "description": "Controls which export prices qualify as possible export periods; 0 lets Predbat determine the threshold automatically.", "type": "input_number", "min": 0.00, "max": 2.00, @@ -687,6 +747,7 @@ { "name": "combine_rate_threshold", "friendly_name": "Combine Rate Threshold", + "description": "Allows nearby tariff rates within this price difference to be treated as one larger rate block; 0 keeps each rate change separate.", "type": "input_number", "min": 0, "max": 1000, @@ -699,12 +760,14 @@ { "name": "car_charging_hold", "friendly_name": "Car charging hold (remove car charging energy from load data)", + "description": "Removes identified historical EV charging from household load data so it is not forecast again as ordinary house consumption.", "type": "switch", "default": True, }, { "name": "car_energy_reported_load", "friendly_name": "Car energy is reported in load data (inside CT clamp)", + "description": "Tells Predbat whether EV charging is already included in the measured household load because the charger is inside the load CT clamp.", "type": "switch", "default": True, "enable_condition": "num_cars > 0", @@ -712,6 +775,7 @@ { "name": "car_charging_manual_soc", "friendly_name": "Car charging manual SOC (Car 0)", + "description": "Uses Predbat’s manually entered EV energy value for car 0 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -720,6 +784,7 @@ { "name": "car_charging_manual_soc_kwh", "friendly_name": "Car manual SOC kWh (Car 0)", + "description": "Current energy stored in car 0, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -733,6 +798,7 @@ { "name": "car_charging_manual_soc_1", "friendly_name": "Car charging manual SOC (Car 1)", + "description": "Uses Predbat’s manually entered EV energy value for car 1 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -741,6 +807,7 @@ { "name": "car_charging_manual_soc_kwh_1", "friendly_name": "Car manual SOC kWh (Car 1)", + "description": "Current energy stored in car 1, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -754,6 +821,7 @@ { "name": "car_charging_manual_soc_2", "friendly_name": "Car charging manual SOC (Car 2)", + "description": "Uses Predbat’s manually entered EV energy value for car 2 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -762,6 +830,7 @@ { "name": "car_charging_manual_soc_kwh_2", "friendly_name": "Car manual SOC kWh (Car 2)", + "description": "Current energy stored in car 2, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -775,6 +844,7 @@ { "name": "car_charging_manual_soc_3", "friendly_name": "Car charging manual SOC (Car 3)", + "description": "Uses Predbat’s manually entered EV energy value for car 3 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -783,6 +853,7 @@ { "name": "car_charging_manual_soc_kwh_3", "friendly_name": "Car manual SOC kWh (Car 3)", + "description": "Current energy stored in car 3, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -796,6 +867,7 @@ { "name": "car_charging_manual_soc_4", "friendly_name": "Car charging manual SOC (Car 4)", + "description": "Uses Predbat’s manually entered EV energy value for car 4 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -804,6 +876,7 @@ { "name": "car_charging_manual_soc_kwh_4", "friendly_name": "Car manual SOC kWh (Car 4)", + "description": "Current energy stored in car 4, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -817,6 +890,7 @@ { "name": "car_charging_manual_soc_5", "friendly_name": "Car charging manual SOC (Car 5)", + "description": "Uses Predbat’s manually entered EV energy value for car 5 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -825,6 +899,7 @@ { "name": "car_charging_manual_soc_kwh_5", "friendly_name": "Car manual SOC kWh (Car 5)", + "description": "Current energy stored in car 5, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -838,6 +913,7 @@ { "name": "car_charging_manual_soc_6", "friendly_name": "Car charging manual SOC (Car 6)", + "description": "Uses Predbat’s manually entered EV energy value for car 6 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -846,6 +922,7 @@ { "name": "car_charging_manual_soc_kwh_6", "friendly_name": "Car manual SOC kWh (Car 6)", + "description": "Current energy stored in car 6, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -859,6 +936,7 @@ { "name": "car_charging_manual_soc_7", "friendly_name": "Car charging manual SOC (Car 7)", + "description": "Uses Predbat’s manually entered EV energy value for car 7 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -867,6 +945,7 @@ { "name": "car_charging_manual_soc_kwh_7", "friendly_name": "Car manual SOC kWh (Car 7)", + "description": "Current energy stored in car 7, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -880,12 +959,14 @@ { "name": "octopus_intelligent_charging", "friendly_name": "Octopus Intelligent Charging", + "description": "Uses charging slots supplied by Octopus Intelligent rather than relying only on Predbat-generated EV charging slots.", "type": "switch", "default": True, }, { "name": "octopus_saving_auto_join", "friendly_name": "Octopus Saving Session Auto Join", + "description": "Automatically joins supported Octopus Saving Sessions when Predbat discovers them.", "type": "switch", "default": True, }, @@ -904,6 +985,7 @@ { "name": "octopus_intelligent_ignore_unplugged", "friendly_name": "Ignore Intelligent slots when car is unplugged", + "description": "Ignores Intelligent Octopus charging slots when Predbat knows the car is not connected.", "type": "switch", "default": False, "enable": "expert_mode", @@ -911,6 +993,7 @@ { "name": "octopus_intelligent_consider_full", "friendly_name": "Consider car full as part of Intelligent plan", + "description": "Makes Intelligent Octopus slot modelling take the configured EV full or charge-limit state into account.", "type": "switch", "default": False, "enable": "expert_mode", @@ -932,6 +1015,7 @@ { "name": "car_charging_plan_smart", "friendly_name": "Car Charging Plan Smart", + "description": "Selects the cheapest suitable periods needed for EV charging instead of treating every eligible low-rate slot as a charging period.", "type": "switch", "default": True, "enable": "num_cars", @@ -940,6 +1024,7 @@ { "name": "car_charging_plan_max_price", "friendly_name": "Car Charging Plan max price", + "description": "Maximum electricity price Predbat may use for its planned EV charging slots.", "type": "input_number", "min": -1000, "max": 1000, @@ -953,6 +1038,7 @@ { "name": "car_charging_from_battery", "friendly_name": "Allow car to charge from battery", + "description": "Allows EV charging to use energy from the home battery; when disabled Predbat protects the home battery while the car is charging.", "type": "switch", "default": False, "reset_inverter": True, @@ -963,6 +1049,7 @@ "name": "calculate_export_oncharge", "oldname": "calculate_discharge_oncharge", "friendly_name": "Calculate Export within charge slots", + "description": "Allows the optimiser to place an export opportunity within periods that were initially considered for battery charging.", "type": "switch", "enable": "expert_mode", "default": False, @@ -970,6 +1057,7 @@ { "name": "calculate_export_on_pv", "friendly_name": "Calculate export slots during solar production", + "description": "Allows forced battery export opportunities to be considered while solar generation is expected.", "type": "switch", "enable": "expert_mode", "default": True, @@ -977,6 +1065,7 @@ { "name": "calculate_second_pass", "friendly_name": "Calculate full second pass (slower)", + "description": "Runs a slower second optimisation pass over charge and export windows to refine the plan.", "type": "switch", "enable": "performance_tweaks", "default": True, @@ -984,6 +1073,7 @@ { "name": "calculate_pv90_plan", "friendly_name": "Calculate PV90 upside plan", + "description": "Includes the optimistic PV90 solar scenario in optimisation; disabling it speeds planning but ignores that upside scenario.", "type": "switch", "enable": "performance_tweaks", "default": True, @@ -991,6 +1081,7 @@ { "name": "calculate_import_low_export", "friendly_name": "Calculate import slots on low export rate slots first", + "description": "When import prices are equal, prefers charging in slots where the opportunity cost of exporting is lowest.", "type": "switch", "enable": "expert_mode", "default": True, @@ -999,6 +1090,7 @@ { "name": "calculate_export_high_import", "friendly_name": "Calculate export slots on high import rate slots first", + "description": "When export prices are equal, uses import prices as a tie-breaker when ordering export opportunities.", "type": "switch", "enable": "expert_mode", "default": True, @@ -1006,12 +1098,14 @@ { "name": "calculate_inday_adjustment", "friendly_name": "Calculate in-day adjustment", + "description": "Compares today’s actual consumption with today’s forecast and adjusts the remaining load forecast when they diverge.", "type": "switch", "default": True, }, { "name": "calculate_plan_every", "friendly_name": "Calculate plan every N minutes", + "description": "Sets how often Predbat fully recalculates the optimised plan; inverter control can still run more frequently.", "type": "input_number", "min": 5, "max": 60, @@ -1024,6 +1118,7 @@ { "name": "combine_charge_slots", "friendly_name": "Combine Charge Slots", + "description": "Treats adjacent charge periods as larger blocks, which can speed optimisation and reduce oscillation but gives the optimiser less flexibility.", "type": "switch", "default": False, }, @@ -1031,6 +1126,7 @@ "name": "combine_export_slots", "oldname": "combine_discharge_slots", "friendly_name": "Combine Export Slots", + "description": "Treats adjacent export periods as larger blocks, which can speed optimisation but may miss a slightly better export arrangement.", "type": "switch", "enable": "expert_mode", "default": False, @@ -1038,30 +1134,35 @@ { "name": "set_status_notify", "friendly_name": "Set Status Notify", + "description": "Sends notifications when Predbat changes operating state, such as Charging, Demand or Exporting.", "type": "switch", "default": True, }, { "name": "set_inverter_notify", "friendly_name": "Set Inverter Notify", + "description": "Sends notifications when Predbat changes inverter parameters such as charge rates, targets or operating windows.", "type": "switch", "default": False, }, { "name": "set_event_notify", "friendly_name": "Set Event Notify", + "description": "Sends notifications about external energy events such as Saving Sessions or other supported events.", "type": "switch", "default": True, }, { "name": "set_system_notify", "friendly_name": "Set System Notify", + "description": "Sends notifications about Predbat itself, such as updates, restarts and settings save or restore operations.", "type": "switch", "default": True, }, { "name": "set_charge_freeze", "friendly_name": "Set Charge Freeze", + "description": "Allows Predbat to hold battery state of charge during a charge window rather than discharging the battery to supply the house.", "type": "switch", "enable": "expert_mode", "default": True, @@ -1070,6 +1171,7 @@ { "name": "set_charge_freeze_only", "friendly_name": "Set Charge Freeze Only", + "description": "Prevents grid charging during charge windows so Predbat may only hold the battery level rather than increase it.", "type": "switch", "enable": "expert_mode", "default": False, @@ -1078,6 +1180,7 @@ { "name": "set_charge_low_power", "friendly_name": "Set Charge Low Power Mode", + "description": "Uses the lowest battery charge rate Predbat believes will still reach the target on time instead of charging at full power.", "type": "switch", "default": False, "reset_inverter": True, @@ -1085,6 +1188,7 @@ { "name": "set_export_low_power", "friendly_name": "Set Export Low Power Mode", + "description": "Uses a reduced forced-discharge rate where the planned export target can still be achieved without exporting at maximum power.", "type": "switch", "default": False, "reset_inverter": True, @@ -1092,6 +1196,7 @@ { "name": "charge_low_power_margin", "friendly_name": "Low power mode margin", + "description": "Sets how many minutes early low-power charging should aim to reach its target, providing a safety margin.", "type": "input_number", "min": 0, "max": 60, @@ -1123,6 +1228,7 @@ { "name": "set_reserve_enable", "friendly_name": "Set Reserve Enable", + "description": "Allows Predbat to use the inverter reserve setting to prevent discharge below the planned minimum state of charge.", "type": "switch", "enable": "expert_mode", "default": True, @@ -1132,6 +1238,7 @@ "name": "set_export_freeze", "oldname": "set_discharge_freeze", "friendly_name": "Set Export Freeze", + "description": "Allows Predbat to prevent battery charging so surplus solar can be exported instead.", "type": "switch", "default": True, "reset_inverter": True, @@ -1140,6 +1247,7 @@ "name": "set_export_freeze_only", "oldname": "set_discharge_freeze_only", "friendly_name": "Set Export Freeze Only", + "description": "Prevents forced battery discharge; Predbat may export surplus solar but will not deliberately empty the battery into the grid.", "type": "switch", "enable": "expert_mode", "default": False, @@ -1148,12 +1256,14 @@ { "name": "set_discharge_during_charge", "friendly_name": "Set discharge During Charge", + "description": "Controls whether Predbat prevents battery discharge during charge slots, mainly to avoid cross-charging in multi-inverter systems.", "type": "switch", "default": True, }, { "name": "set_freeze_export_during_demand", "friendly_name": "Set Freeze Export during Demand", + "description": "Prevents the battery from charging during normal Demand mode while still allowing discharge, mainly as a multi-inverter cross-charging control.", "type": "switch", "enable": "expert_mode", "default": False, @@ -1162,12 +1272,14 @@ { "name": "export_more_solar", "friendly_name": "Export more solar", + "description": "Where the cost difference is small, favours exporting surplus solar rather than storing it in the battery.", "type": "switch", "default": False, }, { "name": "export_more_solar_threshold", "friendly_name": "Export more solar threshold", + "description": "Maximum extra plan cost Predbat will tolerate when favouring solar export instead of storing that energy.", "type": "input_number", "min": 0, "max": 1000, @@ -1180,6 +1292,7 @@ { "name": "set_read_only", "friendly_name": "Read Only mode", + "description": "Lets Predbat calculate its normal plan but prevents it from changing inverter settings.", "type": "switch", "default": False, "reset_inverter_force": True, @@ -1187,30 +1300,35 @@ { "name": "chat_confirm_writes", "friendly_name": "Chat confirm before changing settings", + "description": "Requires confirmation before the Predbat chat or AI feature changes configuration or applies a write action.", "type": "switch", "default": True, }, { "name": "chat_web_search", "friendly_name": "Chat web search (costs per request)", + "description": "Allows the Predbat chat or AI feature to search the wider web through its configured provider, which may incur API charges.", "type": "switch", "default": False, }, { "name": "ai_ha_state_enable", "friendly_name": "AI: allow reading Home Assistant state", + "description": "Allows the Predbat AI feature to read Home Assistant entity states and history outside Predbat itself.", "type": "switch", "default": True, }, { "name": "balance_inverters_enable", "friendly_name": "Balance Inverters Enable (Beta)", + "description": "Enables Predbat’s multi-inverter battery balancing logic.", "type": "switch", "default": False, }, { "name": "balance_inverters_charge", "friendly_name": "Balance Inverters for charging", + "description": "Adjusts individual inverter charge rates when battery states of charge diverge during charging.", "type": "switch", "enable": "balance_inverters_enable", "default": False, @@ -1218,6 +1336,7 @@ { "name": "balance_inverters_discharge", "friendly_name": "Balance Inverters for discharge", + "description": "Adjusts individual inverter discharge rates when battery states of charge diverge during discharge.", "type": "switch", "enable": "balance_inverters_enable", "default": False, @@ -1225,6 +1344,7 @@ { "name": "balance_inverters_crosscharge", "friendly_name": "Balance Inverters for cross-charging", + "description": "Prevents one inverter or battery unnecessarily charging another while the system is in normal operation.", "type": "switch", "enable": "balance_inverters_enable", "default": True, @@ -1232,6 +1352,7 @@ { "name": "balance_inverters_threshold_charge", "friendly_name": "Balance Inverters threshold charge", + "description": "Minimum state-of-charge difference between batteries before charge balancing begins.", "type": "input_number", "min": 1, "max": 20, @@ -1244,6 +1365,7 @@ { "name": "balance_inverters_threshold_discharge", "friendly_name": "Balance Inverters threshold discharge", + "description": "Minimum state-of-charge difference between batteries before discharge balancing begins.", "type": "input_number", "min": 1, "max": 20, @@ -1256,6 +1378,7 @@ { "name": "debug_enable", "friendly_name": "Debug Enable", + "description": "Enables detailed Predbat diagnostic logging and debug output; intended for troubleshooting rather than normal operation.", "type": "switch", "icon": "mdi:bug-outline", "default": False, @@ -1263,6 +1386,7 @@ { "name": "debug_history_enable", "friendly_name": "Debug history rolling capture enable", + "description": "Keeps periodic diagnostic snapshots so a problem can be investigated after it has occurred.", "type": "switch", "icon": "mdi:history", "default": True, @@ -1270,6 +1394,7 @@ { "name": "debug_history_count", "friendly_name": "Debug history snapshot count", + "description": "Number of historical diagnostic snapshots Predbat retains.", "type": "input_number", "min": 1, # The maximum only bounds what a user can opt into, the default below is what almost every @@ -1288,6 +1413,7 @@ { "name": "debug_history_interval", "friendly_name": "Debug history snapshot interval", + "description": "Number of hours between automatic diagnostic-history snapshots.", "type": "input_number", "min": 1, "max": 24, @@ -1299,6 +1425,7 @@ { "name": "debug_history_force_capture", "friendly_name": "Debug history force capture now", + "description": "Requests an immediate diagnostic-history snapshot and then returns to the off state.", "type": "switch", "icon": "mdi:camera", "default": False, @@ -1306,6 +1433,7 @@ { "name": "car_charging_plan_time", "friendly_name": "Car charging planned ready time", + "description": "Time by which Predbat should aim to have planned EV charging completed.", "type": "select", "options": OPTIONS_TIME, "icon": "mdi:clock-end", @@ -1316,6 +1444,7 @@ { "name": "mode", "friendly_name": "Predbat mode", + "description": "Chooses how much control Predbat has, from monitoring only through charge control to full charge and forced-export control.", "type": "select", "options": PREDBAT_MODE_OPTIONS, "icon": "mdi:state-machine", @@ -1325,6 +1454,7 @@ { "name": "update", "friendly_name": "Predbat update", + "description": "Selects a Predbat core update action or version when an update is available.", "type": "select", "options": PREDBAT_UPDATE_OPTIONS, "icon": "mdi:state-machine", @@ -1335,6 +1465,7 @@ { "name": "manual_charge", "friendly_name": "Manual force charge", + "description": "Forces selected future slots to be treated as battery charging periods.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1345,6 +1476,7 @@ { "name": "manual_export", "friendly_name": "Manual force export", + "description": "Forces selected future slots to discharge and export the battery; this takes priority over a manual charge in the same slot.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1355,6 +1487,7 @@ { "name": "manual_demand", "friendly_name": "Manual force demand", + "description": "Forces normal Demand operation for selected slots, with no forced charge or export.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1365,6 +1498,7 @@ { "name": "manual_import_rates", "friendly_name": "Manual import rates", + "description": "Overrides the import tariff used by Predbat for selected future slots.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1375,6 +1509,7 @@ { "name": "manual_export_rates", "friendly_name": "Manual export rates", + "description": "Overrides the export tariff used by Predbat for selected future slots.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1385,6 +1520,7 @@ { "name": "manual_load_adjust", "friendly_name": "Manual load adjustment", + "description": "Adds or removes a specified amount of energy from the predicted household load in selected future slots.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1395,6 +1531,7 @@ { "name": "manual_import_value", "friendly_name": "Manual import value", + "description": "Default import price applied when creating a Manual Import Rates override.", "type": "input_number", "min": -50, "max": 1000, @@ -1406,6 +1543,7 @@ { "name": "manual_export_value", "friendly_name": "Manual export value", + "description": "Default export price applied when creating a Manual Export Rates override.", "type": "input_number", "min": -50, "max": 1000, @@ -1417,6 +1555,7 @@ { "name": "manual_load_value", "friendly_name": "Manual load adjustment value", + "description": "Default kWh adjustment applied when creating a Manual Load Adjust override.", "type": "input_number", "min": -10, "max": 10, @@ -1428,6 +1567,7 @@ { "name": "manual_soc", "friendly_name": "Manual SOC target", + "description": "Sets a minimum battery state-of-charge target that Predbat must aim to reach by the selected time.", "type": "select", "options": ["off"], "icon": "mdi:battery-charging", @@ -1438,6 +1578,7 @@ { "name": "manual_soc_value", "friendly_name": "Manual SOC target value", + "description": "Percentage used as the target for Manual SoC overrides.", "type": "input_number", "min": 0, "max": 100, @@ -1470,6 +1611,7 @@ { "name": "manual_api", "friendly_name": "Manual API controls", + "description": "Advanced interface for temporarily overriding Predbat configuration or controls from Home Assistant automations or API actions.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1480,6 +1622,7 @@ { "name": "manual_freeze_charge", "friendly_name": "Manual force charge freeze", + "description": "Forces selected future slots to hold battery state of charge by preventing discharge.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1490,6 +1633,7 @@ { "name": "manual_freeze_export", "friendly_name": "Manual force export freeze", + "description": "Forces selected future slots to prevent battery charging so surplus solar can be exported while the battery may still supply demand.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1500,6 +1644,7 @@ { "name": "saverestore", "friendly_name": "Save/restore settings", + "description": "Saves the current Predbat settings, restores a saved set, or restores the default configuration.", "type": "select", "options": PREDBAT_SAVE_RESTORE, "icon": "mdi:state-machine", @@ -1510,12 +1655,14 @@ { "name": "auto_update", "friendly_name": "Predbat automatic update enable", + "description": "Automatically installs new Predbat releases when they become available.", "type": "switch", "default": False, }, { "name": "load_filter_modal", "friendly_name": "Apply modal filter historical load", + "description": "Filters unusually low historical-load days from the load average when enough historical days are available.", "type": "switch", "enable": "expert_mode", "default": True, @@ -1523,18 +1670,21 @@ { "name": "iboost_enable", "friendly_name": "iBoost enable", + "description": "Enables Predbat’s hot-water or solar-diverter model and planning controls.", "type": "switch", "default": False, }, { "name": "carbon_enable", "friendly_name": "Carbon enable", + "description": "Includes configured electricity carbon intensity when evaluating plans.", "type": "switch", "default": False, }, { "name": "carbon_metric", "friendly_name": "Carbon Metric", + "description": "Sets the financial-style weighting assigned to carbon emissions; higher values make lower-carbon plans more valuable to the optimiser.", "type": "input_number", "min": 0, "max": 500, @@ -1547,6 +1697,7 @@ { "name": "iboost_solar", "friendly_name": "iBoost on solar power", + "description": "Allows solar generation to be used for hot-water or diverter operation.", "type": "switch", "default": True, "enable": "iboost_enable", @@ -1554,6 +1705,7 @@ { "name": "iboost_solar_excess", "friendly_name": "iBoost solar only runs when exporting excess", + "description": "Restricts solar diversion to genuine surplus energy that would otherwise be exported.", "type": "switch", "default": False, "enable": "iboost_enable", @@ -1561,6 +1713,7 @@ { "name": "iboost_gas", "friendly_name": "iBoost when import electricity cheaper than gas", + "description": "Allows electric hot-water heating when importing electricity is cheaper than the modelled cost of gas heating.", "type": "switch", "default": False, "enable": "iboost_enable", @@ -1568,6 +1721,7 @@ { "name": "iboost_gas_export", "friendly_name": "iBoost when export electricity cheaper than gas", + "description": "Compares the value of exported electricity with gas heating before diverting surplus electricity to hot water.", "type": "switch", "default": False, "enable": "iboost_enable", @@ -1575,6 +1729,7 @@ { "name": "iboost_charging", "friendly_name": "iBoost when battery charging", + "description": "Allows the diverter to run while the home battery is charging.", "type": "switch", "default": False, "enable": "iboost_enable", @@ -1582,6 +1737,7 @@ { "name": "iboost_rate_threshold", "friendly_name": "iBoost max import boost price", + "description": "Maximum import electricity price at which Predbat may run the diverter.", "type": "input_number", "min": -10, "max": 1000, @@ -1594,6 +1750,7 @@ { "name": "iboost_rate_threshold_export", "friendly_name": "iBoost max export boost price", + "description": "Maximum export value Predbat is willing to give up by diverting energy to hot water.", "type": "input_number", "min": -10, "max": 1000, @@ -1606,6 +1763,7 @@ { "name": "iboost_smart", "friendly_name": "iBoost when import rates are lowest to hit target energy", + "description": "Chooses the cheapest import-price slots needed to meet the daily diverter energy target.", "type": "switch", "enable": "iboost_enable", "default": False, @@ -1613,6 +1771,7 @@ { "name": "iboost_smart_min_length", "friendly_name": "iBoost smart min length", + "description": "Minimum continuous duration of a price-based smart diverter boost period.", "type": "input_number", "min": 30, "max": 120, @@ -1626,6 +1785,7 @@ "name": "iboost_on_export", "oldname": "iboost_on_export", "friendly_name": "iBoost is allowed when battery is force exporting", + "description": "Allows the diverter to operate while Predbat is deliberately force-exporting the home battery.", "type": "switch", "enable": "iboost_enable", "default": False, @@ -1633,6 +1793,7 @@ { "name": "iboost_prevent_discharge", "friendly_name": "When iBoost is active prevent battery discharge", + "description": "Prevents the home battery from discharging to supply the diverter while it is active.", "type": "switch", "enable": "iboost_enable", "default": False, @@ -1640,6 +1801,7 @@ { "name": "iboost_gas_scale", "friendly_name": "iBoost gas price scaling", + "description": "Scales the gas price, for example to account for boiler efficiency, before comparing gas heating with electric diversion.", "type": "input_number", "min": 0, "max": 2.0, @@ -1652,6 +1814,7 @@ { "name": "iboost_max_energy", "friendly_name": "iBoost max energy", + "description": "Maximum amount of energy the diverter should consume in one day.", "type": "input_number", "min": 0, "max": 30, @@ -1663,6 +1826,7 @@ { "name": "iboost_today", "friendly_name": "iBoost today", + "description": "Tracks Predbat’s estimate of how much energy the diverter has used today.", "type": "input_number", "min": 0, "max": 30, @@ -1674,6 +1838,7 @@ { "name": "iboost_max_power", "friendly_name": "iBoost max power", + "description": "Maximum electrical power the modelled diverter can consume.", "type": "input_number", "min": 0, "max": 5000, @@ -1685,6 +1850,7 @@ { "name": "iboost_min_power", "friendly_name": "iBoost min power", + "description": "Minimum useful operating power for the modelled diverter.", "type": "input_number", "min": 0, "max": 5000, @@ -1696,6 +1862,7 @@ { "name": "iboost_min_soc", "friendly_name": "iBoost min soc", + "description": "Minimum home-battery state of charge required before solar energy may be diverted to hot water.", "type": "input_number", "min": 0, "max": 100, @@ -1708,6 +1875,7 @@ { "name": "iboost_value_scaling", "friendly_name": "iBoost value scaling", + "description": "Controls how valuable Predbat considers energy sent to hot water; higher values make diversion more attractive to the optimiser.", "type": "input_number", "min": 0, "max": 2.0, @@ -1720,6 +1888,7 @@ { "name": "iboost_energy_subtract", "friendly_name": "Subtract iBoost energy from load predictions", + "description": "Removes measured diverter energy from historical household load so it is not forecast again as normal house demand.", "type": "switch", "enable": "iboost_enable", "default": True, @@ -1727,6 +1896,7 @@ { "name": "holiday_days_left", "friendly_name": "Holiday days left", + "description": "Enables holiday load forecasting for this many remaining days and counts the value down as the holiday progresses.", "type": "input_number", "min": 0, "max": 28, @@ -1739,6 +1909,7 @@ { "name": "holiday_load_scaling", "friendly_name": "Holiday load scaling", + "description": "Temporary scaling applied to normal household consumption while in holiday mode until Predbat has enough holiday-use history.", "type": "input_number", "min": 0.1, "max": 1.0, @@ -1750,6 +1921,7 @@ { "name": "forecast_plan_hours", "friendly_name": "Plan forecast hours", + "description": "Sets the minimum number of future hours Predbat considers when constructing and optimising the plan.", "type": "input_number", "min": 8, "max": 96, @@ -1762,6 +1934,7 @@ { "name": "plan_debug", "friendly_name": "HTML Plan Debug", + "description": "Adds extra optimiser and diagnostic information to the generated HTML plan.", "type": "switch", "default": False, "enable": "expert_mode", @@ -1769,12 +1942,14 @@ { "name": "predheat_enable", "friendly_name": "Enable PredHeat", + "description": "Enables the optional PredHeat model so expected heating demand and temperature can be included in planning.", "type": "switch", "default": False, }, { "name": "next_volume_temp", "friendly_name": "Volume Temperature Next", + "description": "Stores the estimated heating-system or radiator-water temperature carried into the next PredHeat calculation when needed.", "type": "input_number", "min": -20, "max": 40, @@ -1786,6 +1961,7 @@ { "name": "inverter_limit_override", "friendly_name": "Inverter Limit Override", + "description": "Artificially caps the battery or inverter power limit used by Predbat where the installation should not use the reported maximum.", "type": "input_number", "min": 0, "max": 50000, @@ -2609,6 +2785,7 @@ "ha_key": {"type": "string", "empty": False}, "load_filter_threshold": {"type": "integer"}, "web_port": {"type": "integer"}, + "web_ui": {"type": "string", "allowed": ["legacy", "modern"]}, # The chat agent's LLM endpoint. Named chat_api_* rather than openrouter_* because the # endpoint no longer has to be OpenRouter: any OpenAI-compatible API works, including a local # Ollama. The openrouter_* names are still accepted so an existing apps.yaml keeps working. @@ -2725,17 +2902,69 @@ "pv_forecast_tomorrow": {"type": "sensor", "sensor_type": "float"}, "pv_forecast_d3": {"type": "sensor", "sensor_type": "float"}, "pv_forecast_d4": {"type": "sensor", "sensor_type": "float"}, - "car_charging_energy": {"type": "sensor", "sensor_type": "float", "transient_ok": True}, + "car_charging_energy": { + "type": "sensor|sensor_list", + "sensor_type": "float", + "transient_ok": True, + "description": "Home Assistant energy entity for each EV charger, used for today's charging total on Overview.", + }, "car_charging_power": {"type": "sensor|sensor_list", "sensor_type": "float", "transient_ok": True}, + "car_charging_status": { + "type": "sensor|sensor_list", + "sensor_type": "string", + "transient_ok": True, + "entries": "num_cars", + "optional_entries": True, + "description": "Home Assistant status entity for each EV charger, such as unplugged, connected or charging.", + }, "num_cars": {"type": "integer", "zero": True}, "car_charging_planned": {"type": "sensor|sensor_list", "sensor_type": "string|boolean", "entries": "num_cars"}, "car_charging_planned_response": {"type": "string_list"}, "car_charging_now": {"type": "sensor|sensor_list", "sensor_type": "string|boolean", "entries": "num_cars"}, "car_charging_now_response": {"type": "string_list"}, "car_charging_battery_size": {"type": "sensor", "zero": False, "sensor_type": "float", "entries": "num_cars"}, - "car_charging_soc": {"type": "sensor", "sensor_type": "float", "entries": "num_cars"}, + "car_charging_soc": { + "type": "sensor|sensor_list", + "sensor_type": "float", + "entries": "num_cars", + "description": "Home Assistant state-of-charge entity for each configured car.", + }, "car_charging_limit": {"type": "sensor", "sensor_type": "float", "entries": "num_cars"}, "car_charging_exclusive": {"type": "boolean_list", "entries": "num_cars"}, + "weather": { + "type": "sensor", + "sensor_type": "string", + "transient_ok": True, + "description": "Home Assistant weather entity used by Overview when PredHeat is not configured.", + }, + "ashp_enable": { + "type": "boolean", + "description": "Optional Overview ASHP-card override. PredHeat in pump mode shows the card automatically; set false to hide it or true when PredHeat is not configured.", + }, + "ashp_power": { + "type": "sensor", + "sensor_type": "float", + "transient_ok": True, + "description": "Home Assistant entity reporting the heat pump's current power draw.", + }, + "ashp_status": { + "type": "sensor", + "sensor_type": "string", + "transient_ok": True, + "description": "Home Assistant entity reporting the heat pump state, such as off, heating or hot_water.", + }, + "heat_energy": { + "type": "sensor", + "sensor_type": "float", + "transient_ok": True, + "description": "Home Assistant entity reporting today's heat-pump energy use when PredHeat is not configured.", + }, + "ashp_energy_today": { + "type": "sensor", + "sensor_type": "float", + "transient_ok": True, + "description": "Legacy alias for heat_energy, retained so existing Overview configurations continue to work.", + }, "carbon_intensity": {"type": "sensor", "sensor_type": "string"}, "carbon_postcode": {"type": "string", "empty": False}, "carbon_automatic": {"type": "boolean"}, diff --git a/apps/predbat/config/apps.yaml b/apps/predbat/config/apps.yaml index 333db4f7b..0e5b928c7 100644 --- a/apps/predbat/config/apps.yaml +++ b/apps/predbat/config/apps.yaml @@ -332,7 +332,7 @@ pred_bat: # is used for car_charging_hold feature to filter out car charging from the previous load data # Automatically set to detect Wallbox and Zappi, if it doesn't match manually enter your sensor name # Also adjust car_charging_energy_scale if it's not in kwH to fix the units - car_charging_energy: 're:(sensor.myenergi_zappi_[0-9a-z]+_charge_added_session|sensor.wallbox_portal_added_energy)' + car_charging_energy: 're:(sensor.myenergi_zappi_[0-9a-z]+_charge_added_session|sensor.[0-9a-z_]+_myenergi_zappi_[0-9a-z]+_session_energy|sensor.wallbox_portal_added_energy)' # Defines the number of cars modelled by the system, set to 0 for no car num_cars: 1 @@ -345,6 +345,10 @@ pred_bat: car_charging_planned: - 're:(sensor.wallbox_portal_status_description|sensor.myenergi_zappi_[0-9a-z]+_plug_status|sensor.tesla_wall_connector_status)' + # Optional live charger state used by the modern Overview page. + car_charging_status: + - 're:(sensor.wallbox_portal_status_description|sensor.myenergi_zappi_[0-9a-z]+_plug_status|sensor.[0-9a-z_]+_myenergi_zappi_[0-9a-z]+_plug_status|sensor.tesla_wall_connector_status)' + car_charging_planned_response: - 'yes' - 'on' diff --git a/apps/predbat/frontend.zip b/apps/predbat/frontend.zip new file mode 100644 index 000000000..363653d45 Binary files /dev/null and b/apps/predbat/frontend.zip differ diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 684a91e46..a11fb21b0 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -4185,10 +4185,16 @@ def fetch_octopus_sessions(self, axle_sessions=None): octopus_free_slot["start"] = start octopus_free_slot["end"] = end octopus_free_slot["rate"] = 0 + if event.get("event_type", None): + octopus_free_slot["event_type"] = event["event_type"] octopus_free_slots.append(octopus_free_slot) # Direct Octopus URL if "octopus_free_url" in self.args: free_online = self.download_octopus_free(self.get_arg("octopus_free_url", indirect=False)) + for slot in free_online: + # The public website feed exposes only times and a zero rate. Keep + # that provenance so the UI does not guess Power Up or Happy Hour. + slot.setdefault("event_type", "FREE_ELECTRICITY") octopus_free_slots.extend(free_online) # Load free electricity events from Octopus flexibility API @@ -4205,6 +4211,8 @@ def fetch_octopus_sessions(self, axle_sessions=None): octopus_free_slot["start"] = start octopus_free_slot["end"] = end octopus_free_slot["rate"] = 0 + if event.get("event_type", None): + octopus_free_slot["event_type"] = event["event_type"] octopus_free_slots.append(octopus_free_slot) # Octopus saving session @@ -4364,7 +4372,7 @@ def fetch_octopus_sessions(self, axle_sessions=None): continue if abs((start_time - self.now_utc).days) <= 3: self.log("Octopus: Joined Octopus Weekend Happy Hour {}-{} - treating as a free electricity session".format(start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"))) - octopus_free_slots.append({"start": start, "end": end, "rate": 0}) + octopus_free_slots.append({"start": start, "end": end, "rate": 0, "event_type": "WEEKEND_HAPPY_HOUR"}) continue # Skip events with no rate info unless default is configured diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 77c2e397c..24bb7dcf6 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -22,7 +22,7 @@ from datetime import timedelta from predbat import THIS_VERSION_DISPLAY from const import TIME_FORMAT, PREDICT_STEP, EXPORT_LIMIT_IDLE, MINUTE_WATT, FULL_EXPORT_POWER, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, CHARGE_STATE_PRECEDENCE, EXPORT_STATE_PRECEDENCE -from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state, find_charge_rate, export_mode_of, export_target_of, export_power_of, export_limit_sort_key, pack_export_limit, export_limit_from_stored +from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state, find_charge_rate, export_mode_of, export_target_of, export_power_of, export_limit_sort_key, pack_export_limit, export_limit_from_stored, minutes_to_time, str2time from prediction import Prediction # Per-slot plan "why" reason templates. Keyed by a stable reason code, each template is @@ -57,6 +57,41 @@ } +def event_slot_contains_minute(slot, minute, midnight_utc, start_key="start", end_key="end"): + """Return whether an event slot contains an absolute plan minute.""" + start = slot.get(start_key) + end = slot.get(end_key) + if not start or not end: + return False + try: + start_minute = minutes_to_time(str2time(start), midnight_utc) + end_minute = minutes_to_time(str2time(end), midnight_utc) + except (TypeError, ValueError): + return False + return start_minute <= minute < end_minute + + +def plan_rate_event_type(base, minute): + """Return the programme that produced a plan row's generic ``saving`` rate tag.""" + matching_free_slots = [slot for slot in base.octopus_free_slots if event_slot_contains_minute(slot, minute, base.midnight_utc)] + if any(slot.get("event_type") == "WEEKEND_HAPPY_HOUR" for slot in matching_free_slots): + return "octopus_happy_hour" + if any(slot.get("event_type") == "FREE_ELECTRICITY" for slot in matching_free_slots): + return "octopus_free_electricity" + if any("POWER_UP" in str(slot.get("event_type", "")).upper() or slot.get("event_type") == "TURN_UP" for slot in matching_free_slots): + return "octopus_power_up" + if matching_free_slots: + return "octopus_free_electricity" + for slot in base.octopus_saving_slots: + active_undated_slot = slot.get("state") and not slot.get("start") and not slot.get("end") and (base.minutes_now // 30) * 30 <= minute < (base.minutes_now // 30) * 30 + 30 + if active_undated_slot or event_slot_contains_minute(slot, minute, base.midnight_utc): + return "octopus_power_down" + for slot in base.axle_sessions: + if event_slot_contains_minute(slot, minute, base.midnight_utc, "start_time", "end_time"): + return "axle_{}".format(slot.get("import_export", "event")) + return "energy_event" + + def yesterday_slot_is_exporting(slot_status): """True when a historical ``predbat.status`` string (already lower-cased) represents export activity for the "yesterday" plan reconstruction in ``calculate_yesterday()``. @@ -1075,8 +1110,13 @@ def short_textual_plan(self, soc_min, soc_min_minute, pv_forecast_minute_step, p ) if publish: + # Existing HTML version used by the classic Predbat interface. self.text_plan = self.get_text_plan_html(sentence) + # Keep the original textual version as well so that newer + # interfaces can consume it without having to parse HTML. + self.text_plan_raw = sentence + return sentence def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_start, pv_forecast_minute_step): @@ -1169,7 +1209,12 @@ def import_rate_color(rate): raw_plan["import_cost_threshold"] = import_cost_threshold raw_plan["export_cost_threshold"] = export_cost_threshold - raw_plan["reason_templates"] = REASON_TEMPLATES + # Reason templates are shared by the legacy and modern plan views. Replace the + # historical pence label once here so every client receives the configured minor unit. + raw_plan["reason_templates"] = {code: template.replace("p/kWh", "{}/kWh".format(self.currency_symbols[1])) for code, template in REASON_TEMPLATES.items()} + + raw_plan["description"] = [line.strip()[2:] if line.strip().startswith("- ") else line.strip() for line in self.text_plan_raw.splitlines() if line.strip()] + raw_plan["currency_symbols"] = self.currency_symbols raw_plan["soc"] = prediction.soc_kw if prediction is not None else self.soc_kw raw_plan["soc_max"] = prediction.soc_max if prediction is not None else self.soc_max @@ -1793,6 +1838,8 @@ def import_rate_color(rate): export_rate_adjust_type = self.rate_export_replicated.get(minute) if export_rate_adjust_type is not None: json_row["export_rate_adjust_type"] = export_rate_adjust_type + if import_rate_adjust_type == "saving" or export_rate_adjust_type == "saving": + json_row["rate_event_type"] = plan_rate_event_type(self, minute_start) # Add adjusted rates (always included for client-side debug toggle) json_row["import_rate_adjusted"] = dp2(rate_value_import / self.battery_loss / self.inverter_loss + self.metric_battery_cycle) json_row["export_rate_adjusted"] = dp2(rate_value_export * self.battery_loss_discharge * self.inverter_loss - self.metric_battery_cycle) @@ -2730,6 +2777,17 @@ def record_status(self, message, debug="", had_errors=False, notify=False, extra if had_errors: error_count += 1 + status_text = (message + extra).lower() + status_icon = "mdi:information" + if "hold for car" in status_text: + status_icon = "mdi:car" + elif status_text.startswith("demand"): + status_icon = "mdi:house" + elif status_text.startswith("charg"): + status_icon = "mdi:battery-charging" + elif status_text.startswith("export"): + status_icon = "mdi:transmission-tower-export" + # Home Assistant rejects entity states over 255 characters, and this message is the state # of the status sensor. Clamp what is written as the state - the full text survives in # current_status, the log line and the notification, and attributes have no such cap. @@ -2742,7 +2800,7 @@ def record_status(self, message, debug="", had_errors=False, notify=False, extra attributes={ "friendly_name": "Status", "detail": extra, - "icon": "mdi:information", + "icon": status_icon, "last_updated": self.now_utc_real.strftime(TIME_FORMAT), "debug": debug, "version": THIS_VERSION_DISPLAY, diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 93508ee50..88ebfe414 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -84,6 +84,7 @@ dp3, find_unmasked_secret_paths, is_entity_id, + normalise_entity_prefix, mask_secret_args, malloc_trim, limit_malloc_arenas, @@ -315,6 +316,7 @@ def reset(self): Init stub """ self.text_plan = "Computing please wait..." + self.text_plan_raw = "" self.prediction_cache_enable = True self.base_load = 0 self.plan_interval_minutes = self.args.get("plan_interval_minutes", 30) @@ -361,7 +363,9 @@ def reset(self): self.dashboard_index = [] self.dashboard_index_app = {} self.dashboard_values = {} - self.prefix = self.args.get("prefix", "predbat") + self.configured_prefix = self.args.get("prefix", "predbat") + self.prefix = normalise_entity_prefix(self.configured_prefix) + self.args["prefix"] = self.prefix self.current_status = None self.previous_status = None self.had_errors = False @@ -774,6 +778,7 @@ def _emit_snapshot_metrics(self): # Config validity m.config_valid.set(0 if self.arg_errors else 1) m.config_warnings.set(len(self.arg_errors) if self.arg_errors else 0) + m.config_errors = dict(self.arg_errors) # Errors if self.had_errors: @@ -1803,8 +1808,8 @@ def validate_config(self): if spec.get("modify", False): prefix = sensor.split(".")[0] if prefix not in ["switch", "select", "input_number", "number", "time", "input_number", "input_datetime"]: - if sensor.startswith("sensor.predbat_"): - # We can ignore predbat generated sensors as they are control placeholders + if sensor.startswith("sensor.{}_".format(self.prefix)): + # Generated sensor controls are placeholders implemented by the inverter adapter. pass else: self.log("Warn: Validation of apps.yaml found configuration item '{}' element {} which cannot be modified".format(name, sensor)) @@ -2029,6 +2034,8 @@ def initialize(self): try: self.reset() self.update_time(print=False) + if str(self.configured_prefix) != self.prefix: + self.log("Warn: apps.yaml prefix '{}' is not valid in a Home Assistant entity ID; using '{}' instead".format(self.configured_prefix, self.prefix)) # Start all sub-components self.components = Components(self) diff --git a/apps/predbat/predbat_metrics.py b/apps/predbat/predbat_metrics.py index 82ec964bb..bc9ce12fb 100644 --- a/apps/predbat/predbat_metrics.py +++ b/apps/predbat/predbat_metrics.py @@ -76,6 +76,7 @@ def __init__(self): self.last_update_timestamp = _gauge("predbat_last_update_timestamp", "Timestamp of last update") self.config_valid = _gauge("predbat_config_valid", "Configuration is valid (1) or has errors (0)") self.config_warnings = _gauge("predbat_config_warnings", "Number of configuration warnings") + self.config_errors = {} # -- Plan -------------------------------------------------------------- self.plan_valid = _gauge("predbat_plan_valid", "Whether the current plan is valid") @@ -202,6 +203,7 @@ def _api_services(m): "last_update_timestamp": _val(self.last_update_timestamp), "config_valid": _val(self.config_valid), "config_warnings": _val(self.config_warnings), + "config_errors": dict(self.config_errors), # Plan "plan_valid": _val(self.plan_valid), "plan_age_minutes": _val(self.plan_age_minutes), diff --git a/apps/predbat/tests/test_component_health_status.py b/apps/predbat/tests/test_component_health_status.py index c8543daa1..a4e263c67 100644 --- a/apps/predbat/tests/test_component_health_status.py +++ b/apps/predbat/tests/test_component_health_status.py @@ -84,6 +84,20 @@ def test_record_status_state_clamped(my_predbat): failed = 1 else: print("OK: state clamped to 255 characters ({}), full text kept in current_status ({})".format(len(state), len(my_predbat.current_status))) + + expected_icons = [ + ("Demand", "", "mdi:house"), + ("Charging", "", "mdi:battery-charging"), + ("Exporting", "", "mdi:transmission-tower-export"), + ("Demand", ", Hold for car", "mdi:car"), + ("Idle", "", "mdi:information"), + ] + for message, extra, expected_icon in expected_icons: + my_predbat.record_status(message, extra=extra) + icon = my_predbat.dashboard_values.get(my_predbat.prefix + ".status", {}).get("attributes", {}).get("icon") + if icon != expected_icon: + print("ERROR: Status {}{} used icon {}, expected {}".format(message, extra, icon, expected_icon)) + failed = 1 finally: my_predbat.current_status = "" diff --git a/apps/predbat/tests/test_plan_json_rate_adjust.py b/apps/predbat/tests/test_plan_json_rate_adjust.py index 8ea257df1..31dfb2c59 100644 --- a/apps/predbat/tests/test_plan_json_rate_adjust.py +++ b/apps/predbat/tests/test_plan_json_rate_adjust.py @@ -8,6 +8,8 @@ # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init +from datetime import timedelta + from prediction import Prediction from tests.test_infra import reset_inverter, reset_rates, update_rates_import @@ -140,7 +142,45 @@ def run_test_plan_json_rate_adjust(my_predbat): my_predbat.rate_import_replicated = {} my_predbat.rate_export_replicated = {} - # --- Test 3: car rate diverging from the house rate (batpred#4646) --- + # --- Test 3: the generic saving tag retains its source event in the JSON row --- + print("Test plan JSON output identifies the source energy event") + event_start = (my_predbat.midnight_utc + timedelta(minutes=test_minute - 5)).isoformat() + event_end = (my_predbat.midnight_utc + timedelta(minutes=test_minute + 60)).isoformat() + original_saving_slots = my_predbat.octopus_saving_slots + original_free_slots = my_predbat.octopus_free_slots + original_axle_sessions = my_predbat.axle_sessions + event_cases = [ + ("octopus_power_down", [{"start": event_start, "end": event_end}], [], []), + ("octopus_free_electricity", [], [{"start": event_start, "end": event_end}], []), + ("octopus_power_up", [], [{"start": event_start, "end": event_end, "event_type": "POWER_UP"}], []), + ("octopus_free_electricity", [], [{"start": event_start, "end": event_end, "event_type": "FREE_ELECTRICITY"}], []), + ("octopus_happy_hour", [], [{"start": event_start, "end": event_end, "event_type": "WEEKEND_HAPPY_HOUR"}], []), + ( + "octopus_happy_hour", + [], + [{"start": event_start, "end": event_end}, {"start": event_start, "end": event_end, "event_type": "WEEKEND_HAPPY_HOUR"}], + [], + ), + ("axle_export", [], [], [{"start_time": event_start, "end_time": event_end, "import_export": "export"}]), + ] + try: + my_predbat.rate_import_replicated = {test_minute: "saving"} + for expected_type, saving_slots, free_slots, axle_sessions in event_cases: + my_predbat.octopus_saving_slots = saving_slots + my_predbat.octopus_free_slots = free_slots + my_predbat.axle_sessions = axle_sessions + _, event_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False) + event_row = next((row for row in event_plan["rows"] if row.get("slot_minute") == test_minute), None) + if event_row is None or event_row.get("rate_event_type") != expected_type: + print("ERROR: Expected rate_event_type='{}' got '{}'".format(expected_type, event_row and event_row.get("rate_event_type"))) + failed = True + finally: + my_predbat.rate_import_replicated = {} + my_predbat.octopus_saving_slots = original_saving_slots + my_predbat.octopus_free_slots = original_free_slots + my_predbat.axle_sessions = original_axle_sessions + + # --- Test 4: car rate diverging from the house rate (batpred#4646) --- print("Test plan JSON output with a car rate that diverges from the house rate") my_predbat.num_cars = 1 car_minute = my_predbat.minutes_now diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index 08f359bdb..cbae48605 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -852,6 +852,20 @@ def render(car_hold_minutes=None): print("ERROR: expected toggleForceDropdown to null-guard document.getElementById(id) before using it") failed = True + # --- Test 17: shared reason templates use the configured currency unit --- + print("Test reason templates use the configured minor currency symbol") + original_currency_symbols = my_predbat.currency_symbols + try: + my_predbat.currency_symbols = "€c" + _, currency_plan = render() + currency_templates = currency_plan["reason_templates"] + currency_text = " ".join(currency_templates.values()) + if "c/kWh" not in currency_text or "p/kWh" in currency_text: + print("ERROR: reason templates did not replace pence with the configured cents unit: {}".format(currency_templates)) + failed = True + finally: + my_predbat.currency_symbols = original_currency_symbols + if not failed: print("All plan why-reason tests passed") return failed diff --git a/apps/predbat/tests/test_saving_session.py b/apps/predbat/tests/test_saving_session.py index f4e988fbe..a1dd4a9b8 100644 --- a/apps/predbat/tests/test_saving_session.py +++ b/apps/predbat/tests/test_saving_session.py @@ -1547,7 +1547,7 @@ def run(joined_yaml): print(" Test 1: WEEKEND_HAPPY_HOUR is free, TURN_DOWN is a saving slot") octopus_free_slots, octopus_saving_slots = run(happy_hour + "\n" + turn_down_rewarded) - expected_free = [{"start": "{}T11:00:00+{}:00".format(date_today, tz_offset), "end": "{}T12:00:00+{}:00".format(date_today, tz_offset), "rate": 0}] + expected_free = [{"start": "{}T11:00:00+{}:00".format(date_today, tz_offset), "end": "{}T12:00:00+{}:00".format(date_today, tz_offset), "rate": 0, "event_type": "WEEKEND_HAPPY_HOUR"}] expected_saving = [{"start": "{}T17:00:00+{}:00".format(date_today, tz_offset), "end": "{}T18:00:00+{}:00".format(date_today, tz_offset), "rate": 50.0, "state": False}] if json.dumps(octopus_free_slots) != json.dumps(expected_free): print("ERROR: Expecting free slots {} got {}".format(expected_free, octopus_free_slots)) diff --git a/apps/predbat/tests/test_validate_config.py b/apps/predbat/tests/test_validate_config.py index dfe329d0c..ef8a902f0 100644 --- a/apps/predbat/tests/test_validate_config.py +++ b/apps/predbat/tests/test_validate_config.py @@ -30,6 +30,8 @@ import os import tempfile +from utils import normalise_entity_prefix + def _run(my_predbat, extra_args, extra_states=None, expect_errors=(), expect_clean=()): """Inject args/states, run validate_config, assert per-field expectations. @@ -66,6 +68,10 @@ def test_validate_config(my_predbat): """Comprehensive validator tests covering every validation type in APPS_SCHEMA.""" print("**** test_validate_config ****") + print(" [prefix] invalid Home Assistant characters are normalised") + assert normalise_entity_prefix("Predbat-Dev") == "predbat_dev" + assert normalise_entity_prefix("123 test") == "predbat_123_test" + # ========================================================================== # STRING type (gateway_mqtt_host: {"type": "string", "empty": False}) # ========================================================================== @@ -367,6 +373,19 @@ def test_validate_config(my_predbat): expect_clean=["charge_rate"], ) + print(" [sensor_list modify] generated controls respect a custom Predbat prefix") + saved_prefix = my_predbat.prefix + try: + my_predbat.prefix = "battery_site" + _run( + my_predbat, + {"charge_rate": ["sensor.battery_site_GS_0_charge_rate"], "num_inverters": 1}, + extra_states={"sensor.battery_site_GS_0_charge_rate": 2.5}, + expect_clean=["charge_rate"], + ) + finally: + my_predbat.prefix = saved_prefix + print(" [sensor_list modify] select. prefix is allowed for modification") _run( my_predbat, diff --git a/apps/predbat/tests/test_web_annual.py b/apps/predbat/tests/test_web_annual.py index d39a50327..bca9bb8d7 100644 --- a/apps/predbat/tests/test_web_annual.py +++ b/apps/predbat/tests/test_web_annual.py @@ -2525,7 +2525,7 @@ def test_web_annual_post_numeric_coercion(my_predbat): def test_web_annual_routes_registered(my_predbat): - """Verify all nine Annual routes are registered, so a typo'd path cannot ship green.""" + """Verify Annual routes and the modern UI entry point are registered.""" failed = False print("**** Testing web_annual route registration ****") @@ -2539,6 +2539,7 @@ def test_web_annual_routes_registered(my_predbat): expected = { ("GET", "/annual"), + ("GET", "/legacy_annual"), ("POST", "/annual"), ("POST", "/annual_run"), ("GET", "/annual_status"), @@ -2553,6 +2554,19 @@ def test_web_annual_routes_registered(my_predbat): print(" ERROR: missing route registrations: {}".format(missing)) failed = True + print("Test: modern mode serves the React shell while retaining the legacy page for the embedded tool") + modern_interface = WebInterface(my_predbat, web_port=5057) + modern_interface.get_web_ui = lambda: "modern" + modern_app = aiohttp_web.Application() + modern_interface._register_annual_routes(modern_app) + modern_routes = {(route.method, route.resource.canonical): route.handler for route in modern_app.router.routes()} + if modern_routes[("GET", "/annual")] != modern_interface.html_modern_ui: + print(" ERROR: /annual should serve the modern UI when web_ui is modern") + failed = True + if modern_routes[("GET", "/legacy_annual")] != modern_interface.annual_page.html_annual: + print(" ERROR: /legacy_annual should retain the existing What If implementation") + failed = True + return failed diff --git a/apps/predbat/tests/test_web_charts.py b/apps/predbat/tests/test_web_charts.py index 60f97cd6c..c00580d29 100644 --- a/apps/predbat/tests/test_web_charts.py +++ b/apps/predbat/tests/test_web_charts.py @@ -105,6 +105,408 @@ def run_web_charts_tests(my_predbat): now_str = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") series_data = [{"name": "SoC", "data": {"2026-07-23T10:00:00+00:00": 45.0}, "chart_type": "line"}] + # ------------------------------------------------------------------------- + print("Test: battery chart JSON and legacy rendering share the same series source") + original_dashboard_values = my_predbat.dashboard_values + original_history = my_predbat.soc_kwh_history + original_soc_kw = my_predbat.soc_kw + original_history_with_now_attrs = web.get_history_with_now_attrs + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + my_predbat.prefix + ".soc_kw_best": {"attributes": {"results": {forecast_stamp: 6.5}}}, + my_predbat.prefix + ".best_charge_limit_kw": {"attributes": {"results": {forecast_stamp: 8.0}}}, + my_predbat.prefix + ".best_export_limit_kw": {"attributes": {"results": {forecast_stamp: 2.0}}}, + } + my_predbat.soc_kwh_history = {} + my_predbat.soc_kw = 6.25 + web.get_history_with_now_attrs = lambda *args, **kwargs: [] + + battery_data = web.get_battery_chart_data() + legacy_html = web.get_chart("Battery") + + if not battery_data["ready"]: + print(" ERROR: populated optimised forecast should mark battery chart data ready") + failed += 1 + if battery_data["series"]["optimized"].get(forecast_stamp) != 6.5: + print(f" ERROR: optimised series missing from battery chart JSON: {battery_data}") + failed += 1 + if battery_data["series"]["actual"].get(now_str) != 6.25: + print(f" ERROR: current battery value missing from Actual series: {battery_data}") + failed += 1 + if "name: 'Best'" not in legacy_html or "y: 6.5" not in legacy_html: + print(" ERROR: legacy chart no longer renders the shared optimised series") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + my_predbat.soc_kwh_history = original_history + my_predbat.soc_kw = original_soc_kw + web.get_history_with_now_attrs = original_history_with_now_attrs + + # ------------------------------------------------------------------------- + print("Test: power chart JSON exposes the existing optimised power series") + original_dashboard_values = my_predbat.dashboard_values + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + my_predbat.prefix + ".battery_power_best": {"attributes": {"results": {forecast_stamp: -2.5}}}, + my_predbat.prefix + ".pv_power_best": {"attributes": {"results": {forecast_stamp: 3.2}}}, + # The prediction engine stores import as positive; the modern chart + # API normalises it to Predbat's live negative-import convention. + my_predbat.prefix + ".grid_power_best": {"attributes": {"results": {forecast_stamp: 0.4}}}, + my_predbat.prefix + ".load_power_best": {"attributes": {"results": {forecast_stamp: 1.1}}}, + my_predbat.prefix + ".iboost_best": {"attributes": {"results": {forecast_stamp: 0.25}}}, + } + + power_data = web.get_power_chart_data() + + if not power_data["ready"]: + print(" ERROR: populated forecast should mark power chart data ready") + failed += 1 + if power_data["series"]["battery"].get(forecast_stamp) != -2.5: + print(f" ERROR: battery power forecast missing from chart JSON: {power_data}") + failed += 1 + if power_data["series"]["grid"].get(forecast_stamp) != -0.4: + print(f" ERROR: grid import was not normalised to negative power: {power_data}") + failed += 1 + if power_data["series"]["iboost_energy"].get(forecast_stamp) != 0.25: + print(f" ERROR: iBoost energy forecast missing from chart JSON: {power_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + + # ------------------------------------------------------------------------- + print("Test: cost chart JSON exposes actual and optimised costs with currency") + original_dashboard_values = my_predbat.dashboard_values + original_currency_symbols = my_predbat.currency_symbols + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + my_predbat.prefix + ".cost_today": {"attributes": {"results": {forecast_stamp: 125.0}}}, + my_predbat.prefix + ".metric": {"attributes": {"results": {forecast_stamp: 240.0}}}, + my_predbat.prefix + ".best_metric": {"attributes": {"results": {forecast_stamp: 180.0}}}, + } + + for currency_symbols, expected_major, expected_minor in (("£p", "£", "p"), ("$c", "$", "c"), (["€", "c"], "€", "c")): + my_predbat.currency_symbols = currency_symbols + cost_data = web.get_cost_chart_data() + + if not cost_data["ready"]: + print(" ERROR: populated optimised forecast should mark cost chart data ready") + failed += 1 + if cost_data["series"]["actual"].get(forecast_stamp) != 125.0: + print(f" ERROR: actual cost missing from chart JSON: {cost_data}") + failed += 1 + if cost_data["series"]["optimized"].get(forecast_stamp) != 180.0: + print(f" ERROR: optimised cost missing from chart JSON: {cost_data}") + failed += 1 + if cost_data["currency_symbol"] != expected_major or cost_data["currency_unit"] != expected_minor: + print(f" ERROR: cost chart currency metadata is incorrect for {currency_symbols}: {cost_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + my_predbat.currency_symbols = original_currency_symbols + + # ------------------------------------------------------------------------- + print("Test: rates chart JSON exposes tariff series and configured currency") + original_dashboard_values = my_predbat.dashboard_values + original_currency_symbols = my_predbat.currency_symbols + original_history_wrapper = web.get_history_wrapper + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.currency_symbols = ["€", "c"] + my_predbat.dashboard_values = { + my_predbat.prefix + ".rates": {"attributes": {"results": {forecast_stamp: 21.5}}}, + my_predbat.prefix + ".rates_export": {"attributes": {"results": {forecast_stamp: 12.0}}}, + my_predbat.prefix + ".rates_gas": {"attributes": {"results": {forecast_stamp: 7.25}}}, + } + web.get_history_wrapper = lambda *args, **kwargs: [[{"state": 18.5, "last_updated": forecast_stamp}]] + + rates_data = web.get_rates_chart_data() + + if not rates_data["ready"]: + print(" ERROR: populated tariffs should mark rates chart data ready") + failed += 1 + if rates_data["series"]["import"].get(forecast_stamp) != 21.5: + print(f" ERROR: import tariff missing from rates chart JSON: {rates_data}") + failed += 1 + if rates_data["series"]["export"].get(forecast_stamp) != 12.0: + print(f" ERROR: export tariff missing from rates chart JSON: {rates_data}") + failed += 1 + if rates_data["currency_symbol"] != "€" or rates_data["currency_unit"] != "c": + print(f" ERROR: rates chart currency metadata is incorrect: {rates_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + my_predbat.currency_symbols = original_currency_symbols + web.get_history_wrapper = original_history_wrapper + + # ------------------------------------------------------------------------- + print("Test: in-day chart JSON exposes cumulative load forecasts and adjustment history") + original_dashboard_values = my_predbat.dashboard_values + original_history_wrapper = web.get_history_wrapper + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + my_predbat.prefix + ".load_energy_actual": {"attributes": {"results": {forecast_stamp: 4.2}}}, + my_predbat.prefix + ".load_energy_predicted": {"attributes": {"results": {forecast_stamp: 8.5}}}, + my_predbat.prefix + ".load_energy_adjusted": {"attributes": {"results": {forecast_stamp: 9.1}}}, + } + web.get_history_wrapper = lambda *args, **kwargs: [[{"state": 7.5, "last_updated": forecast_stamp}]] + + inday_data = web.get_inday_chart_data() + + if not inday_data["ready"]: + print(" ERROR: populated cumulative load data should mark in-day chart data ready") + failed += 1 + if inday_data["series"]["actual"].get(forecast_stamp) != 4.2: + print(f" ERROR: actual cumulative load missing from in-day chart JSON: {inday_data}") + failed += 1 + if inday_data["series"]["adjusted"].get(forecast_stamp) != 9.1: + print(f" ERROR: adjusted load forecast missing from in-day chart JSON: {inday_data}") + failed += 1 + if 7.5 not in inday_data["series"]["adjustment_factor"].values(): + print(f" ERROR: in-day adjustment history missing from chart JSON: {inday_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + web.get_history_wrapper = original_history_wrapper + + # ------------------------------------------------------------------------- + print("Test: solar chart JSON combines seven-day history with today and tomorrow forecasts") + original_dashboard_values = my_predbat.dashboard_values + original_history_wrapper = web.get_history_wrapper + try: + history_stamp = (my_predbat.now_utc - timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%S%z") + today_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + tomorrow_stamp = (my_predbat.now_utc + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + "sensor." + my_predbat.prefix + "_pv_today": {"attributes": {"detailedForecast": [{"period_start": today_stamp, "pv_estimate": 2.1, "pv_estimate10": 1.4, "pv_estimate90": 2.8, "pv_estimateCL": 2.3}]}}, + "sensor." + my_predbat.prefix + "_pv_tomorrow": {"attributes": {"detailedForecast": [{"period_start": tomorrow_stamp, "pv_estimate": 3.0, "pv_estimate10": 2.0, "pv_estimate90": 3.8, "pv_estimateCL": 3.2}]}}, + } + + def solar_history(entity, *args, **kwargs): + if entity == my_predbat.prefix + ".pv_power": + return [[{"state": 1.9, "last_updated": history_stamp}]] + if entity == my_predbat.prefix + ".pv_energy_h0": + return [[{"state": 4.6, "last_updated": history_stamp}]] + if entity == "sensor." + my_predbat.prefix + "_pv_today": + return [[{"state": 8.0, "last_updated": history_stamp, "attributes": {"totalCL": 8.0, "remainingCL": 3.1}}]] + return [[{"state": 1.8, "last_updated": history_stamp, "attributes": {"nowCL": 2.0}}]] + + web.get_history_wrapper = solar_history + solar_data = web.get_solar_chart_data() + + if not solar_data["ready"]: + print(" ERROR: populated measured and forecast solar data should mark the chart ready") + failed += 1 + if 1.9 not in solar_data["series"]["actual"].values(): + print(f" ERROR: measured solar history missing from chart JSON: {solar_data}") + failed += 1 + if 2.3 not in solar_data["series"]["forecast_calibrated"].values(): + print(f" ERROR: today's calibrated solar forecast missing from chart JSON: {solar_data}") + failed += 1 + if 3.2 not in solar_data["series"]["forecast_calibrated"].values(): + print(f" ERROR: tomorrow's calibrated solar forecast missing from chart JSON: {solar_data}") + failed += 1 + if 2.0 not in solar_data["series"]["forecast_history_calibrated"].values(): + print(f" ERROR: calibrated forecast history missing from chart JSON: {solar_data}") + failed += 1 + if 4.6 not in solar_data["series"]["energy_actual"].values() or 4.9 not in solar_data["series"]["energy_forecast"].values(): + print(f" ERROR: cumulative solar accuracy series missing from chart JSON: {solar_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + web.get_history_wrapper = original_history_wrapper + + # ------------------------------------------------------------------------- + print("Test: savings chart JSON exposes daily and cumulative values in major currency units") + original_currency_symbols = my_predbat.currency_symbols + original_history_wrapper = web.get_history_wrapper + total_entities = { + my_predbat.prefix + ".savings_total_predbat": 301, + my_predbat.prefix + ".savings_total_pvbat": 450, + } + original_total_states = {entity: my_predbat.ha_interface.dummy_items.get(entity) for entity in total_entities} + try: + history_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.currency_symbols = ["€", "c"] + web.get_history_wrapper = lambda entity, *args, **kwargs: [] if ".savings_total_" in entity else [[{"state": 125, "last_updated": history_stamp}]] + for entity, value in total_entities.items(): + my_predbat.ha_interface.dummy_items[entity] = {"state": value, "attributes": {}} + + savings_data = web.get_savings_chart_data() + + if not savings_data["ready"]: + print(" ERROR: populated savings history should mark the chart ready") + failed += 1 + if 1.25 not in savings_data["series"]["daily_predbat"].values(): + print(f" ERROR: savings chart did not convert minor units to major units: {savings_data}") + failed += 1 + if 3.01 not in savings_data["series"]["total_predbat"].values() or 4.5 not in savings_data["series"]["total_pv_battery"].values(): + print(f" ERROR: current cumulative savings are missing when recorder history is empty: {savings_data}") + failed += 1 + if savings_data["currency_symbol"] != "€": + print(f" ERROR: savings chart currency metadata is incorrect: {savings_data}") + failed += 1 + finally: + my_predbat.currency_symbols = original_currency_symbols + web.get_history_wrapper = original_history_wrapper + for entity, value in original_total_states.items(): + if value is None: + my_predbat.ha_interface.dummy_items.pop(entity, None) + else: + my_predbat.ha_interface.dummy_items[entity] = value + + # ------------------------------------------------------------------------- + print("Test: battery degradation chart JSON includes history and today's sensor values") + original_dashboard_values = my_predbat.dashboard_values + original_history_wrapper = web.get_history_wrapper + original_battery_scaling_auto = my_predbat.battery_scaling_auto + try: + sensor_id = "sensor." + my_predbat.prefix + "_soc_max_calculated" + history_stamp = (my_predbat.now_utc - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + sensor_id: { + "state": 9.2, + "attributes": {"nominal_capacity": 10.0, "degradation_percent": 8.0}, + } + } + my_predbat.battery_scaling_auto = True + web.get_history_wrapper = lambda *args, **kwargs: [ + [ + { + "state": 9.3, + "last_updated": history_stamp, + "attributes": {"nominal_capacity": 10.0, "degradation_percent": 7.0}, + } + ] + ] + + degradation_data = web.get_battery_degradation_chart_data() + inverter = degradation_data["inverters"][0] + today = my_predbat.now_utc.strftime("%Y-%m-%d") + + if not degradation_data["ready"] or inverter["calculated"].get(today) != 9.2: + print(f" ERROR: current calculated capacity missing from degradation chart JSON: {degradation_data}") + failed += 1 + if inverter["nominal"].get(today) != 10.0 or inverter["degradation"].get(today) != 8.0: + print(f" ERROR: current battery attributes missing from degradation chart JSON: {degradation_data}") + failed += 1 + if not degradation_data["automatic_scaling"]: + print(f" ERROR: battery scaling state missing from degradation chart JSON: {degradation_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + my_predbat.battery_scaling_auto = original_battery_scaling_auto + web.get_history_wrapper = original_history_wrapper + + # ------------------------------------------------------------------------- + print("Test: marginal costs chart JSON combines history and forecast") + original_dashboard_values = my_predbat.dashboard_values + original_history_with_now_attrs = web.get_history_with_now_attrs + try: + sensor_id = "sensor." + my_predbat.prefix + "_marginal_energy_costs" + time_labels = [(my_predbat.now_utc + timedelta(hours=hours)).strftime("%H:%M") for hours in range(0, 13, 2)] + my_predbat.dashboard_values = { + sensor_id: { + "attributes": { + "matrix": {kwh: {stamp: kwh * 10 + index for index, stamp in enumerate(time_labels)} for kwh in (1, 2, 4, 8)}, + "grid_import": {stamp: 20 + index for index, stamp in enumerate(time_labels)}, + "grid_export": {stamp: 5 + index for index, stamp in enumerate(time_labels)}, + "rate_now_low_consumption": 10, + "rate_now_med_consumption": 20, + "rate_now_high_consumption": 40, + "rate_now_ev_consumption": 80, + } + }, + "binary_sensor.{}_marginal_rate_now_low_is_cheap".format(my_predbat.prefix): {"state": "on"}, + } + web.get_history_with_now_attrs = lambda *args, **kwargs: [] + + marginal_data = web.get_marginal_costs_chart_data() + + if not marginal_data["ready"] or marginal_data["levels"][0]["current_cost"] != 10: + print(f" ERROR: marginal cost summary is incomplete: {marginal_data}") + failed += 1 + if len(marginal_data["levels"][0]["series"]) != 7 or len(marginal_data["grid_import"]) != 7: + print(f" ERROR: marginal forecast series is incomplete: {marginal_data}") + failed += 1 + if not marginal_data["levels"][0]["cheap"] or marginal_data["currency_unit"] != my_predbat.currency_symbols[1]: + print(f" ERROR: marginal cost metadata is incomplete: {marginal_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + web.get_history_with_now_attrs = original_history_with_now_attrs + + # ------------------------------------------------------------------------- + print("Test: carbon chart JSON exposes actual, base, optimised and intensity series") + original_dashboard_values = my_predbat.dashboard_values + original_carbon_intensity = my_predbat.carbon_intensity + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + my_predbat.prefix + ".carbon_today": {"attributes": {"results": {forecast_stamp: 8000}}}, + my_predbat.prefix + ".carbon": {"attributes": {"results": {forecast_stamp: 9200}}}, + my_predbat.prefix + ".carbon_best": {"attributes": {"results": {forecast_stamp: 8700}}}, + } + my_predbat.carbon_intensity = {0: 180, 30: 150} + + carbon_data = web.get_carbon_chart_data() + + if not carbon_data["ready"] or carbon_data["series"]["actual"].get(forecast_stamp) != 8000: + print(f" ERROR: actual household carbon is missing from chart JSON: {carbon_data}") + failed += 1 + if carbon_data["series"]["base"].get(forecast_stamp) != 9200 or carbon_data["series"]["optimized"].get(forecast_stamp) != 8700: + print(f" ERROR: carbon forecasts are missing from chart JSON: {carbon_data}") + failed += 1 + if sorted(carbon_data["series"]["intensity"].values()) != [150, 180]: + print(f" ERROR: grid carbon intensity is missing from chart JSON: {carbon_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + my_predbat.carbon_intensity = original_carbon_intensity + + # ------------------------------------------------------------------------- + print("Test: load ML chart JSON exposes energy, converted power and car-adjusted load") + original_dashboard_values = my_predbat.dashboard_values + original_history_wrapper = web.get_history_wrapper + original_history_with_now_attrs = web.get_history_with_now_attrs + original_car_configured = my_predbat.car_charging_power_configured + try: + first_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + second_stamp = (my_predbat.now_utc + timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + "sensor." + my_predbat.prefix + "_load_ml_forecast": {"attributes": {"results": {first_stamp: 1.0, second_stamp: 1.5}}}, + my_predbat.prefix + ".pv_power_best": {"attributes": {"results": {second_stamp: 2.5}}}, + "sensor." + my_predbat.prefix + "_temperature": {"attributes": {"results": {second_stamp: 12.0}}}, + } + stats = [[{"last_updated": first_stamp, "attributes": {"load_today": 3.0, "load_today_h1": 2.8, "load_today_h8": 2.6, "power_today": 0.8, "power_today_h1": 0.7, "power_today_h8": 0.6}}]] + web.get_history_with_now_attrs = lambda *args, **kwargs: stats + + def load_ml_history(entity, *args, **kwargs): + value = 1.2 if entity.endswith(".load_power") else 0.4 if entity.endswith(".car_charging_power") else 2.0 + return [[{"state": value, "last_updated": first_stamp}]] + + web.get_history_wrapper = load_ml_history + my_predbat.car_charging_power_configured = True + load_ml_data = web.get_load_ml_chart_data() + + if not load_ml_data["ready"] or 3.0 not in load_ml_data["series"]["energy_actual"].values(): + print(f" ERROR: learned load energy is missing from chart JSON: {load_ml_data}") + failed += 1 + if load_ml_data["series"]["power_forecast"].get(second_stamp) != 1.0: + print(f" ERROR: cumulative ML energy was not converted to interval power: {load_ml_data}") + failed += 1 + if 0.8 not in load_ml_data["series"]["power_actual_less_car"].values() or not load_ml_data["car_configured"]: + print(f" ERROR: configured car power was not removed from measured load: {load_ml_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + web.get_history_wrapper = original_history_wrapper + web.get_history_with_now_attrs = original_history_with_now_attrs + my_predbat.car_charging_power_configured = original_car_configured + # ------------------------------------------------------------------------- print("Test: render_chart() targets a percent-unit tagname via getElementById, not a CSS id selector") html = web.render_chart(series_data, "%", "SoC Chart", now_str, tagname="chart_%") diff --git a/apps/predbat/tests/test_web_chat.py b/apps/predbat/tests/test_web_chat.py index 3b9032513..efc1bea16 100644 --- a/apps/predbat/tests/test_web_chat.py +++ b/apps/predbat/tests/test_web_chat.py @@ -119,6 +119,7 @@ def get_component(self, name): paths = {str(route.resource.canonical) for route in app.router.routes()} expected_routes = [ "/chat", + "/legacy_chat", "/chat/conversations", "/chat/history", "/chat/send", @@ -165,6 +166,18 @@ def get_component(self, name): if expected not in paths: print("ERROR: route {} was not registered with a chat component present, got {}".format(expected, sorted(paths))) failed = True + + modern_interface = _make_web(my_predbat) + modern_interface.get_web_ui = lambda: "modern" + modern_app = aiohttp_web.Application() + modern_interface._register_chat_routes(modern_app) + modern_routes = {(route.method, route.resource.canonical): route.handler for route in modern_app.router.routes()} + if modern_routes[("GET", "/chat")] != modern_interface.html_modern_ui: + print("ERROR: /chat should serve the modern UI when web_ui is modern") + failed = True + if modern_routes[("GET", "/legacy_chat")] != modern_interface.chat_page.html_chat: + print("ERROR: /legacy_chat should retain the complete existing Chat page") + failed = True finally: my_predbat.components = original_components diff --git a/apps/predbat/tests/test_web_discovery.py b/apps/predbat/tests/test_web_discovery.py index 4333e5308..de2fc8a34 100644 --- a/apps/predbat/tests/test_web_discovery.py +++ b/apps/predbat/tests/test_web_discovery.py @@ -23,6 +23,7 @@ """ import asyncio +import json from coordinator import Coordinator from web import WebInterface @@ -343,7 +344,7 @@ def test_discovery_page_escapes_rendered_values(my_predbat): def test_discovery_route_is_registered(my_predbat): - """/discovery is wired into the router, so the nav link actually resolves.""" + """The Discovery page and its JSON source are wired into the router.""" print("**** test_discovery_route_is_registered ****") import inspect @@ -352,11 +353,37 @@ def test_discovery_route_is_registered(my_predbat): if '"/discovery"' not in source: print(" ERROR: expected /discovery to be registered on the router") failed = True + if '"/api/discovery"' not in source: + print(" ERROR: expected /api/discovery to be registered on the router") + failed = True if not failed: print("PASS: the /discovery route is registered") return failed +def test_discovery_api_is_redacted_by_default(my_predbat): + """The modern UI API only returns identifying values when raw mode is explicit.""" + print("**** test_discovery_api_is_redacted_by_default ****") + failed = False + saved = getattr(my_predbat, "components", None) + try: + w = _make_web(my_predbat, _real_coordinator(my_predbat)) + redacted = json.loads(asyncio.run(w.html_api_discovery(FakeRequest())).text) + raw = json.loads(asyncio.run(w.html_api_discovery(FakeRequest({"raw": "1"}))).text) + if SAMPLE_MPAN in json.dumps(redacted): + print(" ERROR: the default API response exposed an MPAN") + failed = True + if SAMPLE_MPAN not in json.dumps(raw): + print(" ERROR: raw mode did not return the MPAN") + failed = True + finally: + my_predbat.components = saved + + if not failed: + print("PASS: the Discovery API is redacted by default") + return failed + + def run_web_discovery_tests(my_predbat): """Run every discovery web page test.""" failed = False @@ -368,4 +395,5 @@ def run_web_discovery_tests(my_predbat): failed |= test_discovery_page_renders_a_field_it_was_never_taught(my_predbat) failed |= test_discovery_page_escapes_rendered_values(my_predbat) failed |= test_discovery_route_is_registered(my_predbat) + failed |= test_discovery_api_is_redacted_by_default(my_predbat) return failed diff --git a/apps/predbat/tests/test_web_functions.py b/apps/predbat/tests/test_web_functions.py index 45c0a8392..14a6e930f 100644 --- a/apps/predbat/tests/test_web_functions.py +++ b/apps/predbat/tests/test_web_functions.py @@ -36,6 +36,23 @@ def run_web_functions_tests(my_predbat): web = make_web(my_predbat) prefix = my_predbat.prefix + print("Test: legacy UI is the default and the bundled modern UI is selectable") + had_web_ui = "web_ui" in my_predbat.args + original_web_ui = my_predbat.args.get("web_ui") + my_predbat.args.pop("web_ui", None) + if web.get_web_ui() != "legacy": + print(" ERROR: missing web_ui should select the legacy interface") + failed += 1 + my_predbat.args["web_ui"] = "modern" + modern_response = asyncio.run(web.html_plan(None)) + if modern_response.status != 200 or '
' not in modern_response.text: + print(" ERROR: web_ui modern did not serve the bundled React interface") + failed += 1 + if had_web_ui: + my_predbat.args["web_ui"] = original_web_ui + else: + my_predbat.args.pop("web_ui", None) + charging_entity = "binary_sensor." + prefix + "_charging" exporting_entity = "binary_sensor." + prefix + "_exporting" soc_entity = prefix + ".soc_kw" @@ -326,6 +343,10 @@ def run_compare_empty_state_tests(my_predbat, web): if "7 day rolling average chart loading (please wait)" in text: print(f" ERROR: should not show the stuck '7 day rolling average' message when nothing is configured") failed += 1 + data = web.get_compare_data() + if data["configured"] or data["ready"] or data["tariffs"]: + print(f" ERROR: modern Compare API should return a clean unconfigured state") + failed += 1 # ------------------------------------------------------------------------- print("Test: a configured but not-yet-computed compare_list keeps the genuine loading message") @@ -338,6 +359,10 @@ def run_compare_empty_state_tests(my_predbat, web): if "Loading chart (please wait)" not in text: print(f" ERROR: expected the genuine loading message when compare_list is set but not yet computed") failed += 1 + data = web.get_compare_data() + if not data["configured"] or data["ready"] or data["tariffs"][0]["name"] != "Test tariff": + print(f" ERROR: modern Compare API should expose configured tariffs before results exist") + failed += 1 my_predbat.args = original_args diff --git a/apps/predbat/tests/test_web_if.py b/apps/predbat/tests/test_web_if.py index 4c3abc360..300be86d4 100644 --- a/apps/predbat/tests/test_web_if.py +++ b/apps/predbat/tests/test_web_if.py @@ -82,6 +82,7 @@ def run_test_web_if(my_predbat): ("GET", "/entity"), ("POST", "/entity"), ("POST", "/config"), + ("POST", "/api/config"), ("GET", "/dash"), ("POST", "/dash"), ("GET", "/components"), @@ -105,15 +106,22 @@ def run_test_web_if(my_predbat): ("POST", "/inverter_refresh"), ("GET", "/api/state"), ("GET", "/api/ping"), + ("GET", "/api/apps_schema"), + ("GET", "/api/apps_yaml"), + ("POST", "/api/apps_yaml"), ("POST", "/api/state"), ("POST", "/api/service"), ("GET", "/api/log"), ("GET", "/api/entities"), + ("GET", "/api/config"), + ("GET", "/api/components"), + ("GET", "/api/browse"), ("POST", "/api/login"), ("GET", "/browse"), ("GET", "/download"), ("GET", "/internals"), ("GET", "/api/internals"), + ("GET", "/api/internals/threads"), ("GET", "/api/internals/download"), ("GET", "/api/status"), ("GET", "/metrics"), @@ -199,6 +207,72 @@ def _error_total(page): # Test POST endpoints print("\n**** Testing POST endpoints ****") + components_response = requests.get(base_url + "/api/components") + components_data = components_response.json() + component_ids = {component.get("id") for component in components_data.get("components", [])} + if components_response.status_code != 200 or not {"db", "web"}.issubset(component_ids): + print("ERROR: /api/components did not return registered component status") + failed = 1 + + config_response = requests.get(base_url + "/api/config") + config_items = config_response.json().get("items", []) + if config_response.status_code != 200 or not config_items or not all(item.get("entity") and item.get("description") for item in config_items): + print("ERROR: /api/config did not return enabled configuration entities") + failed = 1 + + browse_response = requests.get(base_url + "/api/browse") + browse_files = {item.get("name") for item in browse_response.json().get("files", [])} + if browse_response.status_code != 200 or "apps.yaml" not in browse_files: + print("ERROR: /api/browse did not return files from the Predbat working directory") + failed = 1 + if requests.get(base_url + "/api/browse", params={"path": "../"}).status_code != 403: + print("ERROR: /api/browse allowed directory traversal") + failed = 1 + + my_predbat.set_state_wrapper("update.predbat_version", "on", attributes={"in_progress": True, "latest_version": "v99.0.0"}) + update_status = requests.get(base_url + "/api/status").json() + if update_status.get("updating") is not True: + print("ERROR: /api/status did not report an in-progress Predbat update") + failed = 1 + if update_status.get("update_available") is not True or update_status.get("latest_version") != "v99.0.0": + print("ERROR: /api/status did not report the available Predbat update") + failed = 1 + my_predbat.set_state_wrapper("update.predbat_version", "on", attributes={"in_progress": False}) + + print("Test apps.yaml editor API and generated schema") + schema_response = requests.get(base_url + "/api/apps_schema") + schema = schema_response.json() + app_schema = schema.get("$defs", {}).get("predbatApp", {}) + properties = app_schema.get("properties", {}) + if schema_response.status_code != 200 or schema.get("required") != ["pred_bat"] or not {"module", "class", "load_today", "active", "grid_power"}.issubset(properties): + print("ERROR: /api/apps_schema did not combine structural, APPS_SCHEMA and CONFIG_ITEMS keys") + failed = 1 + if properties.get("prefix", {}).get("default") != "predbat" or properties.get("timezone", {}).get("default") != "Europe/London": + print("ERROR: /api/apps_schema did not include structural defaults") + failed = 1 + if properties.get("active", {}).get("default") is not False or not properties.get("load_today", {}).get("description"): + print("ERROR: /api/apps_schema did not enrich settings with defaults and descriptions") + failed = 1 + load_today_options = properties.get("load_today", {}).get("oneOf", []) + grid_power_options = properties.get("grid_power", {}).get("oneOf", []) + if not all(any(option.get("x-ha-entity") or option.get("items", {}).get("x-ha-entity") for option in options) for options in (load_today_options, grid_power_options)): + print("ERROR: /api/apps_schema did not identify Home Assistant entity fields") + failed = 1 + + yaml_response = requests.get(base_url + "/api/apps_yaml") + yaml_data = yaml_response.json() + save_response = requests.post(base_url + "/api/apps_yaml", json=yaml_data) + if yaml_response.status_code == 200 and save_response.status_code == 200 and save_response.json().get("saved"): + accessed_endpoints.add(("POST", "/api/apps_yaml")) + else: + print("ERROR: /api/apps_yaml could not round-trip apps.yaml") + failed = 1 + + stale_response = requests.post(base_url + "/api/apps_yaml", json={"content": yaml_data.get("content", ""), "checksum": "stale"}) + if stale_response.status_code != 409: + print("ERROR: /api/apps_yaml did not reject a stale editor save") + failed = 1 + # Test /compare POST print("Test POST /compare") address = base_url + "/compare" @@ -247,6 +321,13 @@ def _error_total(page): print("ERROR: Unexpected response from /config: {} - {}".format(res.status_code, res.text)) failed = 1 + res = requests.post(base_url + "/api/config", data=data) + if res.status_code in [200]: + accessed_endpoints.add(("POST", "/api/config")) + else: + print("ERROR: Unexpected response from /api/config: {} - {}".format(res.status_code, res.text)) + failed = 1 + # Test /dash POST print("Test POST /dash") address = base_url + "/dash" diff --git a/apps/predbat/tests/test_web_power_flow.py b/apps/predbat/tests/test_web_power_flow.py index 557aa3296..b86cb1edb 100644 --- a/apps/predbat/tests/test_web_power_flow.py +++ b/apps/predbat/tests/test_web_power_flow.py @@ -8,11 +8,14 @@ # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init +import asyncio +import json import math import re +from types import SimpleNamespace from config import APPS_SCHEMA -from web import WebInterface +from web import WebInterface, build_apps_json_schema # Where each node is drawn in the diagram's SVG, keyed by the colour of the arm that reaches it HOUSE_CENTRE = (300, 200) @@ -331,6 +334,151 @@ def run_power_flow_geometry_tests(my_predbat, web): return failed +def run_overview_api_tests(my_predbat, web_interface): + """Check the live API adds complete optional Overview data without changing flow values.""" + failed = 0 + print("**** Running Overview API tests ****") + original_args = my_predbat.args.copy() + original_predheat = my_predbat.predheat + original_values = {name: getattr(my_predbat, name) for name in ("load_minutes_now", "pv_today_now", "import_today_now", "export_today_now", "cost_today_sofar")} + added_entities = [ + "weather.forecast_home", + "weather.overview_test", + "weather.predheat_test", + "sensor.predbat_pv_today", + "sensor.configured_pv_forecast_today", + "sensor.car_status", + "sensor.car_soc", + "sensor.car_energy", + "sensor.ashp_power", + "sensor.ashp_status", + "sensor.ashp_energy", + ] + original_entities = {name: my_predbat.ha_interface.dummy_items.get(name) for name in added_entities} + + try: + my_predbat.args.pop("predheat", None) + my_predbat.args.update( + { + "weather": "weather.overview_test", + "pv_forecast_today": "sensor.configured_pv_forecast_today", + "car_charging_planned": ["sensor.car_status"], + "car_charging_soc": ["sensor.car_soc"], + "car_charging_energy": "sensor.car_energy", + "ashp_enable": True, + "ashp_power": "sensor.ashp_power", + "ashp_status": "sensor.ashp_status", + "heat_energy": "sensor.ashp_energy", + } + ) + my_predbat.ha_interface.dummy_items.update( + { + "weather.forecast_home": {"state": "cloudy", "temperature": 12.0, "temperature_unit": "C"}, + "weather.overview_test": {"state": "sunny", "temperature": 17.5, "temperature_unit": "C"}, + "sensor.predbat_pv_today": {"state": 12.12, "unit_of_measurement": "kWh"}, + "sensor.configured_pv_forecast_today": {"state": 7.95, "unit_of_measurement": "kWh"}, + "sensor.car_status": {"state": "EV Connected"}, + "sensor.car_soc": {"state": 68, "unit_of_measurement": "%"}, + "sensor.car_energy": {"state": 4.2, "unit_of_measurement": "kWh"}, + "sensor.ashp_power": {"state": 1.25, "unit_of_measurement": "kW"}, + "sensor.ashp_status": {"state": "heating"}, + "sensor.ashp_energy": {"state": 5.6, "unit_of_measurement": "kWh"}, + } + ) + my_predbat.load_minutes_now = 10.1 + my_predbat.pv_today_now = 6.2 + my_predbat.import_today_now = 3.3 + my_predbat.export_today_now = 1.4 + my_predbat.cost_today_sofar = 87.5 + + response = asyncio.run(web_interface.html_api_power_flow(None)) + data = json.loads(response.text) + if data.get("weather") != {"state": "sunny", "temperature": 17.5, "temperature_unit": "C"}: + print(" ERROR: weather data was not returned from the standalone Overview weather setting: " f"{data.get('weather')!r}, configured={web_interface.get_arg('weather', default=None, indirect=False)!r}") + failed += 1 + if data.get("pv_forecast_today") != 7.95: + print(" ERROR: today's configured PV forecast was not returned") + failed += 1 + if data.get("car", {}).get("status") != "EV Connected" or data.get("car", {}).get("soc") != 68 or data.get("car", {}).get("energy_today") != 4.2: + print(" ERROR: EV status, state of charge or today's energy was not returned") + failed += 1 + if data.get("ashp") != {"power": 1250.0, "status": "heating", "energy_today": 5.6}: + print(" ERROR: the complete ASHP configuration was not returned with converted power") + failed += 1 + if data.get("totals") != {"load_today": 10.1, "pv_today": 6.2, "import_today": 3.3, "export_today": 1.4, "cost_today": 87.5}: + print(" ERROR: daily Overview totals did not match Predbat's current totals") + failed += 1 + + my_predbat.args.pop("ashp_status") + partial_data = json.loads(asyncio.run(web_interface.html_api_power_flow(None)).text) + if partial_data.get("ashp") != {"power": 1250.0, "status": None, "energy_today": 5.6}: + print(" ERROR: enabled ASHP should retain available values when an optional entity is missing") + failed += 1 + + my_predbat.args.pop("ashp_enable") + my_predbat.args.pop("weather") + my_predbat.args.pop("heat_energy") + my_predbat.args["predheat"] = { + "mode": "pump", + "weather": "weather.predheat_test", + "heating_energy": "sensor.ashp_energy", + } + my_predbat.predheat = SimpleNamespace(heat_energy_today=3.25) + my_predbat.ha_interface.dummy_items["weather.predheat_test"] = {"state": "rainy", "temperature": 9.5, "temperature_unit": "C"} + + predheat_data = json.loads(asyncio.run(web_interface.html_api_power_flow(None)).text) + if predheat_data.get("weather") != {"state": "rainy", "temperature": 9.5, "temperature_unit": "C"}: + print(" ERROR: Overview did not reuse the PredHeat weather entity") + failed += 1 + if predheat_data.get("ashp") != {"power": 1250.0, "status": None, "energy_today": 3.25}: + print(" ERROR: pump-mode PredHeat should enable ASHP and provide its calculated daily energy") + failed += 1 + + my_predbat.args["ashp_enable"] = False + disabled_data = json.loads(asyncio.run(web_interface.html_api_power_flow(None)).text) + if disabled_data.get("ashp") is not None: + print(" ERROR: ASHP should be hidden when it is disabled") + failed += 1 + finally: + my_predbat.args = original_args + my_predbat.predheat = original_predheat + for name, value in original_values.items(): + setattr(my_predbat, name, value) + for entity_id, value in original_entities.items(): + if value is None: + my_predbat.ha_interface.dummy_items.pop(entity_id, None) + else: + my_predbat.ha_interface.dummy_items[entity_id] = value + + for name in ("car_charging_energy", "car_charging_status", "car_charging_soc", "weather", "ashp_enable", "ashp_power", "ashp_status", "heat_energy", "ashp_energy_today"): + if name not in APPS_SCHEMA: + print(f" ERROR: {name} is missing from APPS_SCHEMA") + failed += 1 + + schema_properties = build_apps_json_schema()["$defs"]["predbatApp"]["properties"] + generic_description_starts = ("Predbat setting for ", "Enable or disable ", "Home Assistant entity or entities used for ") + for name, property_schema in schema_properties.items(): + description = property_schema.get("description", "") + if len(description) < 24 or description.startswith(generic_description_starts): + print(f" ERROR: {name} has an unhelpful apps.yaml schema description: {description!r}") + failed += 1 + + for name in ("car_charging_energy", "car_charging_status", "car_charging_soc", "weather", "ashp_power", "ashp_status", "heat_energy", "ashp_energy_today"): + encoded = json.dumps(schema_properties[name]) + if '"x-ha-entity": true' not in encoded: + print(f" ERROR: {name} is not marked for Home Assistant entity autocomplete") + failed += 1 + + predheat_properties = schema_properties["predheat"].get("properties", {}) + for name in ("weather", "heating_energy"): + if not predheat_properties.get(name, {}).get("x-ha-entity"): + print(f" ERROR: predheat.{name} is not marked for Home Assistant entity autocomplete") + failed += 1 + + print("**** Overview API tests completed ****") + return failed + + def run_web_power_flow_tests(my_predbat): """Car charging power input, published sensor and its arm of the power flow diagram.""" failed = 0 @@ -617,6 +765,7 @@ def run_web_power_flow_tests(my_predbat): failed += run_power_flow_colour_tests(my_predbat, web) failed += run_battery_icon_tests(my_predbat, web) failed += run_power_flow_geometry_tests(my_predbat, web) + failed += run_overview_api_tests(my_predbat, web) my_predbat.args = original_args my_predbat.load_power = original_load_power diff --git a/apps/predbat/utils.py b/apps/predbat/utils.py index 35033d4f8..190e285f7 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -891,6 +891,16 @@ def prune_today(data, now_utc, midnight_utc, prune=True, group=15, prune_future= return results +def normalise_entity_prefix(value): + """Return a Home Assistant-safe prefix for Predbat-generated entity IDs.""" + raw = str(value or "predbat").strip().lower() + prefix = re.sub(r"[^a-z0-9_]+", "_", raw) + prefix = re.sub(r"_+", "_", prefix).strip("_") + if not prefix or not prefix[0].isalpha(): + prefix = "predbat_{}".format(prefix).rstrip("_") + return prefix + + def is_entity_id(value): """ Whether a resolved apps.yaml value names a Home Assistant entity rather than being a literal. diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 613c33289..dda1fece9 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -36,6 +36,9 @@ from io import StringIO import hashlib import copy +import mimetypes +import zipfile +from pathlib import PurePosixPath from ruamel.yaml import YAML from ruamel.yaml.scalarstring import DoubleQuotedScalarString @@ -95,7 +98,7 @@ from const import TIME_FORMAT, TIME_FORMAT_DAILY, TIME_FORMAT_HA, MANUAL_RATE_MAX_MINUTES, MANUAL_TIME_MAX_MINUTES from predbat import THIS_VERSION_DISPLAY from component_base import ComponentBase -from config import APPS_SCHEMA +from config import APPS_SCHEMA, CONFIG_ITEMS import debug_history from web_annual import AnnualPage from web_chat import WebChat @@ -109,6 +112,458 @@ DEBUG_HISTORY_DOWNLOAD_MAX = 16 +def _optional_float(value): + """Return a finite float for an optional Home Assistant value, otherwise None.""" + try: + result = float(value) + except (TypeError, ValueError): + return None + return result if result == result and abs(result) != float("inf") else None + + +def _apps_schema_value(spec): + """Convert one Predbat validation spec to JSON Schema.""" + scalar_types = { + "none": {"type": "null"}, + "integer": {"type": "integer"}, + "float": {"type": "number"}, + "string": {"type": "string"}, + "boolean": {"type": "boolean"}, + "dict": {"type": "object"}, + "sensor": {"type": "string", "x-ha-entity": True}, + "integer_list": {"type": "array", "items": {"type": "integer"}}, + "float_list": {"type": "array", "items": {"type": "number"}}, + "string_list": {"type": "array", "items": {"type": "string"}}, + "boolean_list": {"type": "array", "items": {"type": "boolean"}}, + "dict_list": {"type": "array", "items": {"type": "object"}}, + "sensor_list": {"type": "array", "items": {"type": ["string", "number", "boolean"], "x-ha-entity": True}}, + "int_float_dict": {"type": "object", "additionalProperties": {"type": "number"}}, + } + choices = [copy.deepcopy(scalar_types[item]) for item in spec.get("type", "string").split("|") if item in scalar_types] + if spec.get("or_auto"): + choices.append({"const": "auto"}) + schema = choices[0] if len(choices) == 1 else {"oneOf": choices} + if spec.get("allowed"): + schema = {"enum": spec["allowed"]} + return schema + + +APPS_SCHEMA_DESCRIPTIONS = { + "currency_symbols": "Currency symbols used throughout Predbat. Supply the major-unit symbol first and the minor-unit symbol second, for example ['£', 'p'] or ['$', 'c'].", + "db_enable": "Enables Predbat's local history database for longer-term statistics and charts.", + "db_days": "Number of days of Predbat history retained in the local database before older records are removed.", + "db_mirror_ha": "Copies relevant Home Assistant history into Predbat's local database so charts can use one consistent data source.", + "db_primary": "Uses Predbat's local database as the primary history source instead of Home Assistant where matching data is available.", + "threads": "Number of worker threads used to optimise the plan. Use 'auto' to match the host, 0 to disable parallel workers, or a fixed number to limit CPU use.", + "prediction_kernel_enable": "Uses Predbat's compiled prediction kernel for faster plan simulation. Disable only when diagnosing compatibility or calculation issues.", + "log_count": "Number of log files to retain, including the current predbat.log. Older rotated logs are deleted after this limit.", + "ha_url": "Base URL of the Home Assistant instance used when Predbat runs outside the add-on environment.", + "ha_key": "Home Assistant long-lived access token used to read entities and call services when Predbat connects remotely.", + "load_filter_threshold": "Maximum number of consecutive minutes with zero household load that Predbat fills from surrounding history. Set 1440 to disable gap filling.", + "web_port": "TCP port used by Predbat's built-in web server and modern dashboard.", + "web_ui": "Selects the dashboard served by Predbat: 'legacy' for the original interface or 'modern' for the new responsive interface.", + "chat": "Chat assistant configuration, including named providers, API endpoints, credentials, model selection and request timeout.", + "load_today": "One or more cumulative Home Assistant energy sensors reporting household consumption today in kWh. Multiple sensors are added together.", + "import_today": "One or more cumulative Home Assistant energy sensors reporting grid import today in kWh. Multiple sensors are added together.", + "export_today": "One or more cumulative Home Assistant energy sensors reporting grid export today in kWh. Multiple sensors are added together.", + "pv_today": "One or more cumulative Home Assistant energy sensors reporting solar generation today in kWh. Include every separate PV source that should be totalled.", + "load_forecast_only": "Uses the configured load_forecast data as the complete demand forecast instead of adding it to Predbat's historical household-load model.", + "load_forecast": "External forecast entities added to predicted household demand, such as a heat-pump forecast. Each source must expose timestamped incremental energy data.", + "ge_cloud_data": "Uses GivEnergy Cloud daily energy data instead of the configured load_today, import_today and export_today entities.", + "ge_cloud_serial": "GivEnergy inverter serial number used when requesting data from GivEnergy Cloud.", + "ge_cloud_key": "API credential used to authenticate requests to GivEnergy Cloud.", + "ge_cloud_direct": "Uses Predbat's direct GivEnergy Cloud integration for live inverter data and control.", + "ge_cloud_automatic": "Allows the GivEnergy Cloud integration to discover the inverter and populate its matching Predbat settings automatically.", + "ge_cloud_load_today_ignore": "Ignores GivEnergy Cloud's load-today total when automatic cloud configuration is active.", + "ge_cloud_automatic_evc": "Allows GivEnergy Cloud discovery to configure a connected GivEnergy EV charger automatically.", + "ge_cloud_evc_control": "Allows Predbat to send supported charging commands to the discovered GivEnergy EV charger.", + "ge_cloud_automatic_shared_ct": "Treats discovered GivEnergy inverters as sharing one grid current transformer when configuring power and energy sources.", + "ge_cloud_automatic_split_ct": "Treats discovered GivEnergy inverters as having separate grid current transformers and combines their readings.", + "ge_cloud_automatic_split_pv": "Combines separate PV readings discovered across multiple GivEnergy inverters.", + "num_inverters": "Number of battery inverters Predbat should model and control. Inverter-specific lists normally require one entry per inverter.", + "validate_config_retries": "Number of times Predbat retries unavailable Home Assistant entities while validating apps.yaml during startup.", + "validate_config_retry_minutes": "Minutes between startup configuration-validation retries when required entities are unavailable.", + "givtcp_rest": "GivTCP REST base URL for each inverter. Direct REST control is faster and more reliable than writing Home Assistant entities.", + "givtcp_automatic": "Allows Predbat to discover GivTCP REST entities and populate inverter settings automatically. Disable to keep all mappings under manual control.", + "charge_rate": "Writable Home Assistant number entity for each inverter's battery charge-rate limit.", + "discharge_rate": "Writable Home Assistant number entity for each inverter's battery discharge-rate limit.", + "battery_power": "Live battery power sensor for each inverter. Predbat uses its sign and magnitude to determine charging or discharging power.", + "pv_power": "Live solar generation power sensor for each inverter or PV source, normally reported in watts.", + "load_power": "Live household demand power sensor for each inverter or meter, normally reported in watts.", + "soc_kw": "Home Assistant sensor reporting usable energy currently stored in each battery, normally in kWh.", + "soc_max": "Home Assistant sensor reporting the usable full capacity of each battery in kWh.", + "reserve": "Writable reserve or minimum state-of-charge entity for each inverter.", + "inverter_mode": "Writable operating-mode selector for each inverter, used to switch between normal, charge and export behaviour.", + "inverter_time": "Entity reporting each inverter's internal clock, used to detect clock drift before programming timed slots.", + "inverter_type": "Predbat inverter-definition name for each inverter when it cannot be detected automatically.", + "charge_start_time": "Writable entity containing the programmed battery charge-window start time for each inverter.", + "charge_end_time": "Writable entity containing the programmed battery charge-window end time for each inverter.", + "charge_limit": "Writable target state-of-charge entity for each inverter's scheduled charge window.", + "scheduled_charge_enable": "Writable switch that enables or disables scheduled battery charging on each inverter.", + "scheduled_discharge_enable": "Writable switch that enables or disables scheduled battery discharge on each inverter.", + "discharge_start_time": "Writable entity containing the programmed forced-discharge start time for each inverter.", + "discharge_end_time": "Writable entity containing the programmed forced-discharge end time for each inverter.", + "battery_temperature": "Battery temperature sensor for each inverter, used when applying temperature-dependent charge limits.", + "battery_calibration": "Optional calibration-status entity for each inverter. Predbat pauses control while a battery calibration cycle is active.", + "pause_mode": "Optional writable inverter mode used to pause battery charge, discharge or both.", + "pause_start_time": "Optional writable start time for an inverter pause window.", + "pause_end_time": "Optional writable end time for an inverter pause window.", + "inverter_limit": "Maximum combined AC power of each inverter in watts, used to cap modelled import, generation and discharge.", + "inverter_can_charge_during_export": "Allows an inverter to charge one battery while another inverter is deliberately exporting.", + "inverter_freeze_export_discharge_rate": "Discharge rate used when implementing a freeze-export action on inverters that require a small forced-discharge setting.", + "pv_ac_limit": "Maximum AC output from connected PV in watts. Predbat uses it to cap modelled solar generation after inverter conversion.", + "inverter_limit_charge": "Maximum AC battery charging power for each inverter in watts.", + "inverter_limit_charge_dc": "Maximum DC battery charging power for each inverter in watts.", + "inverter_limit_discharge": "Maximum battery discharge power for each inverter in watts.", + "inverter_limit_export": "Maximum inverter export power for each inverter in watts.", + "battery_rate_max": "Maximum normal battery charge and discharge rate for each inverter, used by the optimiser when modelling slots.", + "export_limit": "Site or inverter export limit in watts. Predbat prevents the plan from modelling export above this value.", + "inverter_battery_rate_min": "Minimum practical battery charge or discharge rate in watts; lower planned rates are treated as ineffective.", + "inverter_reserve_max": "Highest reserve percentage Predbat may program while protecting a planned battery minimum.", + "battery_charge_power_curve": "Optional state-of-charge to power multiplier curve used to model battery charging slowdown. Use 'auto' to learn it from history.", + "battery_discharge_power_curve": "Optional state-of-charge to power multiplier curve used to model battery discharge limits. Use 'auto' to learn it from history.", + "battery_charge_power_curve_default": "Fallback state-of-charge to charge-power curve used when an automatic curve has insufficient data.", + "battery_discharge_power_curve_default": "Fallback state-of-charge to discharge-power curve used when an automatic curve has insufficient data.", + "clock_skew": "Manual inverter clock correction in minutes, applied when interpreting or programming inverter time windows.", + "predbat_repository": "Git repository used by Predbat's updater. Change this only to test a fork or development branch.", + "solcast_api_key": "Solcast API key used to download site-level solar forecasts directly.", + "solcast_host": "Solcast API base URL. Leave at the documented default unless using a compatible proxy or alternative endpoint.", + "solcast_poll_hours": "Minimum hours between direct Solcast forecast downloads, used to stay within API request limits.", + "solcast_sites": "Solcast rooftop site resource IDs to download and combine into the PV forecast.", + "pv_forecast_today": "Home Assistant energy entity containing today's total forecast solar generation in kWh, used by Overview and forecast comparison views.", + "pv_array_kwp": "Installed DC solar-array capacity in kWp, used to stop uncertainty modelling from exceeding the physical panel capacity.", + "pv_forecast_tomorrow": "Home Assistant energy entity containing tomorrow's total forecast solar generation in kWh.", + "pv_forecast_d3": "Home Assistant energy entity containing the solar forecast total for the third forecast day in kWh.", + "pv_forecast_d4": "Home Assistant energy entity containing the solar forecast total for the fourth forecast day in kWh.", + "car_charging_power": "Live EV-charger power entity or list of entities, used to show charging power and separate EV demand from household load.", + "num_cars": "Number of EVs or chargers Predbat should model. Car-specific lists normally require one entry per car.", + "car_charging_planned": "Entity for each car indicating whether a charging session is planned by its external charger or tariff integration.", + "car_charging_planned_response": "State values from car_charging_planned that Predbat should interpret as a planned charging session.", + "car_charging_now": "Entity for each car indicating whether it is currently charging.", + "car_charging_now_response": "State values from car_charging_now that Predbat should interpret as actively charging.", + "car_charging_battery_size": "Usable battery capacity for each EV in kWh, supplied as a value or Home Assistant sensor.", + "car_charging_limit": "Desired EV charge limit for each car, normally a percentage reported by the vehicle or charger.", + "car_charging_exclusive": "For each car, prevents its planned charging slots from overlapping other modelled EV charging.", + "carbon_intensity": "Home Assistant entity reporting current or forecast grid carbon intensity, normally in gCO2/kWh.", + "carbon_postcode": "UK postcode region used to request local carbon-intensity forecasts when no entity is configured.", + "carbon_automatic": "Automatically obtains carbon-intensity data for carbon-aware plan calculations.", + "axle_pence_per_kwh": "Reward rate in pence per kWh used to value Axle Energy flexibility events in the plan.", + "octopus_intelligent_slot": "Entity for each car containing Intelligent Octopus dispatch slots or whether a smart-charge slot is active.", + "octopus_ready_time": "Entity for each car containing the time by which Intelligent Octopus should complete charging.", + "octopus_charge_limit": "Entity for each car containing the energy or state-of-charge target supplied by Intelligent Octopus.", + "octopus_slot_low_rate": "Treats all Intelligent Octopus dispatch slots as low-rate import periods in the battery plan.", + "octopus_slot_max": "Maximum number of Intelligent Octopus dispatch slots Predbat imports into one plan.", + "octopus_saving_session_octopoints_per_penny": "Conversion rate between Octopoints and pence used when valuing Saving Session rewards.", + "octopus_saving_session_min_octopoints_per_kwh": "Minimum offered Octopoints per kWh required before Predbat joins a Saving Session automatically.", + "octopus_saving_session_rate": "Fallback Saving Session reward rate used when the event does not provide one.", + "octopus_free_url": "Octopus API URL used to retrieve Free Electricity session events.", + "octopus_night_times": "Time ranges that should be treated as Octopus overnight periods for tariff and charging logic.", + "metric_octopus_import": "Home Assistant entity containing Octopus import cost or consumption data used for metric comparisons.", + "metric_octopus_export": "Home Assistant entity containing Octopus export income or energy data used for metric comparisons.", + "octopus_api_key": "Octopus Energy API key used to retrieve account tariffs, rates and supported smart-tariff data.", + "octopus_api_account": "Octopus Energy account number associated with the configured API key.", + "rates_import": "Manual import-tariff periods. Each item defines a rate and its start and end time.", + "rates_export": "Manual export-tariff periods. Each item defines a rate and its start and end time.", + "alerts": "Alert-feed configuration used to adjust planning for forecast grid events or other supported warnings.", + "rates_import_octopus_url": "Octopus product API URL used to retrieve future electricity import rates.", + "rates_export_octopus_url": "Octopus product API URL used to retrieve future electricity export rates.", + "rates_import_override": "Import-rate overrides applied to selected times after the normal tariff has been loaded.", + "rates_export_override": "Export-rate overrides applied to selected times after the normal tariff has been loaded.", + "days_previous": "Historical day offsets used to build the household-load forecast, for example [1, 2, 7] for yesterday, two days ago and last week.", + "days_previous_weight": "Relative weight for each entry in days_previous when averaging historical household demand.", + "days_previous_auto": "Lets Predbat choose suitable historical days automatically instead of using only the configured day offsets.", + "forecast_hours": "Number of future hours of tariff, load and solar data Predbat fetches for planning.", + "notify_devices": "Home Assistant mobile-app notification services or device identifiers that should receive Predbat notifications.", + "battery_scaling": "Per-inverter capacity scaling values used to reconcile reported battery energy with usable capacity.", + "battery_scaling_auto": "Automatically learns battery capacity scaling from charge and discharge history.", + "import_export_scaling": "Scale factor applied when reconciling measured import and export energy with Predbat's model.", + "export_triggers": "Optional price or time rules that trigger export behaviour outside the normal optimised windows.", + "iboost_energy_today": "Home Assistant energy entity reporting today's hot-water diverter or iBoost consumption in kWh.", + "metric_octopus_gas": "Home Assistant entity containing Octopus gas cost or consumption data used for metric comparisons.", + "rates_gas": "Manual gas-tariff periods used by hot-water and gas-versus-electricity cost calculations.", + "futurerate_url": "Nord Pool or compatible future-rate API URL used before the supplier publishes final tariff prices.", + "futurerate_adjust_import": "Adjusts predicted future import prices to match the shape and level of recently published real prices.", + "futurerate_adjust_export": "Adjusts predicted future export prices to match the shape and level of recently published real prices.", + "futurerate_adjust_auto": "Automatically enables future-rate adjustment when enough published tariff data is available.", + "futurerate_peak_start": "Start time of the daily peak window used when shaping predicted future rates.", + "futurerate_peak_end": "End time of the daily peak window used when shaping predicted future rates.", + "octopus_region": "Octopus tariff region letter used when constructing product rate URLs automatically.", + "compare_list": "Tariff scenarios evaluated by the Compare page. Each entry has an id, display name and optional import or export rate definitions.", + "watch_list": "Additional Home Assistant entities copied into Predbat debug data so their values can be inspected during troubleshooting.", + "charge_start_service": "Home Assistant service call or ordered calls used to start battery charging on a custom inverter integration.", + "charge_stop_service": "Home Assistant service call or ordered calls used to stop battery charging on a custom inverter integration.", + "discharge_start_service": "Home Assistant service call or ordered calls used to start forced battery discharge on a custom inverter integration.", + "discharge_stop_service": "Home Assistant service call or ordered calls used to stop forced battery discharge on a custom inverter integration.", + "charge_freeze_service": "Home Assistant service call or ordered calls used to hold battery state of charge by preventing discharge.", + "discharge_freeze_service": "Home Assistant service call or ordered calls used to prevent charging while allowing normal discharge.", + "device_id": "Home Assistant device identifier associated with custom inverter service calls.", + "predheat": "PredHeat configuration block used to model building temperature and forecast electrical heating demand.", + "forecast_solar": "Forecast.Solar array definitions, including panel orientation, tilt, location and installed capacity.", + "forecast_solar_max_age": "Maximum age in hours of cached Forecast.Solar data before Predbat considers it stale and downloads it again.", + "forecast_solar_open_meteo_backup": "Uses Open-Meteo solar forecasting when Forecast.Solar data is unavailable or stale.", + "forecast_solar_open_meteo_first": "Prefers Open-Meteo solar forecasting and uses Forecast.Solar as the fallback source.", + "open_meteo_forecast": "Open-Meteo PV array definitions, including panel orientation, tilt, location and installed capacity.", + "open_meteo_forecast_max_age": "Maximum age in hours of cached Open-Meteo forecast data before Predbat refreshes it.", + "enable_coarse_fine_levels": "Enables the two-stage coarse and fine optimiser search. This can improve plan quality while controlling calculation time.", + "load_power_fill_enable": "Uses live load power to fill gaps in recent household-load history before forecasting.", + "load_ml_enable": "Enables Predbat's machine-learning load forecast in addition to the normal weighted historical model.", + "gateway_device_id": "Stable Home Assistant device identifier used by Predbat Gateway discovery and entity registration.", + "gateway_mqtt_host": "MQTT broker hostname used by Predbat Gateway.", + "gateway_mqtt_port": "MQTT broker port used by Predbat Gateway.", + "gateway_mqtt_token": "Authentication token used by Predbat Gateway when connecting to its MQTT broker.", + "redact_strings": "Extra literal values to mask in logs and debug archives when Predbat cannot identify them as credentials automatically.", + "redact_strings_labelled": "Named values to mask in logs and debug archives. The label is shown in place of each secret to make redacted diagnostics easier to understand.", +} + + +INTEGRATION_NAMES = { + "axle": "Axle Energy", + "sigenergy": "Sigenergy", + "solis": "Solis Cloud", + "myenergi": "myenergi", + "fox": "FoxESS Cloud", + "deye": "Deye Cloud", + "sunsynk": "Sunsynk Connect", + "alphaess": "AlphaESS", + "teslemetry": "Teslemetry", + "enphase": "Enphase Cloud", +} + + +INTEGRATION_SETTING_DESCRIPTIONS = { + "api_key": "{integration} API key used to authenticate Predbat's requests.", + "api_secret": "{integration} API secret paired with the configured API key.", + "app_id": "{integration} application identifier supplied for API access.", + "app_key": "{integration} application key supplied for API access.", + "app_secret": "{integration} application secret supplied for API access.", + "key": "{integration} API credential or access key.", + "username": "{integration} account username used to authenticate Predbat.", + "password": "{integration} account password used to authenticate Predbat.", + "inverter_sn": "{integration} inverter serial number or list of serial numbers Predbat should use.", + "system_id": "{integration} system identifier or list of system identifiers Predbat should use.", + "site_id": "{integration} site identifier or list of site identifiers Predbat should use.", + "hub_serial": "{integration} hub serial number associated with the configured account.", + "base_url": "{integration} API base URL. Change it only when the service documentation requires a different regional or compatible endpoint.", + "mqtt_host": "{integration} MQTT hostname used for live telemetry and control.", + "automatic": "Allows Predbat to discover {integration} devices and populate matching apps.yaml settings automatically.", + "automatic_ignore_pv": "Keeps {integration} automatic discovery enabled but ignores its PV data when solar generation comes from another source.", + "automatic_zappi": "Allows {integration} discovery to configure connected Zappi chargers automatically.", + "automatic_eddi": "Allows {integration} discovery to configure connected Eddi diverters automatically.", + "enable_controls": "Allows Predbat to send supported control commands to {integration}; disable for monitoring only.", + "control_enable": "Allows Predbat to send supported inverter control commands to {integration}; disable for monitoring only.", + "control": "Allows Predbat to dispatch supported demand-response or device controls through {integration}.", + "zappi_control": "Allows Predbat to control the configured {integration} Zappi charger.", + "tbc_control": "Allows Predbat to control the Tesla Backup Controller through {integration}.", + "poll_seconds": "Seconds between {integration} status refreshes. Shorter intervals increase API and network use.", + "nominal_voltage": "Nominal battery voltage used to convert {integration} current limits into power.", + "battery_nominal_voltage": "Nominal battery voltage used to convert {integration} current and power values.", + "battery_rate_max": "Maximum battery charge and discharge power reported to the optimiser for {integration}.", + "api_delay": "Minimum delay between {integration} API operations.", + "min_write_interval": "Minimum seconds between control writes sent to {integration}.", + "region": "{integration} account or API region used to select the correct service endpoint.", + "data_center": "{integration} data-centre identifier used to select the correct regional API.", + "company_id": "{integration} installer or company identifier required by the API.", + "auth_method": "Authentication method currently used for {integration}. This is normally managed automatically after sign-in.", + "access_token": "Saved {integration} access token. Predbat normally creates and refreshes this automatically.", + "token_expires_at": "Expiry time of the saved {integration} access token. Predbat maintains this value automatically.", + "token_hash": "Hash used to detect whether the stored {integration} token changed. Predbat maintains this value automatically.", + "ca_pem": "Certificate-authority PEM file used to verify the {integration} MQTT service.", + "client_pem": "Client certificate PEM file used to authenticate with the {integration} MQTT service.", + "client_key": "Private key PEM file paired with the {integration} client certificate.", + "cloud_pv_load_ignore": "Ignores {integration} cloud PV and load readings when those measurements are provided by another source.", + "hybrid": "Treats the {integration} site as a hybrid installation when interpreting power flow and controls.", +} + + +def _apps_schema_description(name, spec): + """Return purpose-aware help text for an apps.yaml property.""" + description = APPS_SCHEMA_DESCRIPTIONS.get(name) + if description: + return description + + for prefix, integration in INTEGRATION_NAMES.items(): + marker = prefix + "_" + if not name.startswith(marker): + continue + suffix = name[len(marker) :] + template = INTEGRATION_SETTING_DESCRIPTIONS.get(suffix) + if template: + return template.format(integration=integration) + + label = name.replace("_", " ") + setting_type = spec.get("type", "string") + if "sensor" in setting_type: + return f"Home Assistant entity or list of entities supplying {label} data to Predbat for planning, control or dashboard reporting." + if "boolean" in setting_type: + return f"Controls whether Predbat uses the {label} feature. Disable it when the related integration or behaviour is not required." + if "dict" in setting_type: + return f"Structured apps.yaml configuration for {label}. Each nested key controls part of this Predbat feature." + if "list" in setting_type: + return f"Ordered apps.yaml values for {label}. Supply one entry for each relevant device or configured source." + return f"Configuration value used by Predbat for {label}. Its expected format is {setting_type.replace('|', ' or ')}." + + +def build_apps_json_schema(): + """Build the Monaco JSON Schema for a Predbat apps.yaml file.""" + properties = { + "module": {"type": "string", "const": "predbat", "description": "AppDaemon module to load for this app. Predbat must use the value 'predbat'."}, + "class": {"type": "string", "const": "PredBat", "description": "Python application class AppDaemon starts. Predbat must use the value 'PredBat'."}, + "dependencies": {"type": "array", "items": {"type": "string"}, "description": "Names of other AppDaemon apps that must finish starting before Predbat is initialised."}, + "prefix": { + "type": "string", + "default": "predbat", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Prefix added to every Home Assistant entity Predbat creates. Change it only when running multiple instances; use lowercase letters, numbers and underscores.", + }, + "timezone": {"type": "string", "default": "Europe/London", "description": "IANA timezone used to align tariffs, forecasts and plan times, for example Europe/London."}, + "template": {"type": "boolean", "description": "Marks the file as the unconfigured example template. Remove this setting after replacing the example entities and credentials."}, + "grid_power": { + **_apps_schema_value({"type": "sensor|sensor_list"}), + "description": "Live grid power entity or list of entities, normally in watts. Predbat uses the sign to distinguish import from export and sums multiple meters.", + }, + "geserial": { + "type": "string", + "description": "GivTCP serial-number value or regular-expression lookup used by {geserial} placeholders in entity IDs.", + }, + "geserial2": { + "type": "string", + "description": "Second GivTCP serial-number value or lookup used by {geserial2} placeholders in multi-inverter entity IDs.", + }, + "auto_restart": { + **_apps_schema_value({"type": "dict_list"}), + "description": "Home Assistant service calls Predbat may run to restart a failed inverter integration after repeated communication errors.", + }, + "charge_limit_enable": { + **_apps_schema_value({"type": "sensor_list"}), + "description": "Writable switch for each inverter that enables its scheduled-charge target state of charge.", + }, + "discharge_target_soc": { + **_apps_schema_value({"type": "sensor_list"}), + "description": "Writable state-of-charge target for each inverter's scheduled forced-discharge window.", + }, + "battery_temperature_history": { + **_apps_schema_value({"type": "sensor"}), + "description": "Battery temperature history entity used to forecast temperature-dependent charge-rate limits.", + }, + "battery_temperature_charge_curve": { + **_apps_schema_value({"type": "int_float_dict"}), + "description": "Battery-temperature to C-rate multiplier curve used to reduce charging power when the battery is cold or hot.", + }, + "inverter_clock_skew_start": { + "type": "integer", + "description": "Minutes added when programming charge-window start times to compensate for an inaccurate inverter clock.", + }, + "inverter_clock_skew_end": { + "type": "integer", + "description": "Minutes added when programming charge-window end times to compensate for an inaccurate inverter clock.", + }, + "inverter_clock_skew_discharge_start": { + "type": "integer", + "description": "Minutes added when programming forced-discharge start times to compensate for an inaccurate inverter clock.", + }, + "inverter_clock_skew_discharge_end": { + "type": "integer", + "description": "Minutes added when programming forced-discharge end times to compensate for an inaccurate inverter clock.", + }, + "octopus_saving_session": { + **_apps_schema_value({"type": "sensor"}), + "description": "Octopus event or binary-sensor entity announcing Saving Sessions that Predbat may model or join.", + }, + "octopus_free_session": { + **_apps_schema_value({"type": "sensor"}), + "description": "Octopus event entity announcing Free Electricity sessions that Predbat should include in the plan.", + }, + "metric_standing_charge": { + **_apps_schema_value({"type": "sensor"}), + "description": "Current daily electricity standing-charge entity, included in Predbat cost totals and comparisons.", + }, + } + config_by_name = {item.get("name"): item for item in CONFIG_ITEMS if item.get("name")} + for name, spec in APPS_SCHEMA.items(): + value_schema = _apps_schema_value(spec) + value_schema["description"] = spec.get("description") or _apps_schema_description(name, spec) + properties[name] = value_schema + + for name, item in config_by_name.items(): + value_schema = properties.get(name) + + if value_schema is None: + item_type = item.get("type") + if item_type == "switch": + value_schema = {"type": "boolean"} + elif item_type == "input_number": + value_schema = {"type": "number"} + elif item_type == "select": + value_schema = {"type": "string"} + else: + continue + + properties[name] = value_schema + + # Prefer the proper CONFIG_ITEMS description + if item.get("description"): + value_schema["description"] = item["description"] + elif not value_schema.get("description"): + value_schema["description"] = item.get( + "friendly_name", + name.replace("_", " ").capitalize(), + ) + + if "min" in item: + value_schema["minimum"] = item["min"] + if "max" in item: + value_schema["maximum"] = item["max"] + if isinstance(item.get("options"), list): + value_schema["enum"] = item["options"] + if isinstance(item.get("default"), (str, int, float, bool, list, dict)): + value_schema["default"] = item["default"] + + # PredHeat already owns these entity mappings. Describing the nested keys + # here lets Monaco offer the same Home Assistant entity autocomplete inside + # the predheat block instead of encouraging duplicate Overview settings. + properties["predheat"].update( + { + "properties": { + "mode": { + "type": "string", + "enum": ["gas", "pump"], + "description": "Heating-system type modelled by PredHeat. Pump mode also enables the Overview ASHP card.", + }, + "weather": { + "type": "string", + "x-ha-entity": True, + "description": "Home Assistant weather entity used by PredHeat and by Overview for current conditions and weather effects.", + }, + "heating_energy": { + "type": "string", + "x-ha-entity": True, + "description": "Cumulative heating-energy entity used by PredHeat to calculate today's ASHP energy for Overview.", + }, + }, + "additionalProperties": True, + } + ) + + required = ["module", "class"] + [name for name, spec in APPS_SCHEMA.items() if spec.get("required")] + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://predbat.local/apps.schema.json", + "title": "Predbat apps.yaml", + "type": "object", + "properties": {"pred_bat": {"$ref": "#/$defs/predbatApp"}}, + "required": ["pred_bat"], + "additionalProperties": True, + "$defs": { + "predbatApp": { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": True, + } + }, + } + + def state_as_of_slots(records, slots): """ Resolve each slot to the state in effect at it - the most recent record at or before the slot. @@ -469,7 +924,8 @@ def _register_annual_routes(self, app): Application and assert they exist, without booting a real TCP listener - the constructor for that Application performs no network I/O of its own. """ - app.router.add_get("/annual", self.annual_page.html_annual) + app.router.add_get("/annual", self.html_modern_ui if self.get_web_ui() == "modern" else self.annual_page.html_annual) + app.router.add_get("/legacy_annual", self.annual_page.html_annual) app.router.add_post("/annual", self.annual_page.html_annual_post) app.router.add_post("/annual_reset", self.annual_page.html_annual_reset) app.router.add_post("/annual_array", self.annual_page.html_annual_array) @@ -501,7 +957,8 @@ def _register_chat_routes(self, app): configured" when the component is not up yet - that per-request check is what stands in for a boot-time gate. """ - app.router.add_get("/chat", self.chat_page.html_chat) + app.router.add_get("/chat", self.html_modern_ui if self.get_web_ui() == "modern" else self.chat_page.html_chat) + app.router.add_get("/legacy_chat", self.chat_page.html_chat) app.router.add_get("/chat/conversations", self.chat_page.html_chat_conversations) app.router.add_post("/chat/conversations", self.chat_page.html_chat_create) app.router.add_post("/chat/rename", self.chat_page.html_chat_rename) @@ -523,22 +980,28 @@ def _register_chat_routes(self, app): async def start(self): # Start the web server app = web.Application() - app.router.add_get("/", self.html_default) - app.router.add_get("/plan", self.html_plan) - app.router.add_get("/log", self.html_log) - app.router.add_get("/apps", self.html_apps) + modern_ui = self.get_web_ui() == "modern" + app.router.add_get("/", self.html_modern_ui if modern_ui else self.html_default) + app.router.add_get("/plan", self.html_modern_ui if modern_ui else self.html_plan_legacy) + app.router.add_get("/log", self.html_modern_ui if modern_ui else self.html_log) + app.router.add_get("/apps", self.html_modern_ui if modern_ui else self.html_apps) + app.router.add_get("/legacy_apps", self.html_apps) app.router.add_post("/apps", self.html_apps_post) app.router.add_get("/apps_value", self.html_apps_value) - app.router.add_get("/charts", self.html_charts) - app.router.add_get("/config", self.html_config) + app.router.add_get("/charts", self.html_modern_ui if modern_ui else self.html_charts_legacy) + app.router.add_get("/legacy_charts", self.html_charts_legacy) + app.router.add_get("/config", self.html_modern_ui if modern_ui else self.html_config) app.router.add_get("/entity", self.html_entity) app.router.add_post("/entity", self.html_entity_post) app.router.add_post("/config", self.html_config_post) - app.router.add_get("/dash", self.html_dash) + app.router.add_get("/dash", self.html_modern_ui if modern_ui else self.html_dash_legacy) app.router.add_post("/dash", self.html_dash_post) app.router.add_get("/dash_content", self.html_dash_content) - app.router.add_get("/components", self.html_components) - app.router.add_get("/discovery", self.html_discovery) + app.router.add_get("/legacy_dash", self.html_dash_legacy) + app.router.add_get("/legacy_plan", self.html_plan_legacy) + app.router.add_get("/components", self.html_modern_ui if modern_ui else self.html_components) + app.router.add_get("/discovery", self.html_modern_ui if modern_ui else self.html_discovery) + app.router.add_get("/api/discovery", self.html_api_discovery) app.router.add_get("/component_entities", self.html_component_entities) app.router.add_post("/component_restart", self.html_component_restart) app.router.add_get("/component_config", self.html_component_config) @@ -551,35 +1014,54 @@ async def start(self): app.router.add_get("/debug_history_list", self.html_debug_history_list) app.router.add_get("/debug_history_download", self.html_debug_history_download) app.router.add_get("/debug_history_download_recent", self.html_debug_history_download_recent) - app.router.add_get("/compare", self.html_compare) + app.router.add_get("/compare", self.html_modern_ui if modern_ui else self.html_compare) app.router.add_post("/compare", self.html_compare_post) self._register_annual_routes(app) self._register_chat_routes(app) - app.router.add_get("/apps_editor", self.html_apps_editor) + app.router.add_get("/apps_editor", self.html_modern_ui if modern_ui else self.html_apps_editor) app.router.add_post("/apps_editor", self.html_apps_editor_post) app.router.add_get("/apps_editor_checksum", self.html_apps_editor_checksum) app.router.add_post("/plan_override", self.html_plan_override) app.router.add_post("/rate_override", self.html_rate_override) app.router.add_post("/restart", self.html_restart) + app.router.add_post("/api/restart", self.html_restart) app.router.add_post("/inverter_refresh", self.html_inverter_refresh) app.router.add_get("/api/state", self.html_api_get_state) app.router.add_get("/api/ping", self.html_api_ping) app.router.add_post("/api/state", self.html_api_post_state) app.router.add_post("/api/service", self.html_api_post_service) app.router.add_get("/api/plan_data", self.html_api_plan_data) + app.router.add_get("/api/chart_data", self.html_api_chart_data) + app.router.add_get("/api/compare", self.html_api_compare) + app.router.add_get("/api/apps_schema", self.html_api_apps_schema) + app.router.add_get("/api/apps_yaml", self.html_api_apps_yaml) + app.router.add_post("/api/apps_yaml", self.html_api_apps_yaml_post) app.router.add_get("/api/log", self.html_api_get_log) app.router.add_get("/api/entities", self.html_api_get_entities) + app.router.add_get("/api/config", self.html_api_config) + app.router.add_post("/api/config", self.html_config_post) + app.router.add_get("/api/components", self.html_api_components) + app.router.add_get("/api/browse", self.html_api_browse) app.router.add_post("/api/login", self.html_api_login) - app.router.add_get("/browse", self.html_browse) + app.router.add_get("/browse", self.html_modern_ui if modern_ui else self.html_browse) app.router.add_get("/download", self.html_download_file) app.router.add_get("/images/{filename}", self.html_logo_image) - app.router.add_get("/internals", self.html_internals) + app.router.add_get("/internals", self.html_modern_ui if modern_ui else self.html_internals) app.router.add_get("/api/internals", self.html_api_internals) + app.router.add_get("/api/internals/threads", self.html_api_internals_threads) app.router.add_get("/api/internals/download", self.html_api_internals_download) app.router.add_get("/api/status", self.html_api_get_status) + app.router.add_post("/api/dashboard_control", self.html_api_dashboard_control) + app.router.add_get("/api/power_flow", self.html_api_power_flow) + app.router.add_get("/overview", self.html_modern_ui if modern_ui else self.html_dash_legacy) app.router.add_get("/metrics", metrics_handler) app.router.add_get("/metrics/json", metrics_json_handler) app.router.add_get("/metrics_dashboard", self.html_metrics_dashboard) + if modern_ui: + app.router.add_get("/docs", self.html_modern_ui) + app.router.add_get("/assets/{filename:.*}", self.html_modern_ui_asset) + app.router.add_get("/favicon.svg", self.html_modern_ui_asset) + app.router.add_get("/icons.svg", self.html_modern_ui_asset) # Notify plugin system that web interface is ready if hasattr(self.base, "plugin_system") and self.base.plugin_system: @@ -2496,22 +2978,264 @@ async def html_api_post_state(self, request): async def html_api_get_status(self, request): """ - Get current Predbat status (calculating state and battery info) + Get current Predbat dashboard status. + + This endpoint provides the live operational state used by the dashboard. + Existing fields are retained for backwards compatibility. """ try: + # Existing calculation state calculating = self.get_arg("active", False) + if self.base.update_pending: calculating = True + # Existing battery HTML used by the legacy dashboard battery_icon = self.get_battery_status_icon() - status_data = {"calculating": calculating, "battery_html": battery_icon} + # Predbat status entity + status_entity = self.prefix + ".status" + + status = self.get_state_wrapper(status_entity, default="Unknown") + + detail = self.get_state_wrapper(status_entity, attribute="detail", default="") + + last_updated = self.get_state_wrapper(status_entity, attribute="last_updated", default=None) + + # Time Predbat was last started + last_started = self.get_state_wrapper(self.prefix + ".last_started", default=None) + + # Current operating mode + mode = self.get_arg("mode", "") + + # Dashboard controls + debug_enable = self.get_arg("debug_enable", False) + + read_only = self.get_arg("set_read_only", False) + + predbat_active, _ = self.get_ha_config("active", None) + + update_entity = "update.predbat_version" + updating = self.get_state_wrapper(update_entity, attribute="in_progress", default=False) is True + update_available = self.get_state_wrapper(update_entity, default="off") == "on" + latest_version = self.get_state_wrapper(update_entity, attribute="latest_version", default=None) + if latest_version in (None, "", "unknown", "unavailable", "check HACS"): + latest_version = None + + # Configuration health + config_errors = len(self.arg_errors) + + status_data = { + # Existing API fields + "calculating": calculating, + "updating": updating, + "battery_html": battery_icon, + # Dashboard status + "status": status, + "detail": detail, + "last_updated": last_updated, + "last_started": last_started, + "version": THIS_VERSION_DISPLAY, + "latest_version": latest_version, + "update_available": update_available, + # Predbat configuration + "mode": mode, + "debug_enable": debug_enable, + "read_only": read_only, + "active": predbat_active, + "chat_enabled": self.chat_enabled(), + "load_ml_enabled": bool(self.base.get_arg("load_ml_enable", False)), + # Configuration health + "config_ok": config_errors == 0, + "config_errors": config_errors, + } return web.Response(content_type="application/json", text=json.dumps(status_data)) + except Exception as e: self.log("Error getting status: {}".format(e)) + return web.Response(status=500, content_type="application/json", text=json.dumps({"error": str(e)})) + async def html_api_dashboard_control(self, request): + """ + Update a Predbat dashboard control. + + This provides the React dashboard with a JSON API for the same + controls exposed by the legacy /dash page. + + Supported controls: + - mode + - debug_enable + - set_read_only + - active + """ + try: + data = await request.json() + + control = data.get("control") + value = data.get("value") + + # Mode is represented by a Home Assistant select entity. + if control == "mode": + if not isinstance(value, str): + return web.json_response({"result": "error", "error": "Mode must be a string"}, status=400) + + entity_id = f"select.{self.prefix}_mode" + + await self.set_state_external(entity_id, value) + + # The remaining dashboard controls are Home Assistant switches. + elif control in ["debug_enable", "set_read_only", "active"]: + if not isinstance(value, bool): + return web.json_response({"result": "error", "error": "Switch value must be boolean"}, status=400) + + entity_id = f"switch.{self.prefix}_{control}" + + await self.set_state_external(entity_id, value) + + else: + return web.json_response({"result": "error", "error": f"Unsupported control: {control}"}, status=400) + + self.log(f"Dashboard control updated: {control} = {value}") + + return web.json_response({"result": "ok", "control": control, "value": value}) + + except Exception as e: + self.log(f"ERROR: Failed to update dashboard control: {str(e)}") + + return web.json_response({"result": "error", "error": str(e)}, status=500) + + async def html_api_power_flow(self, request): + """ + Return live power-flow and Overview data for the React dashboard. + + Uses the same values and sign conventions as the legacy SVG diagram so + the React UI does not have to reinterpret inverter data. Optional + Overview entities resolve here so the browser never needs Home + Assistant credentials. + """ + try: + grid_power = self.base.grid_power + battery_power = self.base.battery_power + pv_power = self.base.pv_power + load_power = self.base.load_power + + # Car charging is handled the same way as the legacy power-flow view. + car_configured = self.base.car_charging_power_configured + car_power = self.base.car_charging_power + car_inside_clamp = self.base.car_energy_reported_load + house_power = max(0, load_power - car_power) if car_configured and car_inside_clamp else load_power + + sun_state = self.get_state_wrapper(entity_id="sun.sun", default=None) + predheat_config = self.get_arg("predheat", default={}, indirect=False) + if not isinstance(predheat_config, dict): + predheat_config = {} + weather_entity = predheat_config.get("weather") or self.get_arg("weather", default=None, indirect=False) or "weather.forecast_home" + weather_state = self.get_state_wrapper(entity_id=weather_entity, default=None) + weather_temperature = _optional_float(self.get_state_wrapper(entity_id=weather_entity, attribute="temperature", default=None)) + weather_temperature_unit = self.get_state_wrapper(entity_id=weather_entity, attribute="temperature_unit", default="") + weather = None + if weather_state not in (None, "unknown", "unavailable") or weather_temperature is not None: + weather = { + "state": weather_state, + "temperature": weather_temperature, + "temperature_unit": weather_temperature_unit or "", + } + + pv_forecast_today = _optional_float(self.get_arg("pv_forecast_today", default=None, required_unit="kWh")) + + car_status_values = self.get_arg("car_charging_status", default=[]) + if not car_status_values: + car_status_values = self.get_arg("car_charging_planned", default=[]) + if not isinstance(car_status_values, list): + car_status_values = [car_status_values] if car_status_values else [] + car_status = next((str(value) for value in car_status_values if value not in (None, "unknown", "unavailable")), None) + + car_soc = _optional_float(self.get_arg("car_charging_soc", default=None, index=0)) + + car_energy_values = self.get_arg("car_charging_energy", default=[]) + if not isinstance(car_energy_values, list): + car_energy_values = [car_energy_values] if car_energy_values else [] + resolved_car_energy = [_optional_float(value) for value in car_energy_values] + car_energy_today = sum(value for value in resolved_car_energy if value is not None) if any(value is not None for value in resolved_car_energy) else None + if car_energy_today is None and getattr(self.base, "car_charging_energy", None): + car_energy_today = sum(self.base.get_from_incrementing(self.base.car_charging_energy, minute) for minute in range(self.base.minutes_now + 1)) + + ashp = None + predheat_is_pump = bool(predheat_config) and str(predheat_config.get("mode", "pump")).lower() == "pump" + ashp_enabled = predheat_is_pump + if "ashp_enable" in self.base.args: + ashp_enabled = self.get_arg("ashp_enable", default=False, indirect=False) + if ashp_enabled: + ashp_power_entity = self.get_arg("ashp_power", default=None, indirect=False) + ashp_status_entity = self.get_arg("ashp_status", default=None, indirect=False) + ashp_energy_entity = self.get_arg("heat_energy", default=None, indirect=False) + if not ashp_energy_entity: + ashp_energy_entity = self.get_arg("ashp_energy_today", default=None, indirect=False) + ashp_power = None + ashp_status = None + ashp_energy = None + + if ashp_power_entity: + ashp_power = _optional_float(self.get_state_wrapper(entity_id=ashp_power_entity, default=None, required_unit="W")) + if ashp_status_entity: + status_value = self.get_state_wrapper(entity_id=ashp_status_entity, default=None) + if status_value not in (None, "unknown", "unavailable"): + ashp_status = str(status_value) + if ashp_energy_entity: + ashp_energy = _optional_float(self.get_state_wrapper(entity_id=ashp_energy_entity, default=None, required_unit="kWh")) + elif predheat_is_pump: + ashp_energy = _optional_float(getattr(getattr(self.base, "predheat", None), "heat_energy_today", None)) + + ashp = {"power": ashp_power, "status": ashp_status, "energy_today": ashp_energy} + + # Match the existing diagram's direction thresholds/sign conventions. + grid_importing = grid_power <= -10 + battery_discharging = battery_power >= 10 + battery_charging = battery_power <= -10 + pv_generating = pv_power > 0 + + return web.json_response( + { + "grid_power": grid_power, + "battery_power": battery_power, + "pv_power": pv_power, + "load_power": load_power, + "house_power": house_power, + "soc_percent": self.base.soc_percent, + "grid_importing": grid_importing, + "battery_charging": battery_charging, + "battery_discharging": battery_discharging, + "pv_generating": pv_generating, + "sun_state": sun_state, + "currency_symbols": self.currency_symbols, + "pv_forecast_today": pv_forecast_today, + "totals": { + "load_today": _optional_float(getattr(self.base, "load_minutes_now", None)), + "pv_today": _optional_float(getattr(self.base, "pv_today_now", None)), + "import_today": _optional_float(getattr(self.base, "import_today_now", None)), + "export_today": _optional_float(getattr(self.base, "export_today_now", None)), + "cost_today": _optional_float(getattr(self.base, "cost_today_sofar", None)) or 0, + }, + "weather": weather, + "car": { + "configured": car_configured, + "power": car_power, + "inside_clamp": car_inside_clamp, + "charging": car_configured and car_power >= 10, + "status": car_status, + "soc": car_soc, + "energy_today": car_energy_today, + }, + "ashp": ashp, + } + ) + + except Exception as e: + self.log(f"ERROR: Failed to get power-flow data: {str(e)}") + return web.json_response({"error": str(e)}, status=500) + async def html_api_ping(self, request): """ Check if Predbat is running @@ -2570,6 +3294,14 @@ async def html_api_plan_data(self, request): # Get JSON data (with timestamps embedded in the JSON) plan_json = self.get_state_wrapper(entity_id=plan_entity, attribute="raw", default=None) + if plan_json: + # Work on our own copy rather than modifying the + # object returned from Home Assistant state. + plan_json = dict(plan_json) + + plan_json["car_charging_from_battery"] = bool(getattr(self.base, "car_charging_from_battery", True)) + + plan_json["car_energy_reported_load"] = bool(getattr(self.base, "car_energy_reported_load", False)) plan_timestamp = plan_json.get("timestamp", None) if plan_json else None yesterday_json = self.get_state_wrapper(entity_id=yesterday_entity, attribute="json", default=None) @@ -2645,6 +3377,15 @@ async def html_api_plan_data(self, request): return web.json_response(response_data) async def html_plan(self, request): + """ + Serve the selected plan interface. + """ + if self.get_web_ui() == "modern": + return await self.html_modern_ui(request) + + return await self.html_plan_legacy(request) + + async def html_plan_legacy(self, request): """ Return the Predbat plan as an HTML page with client-side JSON rendering """ @@ -2743,6 +3484,18 @@ async def html_plan(self, request): text += "