Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4870833
Minor documentation tweaks
gcoan May 16, 2026
edf4226
Document PVAccuracy chart and Metrics view
gcoan May 16, 2026
7fd69e3
Merge branch 'springfall2008:main' into main
gcoan May 18, 2026
7acc09e
#3934 iboost daily reset
gcoan May 18, 2026
da5635f
Added soc_max_calculated to output_data
gcoan May 18, 2026
ed0ee23
Add error checking to Octopus saving session join, only send joined n…
gcoan May 18, 2026
e74cc2b
#3801 use module name in predbat notifications instead of hard coded …
gcoan May 18, 2026
875cb51
capitalise predbat prefix in alert notifications
gcoan May 21, 2026
49fe906
Updated plan doc to include Axle in $ prefix
gcoan May 21, 2026
5ebaf3d
fix sigenergy, luxpower and kostal inverter setup formatting issues
gcoan May 21, 2026
ec7bb56
Potential fix for pull request finding
springfall2008 Jun 6, 2026
b4ecfaf
Merge branch 'main' into main
gcoan Aug 12, 2026
fbd5a3c
Apply claude review comments
gcoan Aug 12, 2026
600202f
change dummy service call to return success result
gcoan Aug 12, 2026
469bc21
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Aug 12, 2026
7d9f4e7
correct service join call to match octopus.py (add return_response)
gcoan Aug 12, 2026
54325a8
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Aug 12, 2026
06e611e
Merge branch 'springfall2008:main' into main
gcoan Aug 13, 2026
89eda6f
Merge branch 'springfall2008:main' into main
gcoan Aug 14, 2026
360d96c
#4522 Add minimum memory guidance
gcoan Aug 14, 2026
4da7b26
#4523 #4496 load energy entities documentation update
gcoan Aug 14, 2026
54447ff
revert checking octopus saving session join response as service curre…
gcoan Aug 15, 2026
2b30cc9
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Aug 15, 2026
2911688
correct prior fix
gcoan Aug 15, 2026
5917e4c
fix saving session test to match current code (no return response)
gcoan Aug 15, 2026
652407d
Merge branch 'springfall2008:main' into main
gcoan Aug 16, 2026
19a08c9
Merge branch 'springfall2008:main' into main
gcoan Aug 19, 2026
59504f8
#4607 change 'addon_configs' to 'app_configs'
gcoan Aug 20, 2026
b86ae98
Fix missing space warning in agents.md table documentation
gcoan Aug 20, 2026
9c77ef9
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t

## Project Overview

Predbat is a Home Assistant addon (app) that predicts and optimizes home battery charging/discharging based on electricity rates, solar forecasts, and historical load data. It supports inverters from GivEnergy, Solis, Huawei, SolarEdge, and Sofar, and integrates with energy providers like Octopus Energy, Kraken (EDF/E.ON), and Axle Energy VPP.
Predbat is a Home Assistant App (addon) that predicts and optimizes home battery charging/discharging based on electricity rates, solar forecasts, and historical load data. It supports inverters from GivEnergy, Solis, Huawei, SolarEdge, and Sofar, and integrates with energy providers like Octopus Energy, Kraken (EDF/E.ON), and Axle Energy VPP.

It also supports Predbat.com which is a cloud based product that does not use Home Assistant and can run in a Docker environment.

Expand Down Expand Up @@ -76,7 +76,7 @@ The main loop (`update_pred()`) runs every 5 minutes: fetch data → run optimiz
### Core Modules

| Module | Role |
|--------|------|
| -------- | ------ |
| `plan.py` | Optimization engine — multi-threaded search across thousands of charge/discharge window scenarios |
| `predict.py` / `prediction.py` | Battery SOC prediction models, PV generation, load forecasting |
| `fetch.py` | Pulls PV forecasts, historical load, rate data, and inverter state |
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

Predbat is a Home Assistant addon (app) that predicts and optimizes home battery charging/discharging based on electricity rates, solar forecasts, and historical load data. It supports inverters from GivEnergy, Solis, Huawei, SolarEdge, and Sofar, and integrates with energy providers like Octopus Energy, Kraken (EDF/E.ON), and Axle Energy VPP.
Predbat is a Home Assistant App (addon) that predicts and optimizes home battery charging/discharging based on electricity rates, solar forecasts, and historical load data. It supports inverters from GivEnergy, Solis, Huawei, SolarEdge, and Sofar, and integrates with energy providers like Octopus Energy, Kraken (EDF/E.ON), and Axle Energy VPP.

It also supports Predbat.com which is a cloud based product that does not use Home Assistant and can run in a Docker environment.

Expand Down
3 changes: 2 additions & 1 deletion apps/predbat/axle.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,8 @@ async def _fetch_byok_event(self):
if self.get_arg("set_event_notify"):
local_start = start_time.astimezone(self.local_tz)
local_end = end_time.astimezone(self.local_tz)
self.call_notify("Predbat: Scheduled Axle VPP event {}-{}, {} p/kWh".format(local_start.strftime("%a %d/%m %H:%M"), local_end.strftime("%H:%M"), self.pence_per_kwh))
msg = f"Scheduled Axle VPP event {local_start.strftime('%a %d/%m %H:%M')}-{local_end.strftime('%H:%M')}, {self.pence_per_kwh} p/kWh"
self.call_notify(f"{self.prefix.capitalize()}: {msg}")

self.cleanup_event_history()
await self.save_event_history()
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/build_kernel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Copyright Trefor Southwell 2026 - All Rights Reserved
# -----------------------------------------------------------------------------
# Build the C++ prediction kernel as a shared library for local testing or
# inside the addon Docker image. Falls back to the Python engine when absent.
# inside the app Docker image. Falls back to the Python engine when absent.
#
# Usage: bash apps/predbat/build_kernel.sh [output.so]
set -e
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/build_kernel_cross.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# matching the platform and machine at runtime, or skip if missing:
# prediction_kernel_lib_<machine>.so (Linux)
# prediction_kernel_lib_darwin_<machine>.so (macOS)
# where Linux <machine> is x86_64, aarch64, armv7l or i686, covering the addon's
# where Linux <machine> is x86_64, aarch64, armv7l or i686, covering the app's
# Ubuntu (glibc) base images (amd64, aarch64, armv7/armhf, i386; glibc floor 2.17),
# and macOS <machine> is arm64 (Apple Silicon) or x86_64 (Intel).
#
Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/config/apps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ pred_bat:
# Example below is auto-restart for GivTCP app itself
#auto_restart:
# - shell: 'rm -rf /homeassistant/GivTCP/*.pkl'
# - service: hassio/addon_restart
# addon: 533ea71a_givtcp
# - service: hassio/app_restart
# app: 533ea71a_givtcp
#
# Example on how to restart the inverter via GivTCP
# - service: button.press
Expand Down
9 changes: 7 additions & 2 deletions apps/predbat/ha.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,10 +422,15 @@ def initialize(self, ha_url, ha_key, db_enable, db_mirror_ha, db_primary):
self.log("Info: Using SQL Lite database as primary data source, no HA interface available")

if self.ha_key:
# Get the current addon info, but suppress warning message if the API call fails as non-HAOS installs won't have supervisor running
# Get the current app info, but suppress warning message if the API call fails as non-HAOS installs won't have supervisor running
#
# HA changed terminology from 'addons' to 'apps' in HA 2026.2 but retained the old service calls for transition
#
# At present have not changed Predbat API call in order to not break installations that are still using an older HA supervisor
# Propose in Feb 2027 that Predbat be changed to use the new service call
res = self.api_call("/addons/self/info", core=False, silent=True)
if res:
# get app slug name which is the actual directory name under /addon_configs that /config is mounted to
# get app slug name which is the actual directory name under /apps_configs that /config is mounted to
self.slug = res["data"]["slug"]
self.log("Info: App slug is {}".format(self.slug))

Expand Down
5 changes: 5 additions & 0 deletions apps/predbat/hass.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ async def main():
#

# List of root directories to search
# HA changed terminology from 'addons' to 'apps' in HA 2026.2 with 'addon_configs' becoming 'app_configs' but retained
# the old directory names for transition
#
# At present have not changed Predbat directory call in order to not break installations that are still using an older HA supervisor
# Propose in Feb 2027 that Predbat be changed to use the new directory call
roots = [".", "/addon"]

# Find all .py files in the directory hierarchy, plus the one real apps.yaml this
Expand Down
33 changes: 20 additions & 13 deletions apps/predbat/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,12 @@ def auto_restart(self, reason):
for command in restart_command:
shell = command.get("shell", None)
service = command.get("service", None)
app = command.get("app", None)
# retain addon service parameter for backwards compatibility with configs using the pre HA 2026.2 nomenclature
addon = command.get("addon", None)
if addon:
if app:
app = self.base.resolve_arg(service, app, indirect=False)
elif addon:
addon = self.base.resolve_arg(service, addon, indirect=False)
entity_id = command.get("entity_id", None)
if entity_id:
Expand All @@ -113,7 +117,10 @@ def auto_restart(self, reason):
self.log("Warn: Calling restart shell command: {}".format(shell))
os.system(shell)
if service:
if addon:
if app:
self.log("Warn: Calling restart service {} with app {}".format(service, app))
self.base.call_service_wrapper(service, app=app)
elif addon:
self.log("Warn: Calling restart service {} with addon {}".format(service, addon))
self.base.call_service_wrapper(service, addon=addon)
elif entity_id:
Expand All @@ -123,7 +130,7 @@ def auto_restart(self, reason):
self.log("Warn: Calling restart service {}".format(service))
self.base.call_service_wrapper(service)
if self.base.get_arg("set_system_notify"):
self.base.call_notify("Auto-restart service {} called due to: {}".format(service, reason))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Auto-restart service {service} called due to: {reason}")
self.sleep(15)
raise Exception("Auto-restart triggered")
else:
Expand Down Expand Up @@ -1791,7 +1798,7 @@ def adjust_reserve(self, reserve):
else:
self.write_and_poll_value("reserve", self.base.get_arg("reserve", indirect=False, index=self.id, required_unit="%"), reserve)
if self.base.set_inverter_notify:
self.base.call_notify("Predbat: Inverter {} Target Reserve has been changed to {}% at {}".format(self.id, dp0(reserve), self.base.time_now_str()))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} Target Reserve has been changed to {dp0(reserve)}% at {self.base.time_now_str()}")
self.mqtt_message(topic="set/reserve", payload=reserve)
else:
self.base.log("Inverter {} Current reserve is {}%, already at target".format(self.id, dp0(current_reserve)))
Expand Down Expand Up @@ -1871,7 +1878,7 @@ def adjust_charge_rate(self, new_rate, notify=True):
self.write_and_poll_value("charge_rate_percent", self.base.get_arg("charge_rate_percent", indirect=False, index=self.id, required_unit="%"), min(int(new_rate / self.battery_rate_max_raw * 100), 100), fuzzy=5, required_unit="%")

if notify and self.base.set_inverter_notify:
self.base.call_notify("Predbat: Inverter {} charge rate changes to {}W at {}".format(self.id, new_rate, self.base.time_now_str()))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} charge rate changes to {new_rate}W at {self.base.time_now_str()}")
self.mqtt_message(topic="set/charge_rate", payload=new_rate)

# Re-assert the timed current register on every call, not just when charge_rate itself
Expand Down Expand Up @@ -1915,7 +1922,7 @@ def adjust_discharge_rate(self, new_rate, notify=True):
self.write_and_poll_value("discharge_rate_percent", self.base.get_arg("discharge_rate_percent", indirect=False, index=self.id, required_unit="%"), min(int(new_rate / self.battery_rate_max_raw * 100), 100), fuzzy=5, required_unit="%")

if notify and self.base.set_inverter_notify:
self.base.call_notify("Predbat: Inverter {} discharge rate changes to {}W at {}".format(self.id, new_rate, self.base.time_now_str()))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} discharge rate changes to {new_rate}W at {self.base.time_now_str()}")
self.mqtt_message(topic="set/discharge_rate", payload=new_rate)

# Re-assert the timed current register on every call, not just when discharge_rate itself
Expand Down Expand Up @@ -1976,7 +1983,7 @@ def adjust_battery_target(self, soc, isCharging=False, isExporting=False):
self.press_and_poll_button()

if self.base.set_inverter_notify:
self.base.call_notify("Predbat: Inverter {} Target SoC has been changed to {}% at {}".format(self.id, soc, self.base.time_now_str()))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} Target SoC has been changed to {soc}% at {self.base.time_now_str()}")
self.mqtt_message(topic="set/target_soc", payload=soc)
else:
self.base.log("Inverter {} Current Target SoC is {}%, already at target".format(self.id, current_soc))
Expand Down Expand Up @@ -2230,7 +2237,7 @@ def adjust_pause_mode(self, pause_charge=False, pause_discharge=False):
self.write_and_poll_option("pause_mode", entity_mode, new_pause_mode)

if self.base.set_inverter_notify:
self.base.call_notify("Predbat: Inverter {} pause mode to set {} at time {}".format(self.id, new_pause_mode, self.base.time_now_str()))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} pause mode to set {new_pause_mode} at time {self.base.time_now_str()}")

self.base.log("Inverter {} set pause mode to {}".format(self.id, new_pause_mode))

Expand Down Expand Up @@ -2303,7 +2310,7 @@ def adjust_inverter_mode(self, force_export, changed_start_end=False):

# Notify
if self.base.set_inverter_notify:
self.base.call_notify("Predbat: Inverter {} Force export set to {} at time {}".format(self.id, force_export, self.base.time_now_str()))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} Force export set to {force_export} at time {self.base.time_now_str()}")

self.base.log("Inverter {} set force export to {}".format(self.id, force_export))

Expand Down Expand Up @@ -2602,7 +2609,7 @@ def adjust_force_export(self, force_export, new_start_time=None, new_end_time=No
# Notify
if changed_start_end:
if self.base.set_inverter_notify:
self.base.call_notify("Predbat: Inverter {} Export time slot set to {} - {} at time {}".format(self.id, new_start, new_end, self.base.time_now_str()))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} Export time slot set to {new_start} - {new_end} at time {self.base.time_now_str()}")

def disable_charge_window(self, notify=True):
"""
Expand Down Expand Up @@ -2650,7 +2657,7 @@ def disable_charge_window(self, notify=True):
self.enable_charge_discharge_with_time_current("charge", False)

if self.base.set_inverter_notify and notify:
self.base.call_notify("Predbat: Inverter {} Disabled scheduled charging at {}".format(self.id, self.base.time_now_str()))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} Disabled scheduled charging at {self.base.time_now_str()}")

self.base.log("Inverter {} Turning off scheduled charge".format(self.id))

Expand Down Expand Up @@ -3025,7 +3032,7 @@ def adjust_charge_window(self, charge_start_time, charge_end_time, minutes_now):
self.rest_setChargeSlot1(new_start, new_end)

if self.base.set_inverter_notify:
self.base.call_notify("Predbat: Inverter {} Charge window change to: {} - {} at {}".format(self.id, new_start, new_end, self.base.time_now_str()))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} Charge window change to: {new_start} - {new_end} at {self.base.time_now_str()}")
self.base.log("Inverter {} Updated start and end charge window to {} - {} (old {} - {})".format(self.id, new_start, new_end, old_start, old_end))

if (old_charge_schedule_enable == "off" or have_disabled) and (new_start != new_end):
Expand All @@ -3042,7 +3049,7 @@ def adjust_charge_window(self, charge_start_time, charge_end_time, minutes_now):

# Only notify if it's a real change and not a temporary one
if old_charge_schedule_enable == "off" and self.base.set_inverter_notify:
self.base.call_notify("Predbat: Inverter {} Enabling scheduled charging at {}".format(self.id, self.base.time_now_str()))
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} Enabling scheduled charging at {self.base.time_now_str()}")

self.charge_enable_time = True

Expand Down
22 changes: 17 additions & 5 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -2934,7 +2934,7 @@ def fetch_octopus_sessions(self, axle_sessions=None):
octopus_saving_slots = []
if "octopus_saving_session" in self.args:
saving_rate = 200 # Default rate if not reported
octopoints_per_penny = self.get_arg("octopus_saving_session_octopoints_per_penny", 8) # Default 8 octopoints per found
octopoints_per_penny = self.get_arg("octopus_saving_session_octopoints_per_penny", 8) # Default 8 octopoints per penny

joined_events = []
available_events = []
Expand Down Expand Up @@ -2983,17 +2983,29 @@ def fetch_octopus_sessions(self, axle_sessions=None):
if self._saving_event_conflicts_axle(start_time, end_time, axle_sessions):
self.log("Octopus: Skipping saving event code {} {}-{} - conflicts with an Axle VPP session".format(code, start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M")))
continue
if code: # Join the new Octopus saving event and send an alert
if code: # Join the new Octopus saving event and send an alert if successfully joined
self.log("Octopus: Joining Octopus saving event code {} {}-{} at rate {} p/kWh".format(code, start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"), saving_rate))
entity_id_join = self.get_arg("octopus_saving_session_join", indirect=False)
if entity_id_join:
# Join via selector
self.call_service_wrapper("select/select_option", entity_id=entity_id_join, option=code)
cmd = "select/select_option, entity_id={}, option={}".format(entity_id_join, code)
result = self.call_service_wrapper("select/select_option", entity_id=entity_id_join, option=code, return_response=True)
else:
# Join via octopus event (Bottle Cap Dave)
cmd = "octopus_energy/join_octoplus_saving_session_event, event_code={}, entity_id={}".format(code, entity_id)
# result = self.call_service_wrapper("octopus_energy/join_octoplus_saving_session_event", event_code=code, entity_id=entity_id, return_response=True)
#
# at present bottle cap dave integration doesn't return a response from the saving session so can't check it succeeds
# see https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/issues/1823
# so for now dummy a positive response
result = True
self.call_service_wrapper("octopus_energy/join_octoplus_saving_session_event", event_code=code, entity_id=entity_id)
if self.get_arg("set_event_notify"):
self.call_notify("Predbat: Joined Octopus saving event {}-{}, {} p/kWh".format(start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"), saving_rate))
if result:
if self.get_arg("set_event_notify"):
msg = "Joined Octopus saving event " + start_time.strftime("%a %d/%m %H:%M") + "-" + end_time.strftime("%H:%M") + ", " + str(saving_rate) + " p/kWh"
self.call_notify(f"{self.prefix.capitalize()}: {msg}")
Comment thread
gcoan marked this conversation as resolved.
else:
self.log("Warn: Unable to join Octoplus saving event with command {}, result was {}".format(cmd, result))
Comment thread
gcoan marked this conversation as resolved.
self.octopus_last_joined_try = self.now_utc

# Default saving session rate for when octopoints_per_kwh is not available
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -2505,7 +2505,7 @@ def record_status(self, message, debug="", had_errors=False, notify=False, extra
# Already in error state, do not notify second error in a single run (spam)
pass
else:
self.call_notify("Predbat status change to: " + message + extra)
self.call_notify(f"{self.prefix.capitalize()} status change to: {message} {extra}")
self.previous_status = message
Comment thread
gcoan marked this conversation as resolved.

error_count = self.get_state_wrapper(self.prefix + ".status", attribute="error_count", default=0)
Expand Down
Loading
Loading