diff --git a/apps/predbat/axle.py b/apps/predbat/axle.py
index 25bc83c50..e24da25b8 100644
--- a/apps/predbat/axle.py
+++ b/apps/predbat/axle.py
@@ -307,6 +307,8 @@ async def run(self, seconds, first):
except Exception as e:
self.log(f"Warn: Axle API: Exception during fetch: {e}")
self.failures_total += 1
+ elif (seconds % 60) == 0: # Every minute, update state to reflect if event is active or not
+ self.publish_axle_event()
return True
diff --git a/apps/predbat/ha.py b/apps/predbat/ha.py
index 79968c817..b31cb5c74 100644
--- a/apps/predbat/ha.py
+++ b/apps/predbat/ha.py
@@ -808,7 +808,13 @@ def call_service(self, service, **kwargs):
data = {}
for key in kwargs:
data[key] = kwargs[key]
- domain, service = service.split("/")
+ if "/" in service:
+ domain, service = service.split("/")
+ elif "." in service:
+ domain, service = service.split(".")
+ else:
+ domain = ""
+
if self.websocket_active:
return self.call_service_websocket_command(domain, service, data)
else:
diff --git a/apps/predbat/tests/test_web_if.py b/apps/predbat/tests/test_web_if.py
index 4b6a396ea..6ace1c43c 100644
--- a/apps/predbat/tests/test_web_if.py
+++ b/apps/predbat/tests/test_web_if.py
@@ -10,6 +10,9 @@
# fmt on
import time
import requests
+import os
+import shutil
+import tempfile
from components import Components
@@ -19,46 +22,317 @@ def run_test_web_if(my_predbat):
"""
failed = 0
print("**** Running web interface test ****\n")
- orig_ha_if = my_predbat.ha_interface
- my_predbat.components = Components(my_predbat)
- my_predbat.components.initialize()
- my_predbat.components.start("ha_interface")
- my_predbat.components.start("db")
- my_predbat.components.start("web")
- ha = my_predbat.ha_interface
-
- # Fetch page from 127.0.0.1:5052
- for page in ["/", "/dash", "/plan", "/config", "/apps", "/charts", "/compare", "/log", "/entity", "/components", "/browse"]:
- print("Fetch page {}".format(page))
- address = "http://127.0.0.1:5052" + page
- res = requests.get(address)
+
+ # Create temp directory and copy apps.yaml
+ original_dir = os.getcwd()
+ temp_dir = tempfile.mkdtemp(prefix="predbat_test_")
+ print(f"Using temporary directory: {temp_dir}")
+
+ try:
+ # Copy apps.yaml to temp directory
+ if os.path.exists("apps.yaml"):
+ shutil.copy("apps.yaml", os.path.join(temp_dir, "apps.yaml"))
+ # Create dummy predbat.log
+ with open(os.path.join(temp_dir, "predbat.log"), "w") as f:
+ f.write("Predbat debug log\n")
+
+ # Change to temp directory
+ os.chdir(temp_dir)
+
+ orig_ha_if = my_predbat.ha_interface
+ my_predbat.components = Components(my_predbat)
+ my_predbat.components.initialize()
+ my_predbat.components.start("ha_interface")
+ my_predbat.components.start("db")
+ my_predbat.components.start("web")
+ ha = my_predbat.ha_interface
+
+ # Define all registered endpoints from web.py
+ # Format: (method, path)
+ all_endpoints = [
+ ("GET", "/"),
+ ("GET", "/plan"),
+ ("GET", "/log"),
+ ("GET", "/apps"),
+ ("POST", "/apps"),
+ ("GET", "/charts"),
+ ("GET", "/config"),
+ ("GET", "/entity"),
+ ("POST", "/entity"),
+ ("POST", "/config"),
+ ("GET", "/dash"),
+ ("POST", "/dash"),
+ ("GET", "/components"),
+ ("GET", "/component_entities"),
+ ("POST", "/component_restart"),
+ ("GET", "/component_config"),
+ ("POST", "/component_config_save"),
+ ("GET", "/debug_yaml"),
+ ("GET", "/debug_log"),
+ ("GET", "/debug_apps"),
+ ("GET", "/debug_plan"),
+ ("GET", "/compare"),
+ ("POST", "/compare"),
+ ("GET", "/apps_editor"),
+ ("POST", "/apps_editor"),
+ ("GET", "/apps_editor_checksum"),
+ ("POST", "/plan_override"),
+ ("POST", "/rate_override"),
+ ("POST", "/restart"),
+ ("GET", "/api/state"),
+ ("GET", "/api/ping"),
+ ("POST", "/api/state"),
+ ("POST", "/api/service"),
+ ("GET", "/api/log"),
+ ("GET", "/api/entities"),
+ ("POST", "/api/login"),
+ ("GET", "/browse"),
+ ("GET", "/download"),
+ ("GET", "/internals"),
+ ("GET", "/api/internals"),
+ ("GET", "/api/internals/download"),
+ ("GET", "/api/status"),
+ ]
+
+ # Track accessed endpoints
+ accessed_endpoints = set()
+
+ # Fetch all GET pages from 127.0.0.1:5052
+ for method, page in all_endpoints:
+ if method != "GET":
+ continue
+ print("Fetch page {}".format(page))
+ address = "http://127.0.0.1:5052" + page
+
+ # Add required parameters for endpoints that need them
+ params = {}
+ if page == "/component_config":
+ params = {"component_name": "web"}
+ elif page == "/download":
+ params = {"file": "apps.yaml"}
+
+ if params:
+ res = requests.get(address, params=params)
+ else:
+ res = requests.get(address)
+
+ # /api/ping returns 500 when Predbat isn't fully initialized (expected in test)
+ # Other endpoints may return 400 for missing optional params, which is fine
+ acceptable_statuses = [200]
+ if page == "/api/ping":
+ acceptable_statuses.append(500)
+ if res.status_code in acceptable_statuses:
+ accessed_endpoints.add(("GET", page))
+ else:
+ print("ERROR: Unexpected status from {} got {} value {}".format(address, res.status_code, res.text))
+ failed = 1
+
+ # Test POST endpoints
+ print("\n**** Testing POST endpoints ****")
+
+ # Test /compare POST
+ print("Test POST /compare")
+ address = "http://127.0.0.1:5052/compare"
+ data = {"run": "run"}
+ res = requests.post(address, data=data)
if res.status_code != 200:
- print("ERROR: Failed to fetch from page {} got status {} value {}".format(address, res.status_code, res.text))
- failed = 1
-
- # Perform a post to /compare page with data for form 'compareform' value 'run'
- print("**** Running test: Fetch page /compare with post")
-
- address = "http://127.0.0.1:5052/compare"
- data = {"run": "run"}
- res = requests.post(address, data=data)
- if res.status_code != 200:
- print("ERROR: Failed to post to pagepage {} got status {} value {}".format(address, res.status_code, res.text))
- failed = 1
- time.sleep(0.1)
- # Get service data
- entity_id = "switch.predbat_compare_active"
- result = ha.get_state(entity_id)
-
- if result != "on":
- print("ERROR: Compare tariffs not triggered - expected {} got {}".format("on", result))
- failed = 1
-
- # Run stop as task as we need to await it
- my_predbat.create_task(my_predbat.components.stop("ha_interface"))
- my_predbat.create_task(my_predbat.components.stop("web"))
- my_predbat.create_task(my_predbat.components.stop("db"))
- time.sleep(0.1)
- my_predbat.components = Components(my_predbat)
- my_predbat.ha_interface = orig_ha_if
+ print("ERROR: Failed to post to /compare got status {} value {}".format(res.status_code, res.text))
+ failed = 1
+ else:
+ accessed_endpoints.add(("POST", "/compare"))
+
+ time.sleep(0.1)
+
+ # Test /api/state POST
+ print("Test POST /api/state")
+ address = "http://127.0.0.1:5052/api/state"
+ data = {"entity_id": "sensor.predbat_status", "state": "Idle"}
+ res = requests.post(address, json=data)
+ # Accept 200 (success) or 500 (entity doesn't exist in test)
+ if res.status_code in [200, 500]:
+ accessed_endpoints.add(("POST", "/api/state"))
+ else:
+ print("ERROR: Unexpected response from /api/state: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /api/service POST
+ print("Test POST /api/service")
+ address = "http://127.0.0.1:5052/api/service"
+ # Correct format: service field should be full service name like "switch.turn_on"
+ data = {"service": "switch/turn_on", "data": {"entity_id": "switch.predbat_active"}}
+ res = requests.post(address, json=data)
+ if res.status_code in [200]: # May fail if service doesn't exist in test
+ accessed_endpoints.add(("POST", "/api/service"))
+ else:
+ print("ERROR: Unexpected response from /api/service: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /config POST
+ print("Test POST /config")
+ address = "http://127.0.0.1:5052/config"
+ data = {"set_read_only": "true"}
+ res = requests.post(address, data=data)
+ if res.status_code in [200]: # Redirects are OK
+ accessed_endpoints.add(("POST", "/config"))
+ else:
+ print("ERROR: Unexpected response from /config: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /dash POST
+ print("Test POST /dash")
+ address = "http://127.0.0.1:5052/dash"
+ data = {"mode": "Monitor"}
+ res = requests.post(address, data=data)
+ if res.status_code in [200]:
+ accessed_endpoints.add(("POST", "/dash"))
+ else:
+ print("ERROR: Unexpected response from /dash: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /entity POST
+ print("Test POST /entity")
+ address = "http://127.0.0.1:5052/entity"
+ data = {"entity_id": "switch.predbat_active", "value": "on"}
+ res = requests.post(address, data=data)
+ if res.status_code in [200]:
+ accessed_endpoints.add(("POST", "/entity"))
+ else:
+ print("ERROR: Unexpected response from /entity: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /apps POST
+ print("Test POST /apps")
+ address = "http://127.0.0.1:5052/apps"
+ data = {"apps_content": "test: value"}
+ res = requests.post(address, data=data)
+ if res.status_code in [200]:
+ accessed_endpoints.add(("POST", "/apps"))
+ else:
+ print("ERROR: Unexpected response from /apps: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /apps_editor POST
+ print("Test POST /apps_editor")
+ address = "http://127.0.0.1:5052/apps_editor"
+ data = {"dummy": "data"}
+ res = requests.post(address, data=data)
+ if res.status_code in [200]:
+ accessed_endpoints.add(("POST", "/apps_editor"))
+ else:
+ print("ERROR: Unexpected response from /apps_editor: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /plan_override POST
+ print("Test POST /plan_override")
+ address = "http://127.0.0.1:5052/plan_override"
+ data = {"time": "00:00", "action": "Clear"}
+ res = requests.post(address, data=data)
+ if res.status_code in [200]:
+ accessed_endpoints.add(("POST", "/plan_override"))
+ else:
+ print("ERROR: Unexpected response from /plan_override: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /rate_override POST
+ print("Test POST /rate_override")
+ address = "http://127.0.0.1:5052/rate_override"
+ data = {"time": "00:00", "rate": "15", "action": "Clear SOC"}
+ res = requests.post(address, data=data)
+ if res.status_code in [200]:
+ accessed_endpoints.add(("POST", "/rate_override"))
+ else:
+ print("ERROR: Unexpected response from /rate_override: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /restart POST
+ print("Test POST /restart")
+ address = "http://127.0.0.1:5052/restart"
+ res = requests.post(address, data={})
+ if res.status_code in [200]:
+ accessed_endpoints.add(("POST", "/restart"))
+ else:
+ print("ERROR: Unexpected response from /restart: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /component_restart POST
+ print("Test POST /component_restart")
+ address = "http://127.0.0.1:5052/component_restart"
+ data = {"component": "db"}
+ res = requests.post(address, data=data)
+ if res.status_code in [200]:
+ accessed_endpoints.add(("POST", "/component_restart"))
+ else:
+ print("ERROR: Unexpected response from /component_restart: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /component_config_save POST
+ print("Test POST /component_config_save")
+ address = "http://127.0.0.1:5052/component_config_save"
+ # Correct format: JSON with component_name, changes, deletions
+ data = {"component_name": "web", "changes": {}, "deletions": []}
+ res = requests.post(address, json=data)
+ if res.status_code in [200]: # May fail if component doesn't support config changes
+ accessed_endpoints.add(("POST", "/component_config_save"))
+ else:
+ print("ERROR: Unexpected response from /component_config_save: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ # Test /api/login POST
+ print("Test POST /api/login")
+ address = "http://127.0.0.1:5052/api/login"
+ data = {"token": "invalid_token"}
+ res = requests.post(address, json=data)
+ if res.status_code in [200]: # Expect auth failure
+ accessed_endpoints.add(("POST", "/api/login"))
+ else:
+ print("ERROR: Unexpected response from /api/login: {} - {}".format(res.status_code, res.text))
+ failed = 1
+
+ print("\n**** Verifying compare tariffs functionality ****")
+ time.sleep(0.1)
+ # Get service data
+ entity_id = "switch.predbat_compare_active"
+ result = ha.get_state(entity_id)
+
+ if result != "on":
+ print("ERROR: Compare tariffs not triggered - expected {} got {}".format("on", result))
+ failed = 1
+
+ # Check endpoint coverage
+ print("\n**** Checking endpoint coverage ****")
+ untested_endpoints = []
+ for endpoint in all_endpoints:
+ if endpoint not in accessed_endpoints:
+ untested_endpoints.append(endpoint)
+
+ if untested_endpoints:
+ print("\nWARNING: The following endpoints were not tested:")
+ for method, path in sorted(untested_endpoints):
+ print(f" {method:6s} {path}")
+ print(f"\nTotal: {len(untested_endpoints)} untested endpoints out of {len(all_endpoints)}")
+ print(f"Coverage: {len(accessed_endpoints)}/{len(all_endpoints)} ({100*len(accessed_endpoints)//len(all_endpoints)}%)")
+ failed = 1
+ else:
+ if failed == 0:
+ print("\nSUCCESS: All endpoints were tested successfully!")
+ else:
+ print("\nFAILED: All endpoints were accessed but some tests failed. Please review the errors above.")
+
+ # Run stop as task as we need to await it
+ my_predbat.create_task(my_predbat.components.stop("ha_interface"))
+ my_predbat.create_task(my_predbat.components.stop("web"))
+ my_predbat.create_task(my_predbat.components.stop("db"))
+ time.sleep(0.1)
+ my_predbat.components = Components(my_predbat)
+ my_predbat.ha_interface = orig_ha_if
+
+ finally:
+ # Clean up: return to original directory and remove temp dir
+ os.chdir(original_dir)
+ try:
+ shutil.rmtree(temp_dir)
+ print(f"\nCleaned up temporary directory: {temp_dir}")
+ except Exception as e:
+ print(f"\nWarning: Failed to clean up temp directory {temp_dir}: {e}")
+
return failed
diff --git a/apps/predbat/web.py b/apps/predbat/web.py
index 155c00e71..911438a41 100644
--- a/apps/predbat/web.py
+++ b/apps/predbat/web.py
@@ -25,6 +25,7 @@
import threading
import io
from io import StringIO
+import hashlib
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import DoubleQuotedScalarString
@@ -127,6 +128,7 @@ async def start(self):
app.router.add_post("/compare", self.html_compare_post)
app.router.add_get("/apps_editor", 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)
@@ -1786,7 +1788,7 @@ async def html_api_post_state(self, request):
JSON API
"""
json_data = await request.json()
- entity_id = json.get("entity_id", None)
+ entity_id = json_data.get("entity_id", None)
state = json_data.get("state", None)
attributes = json_data.get("attributes", {})
if entity_id:
@@ -2679,6 +2681,38 @@ def get_chart(self, chart):
]
text += self.render_chart(series_data, "kW", "ML Load & PV Power with Temperature", now_str, extra_yaxis=secondary_axis)
+ elif chart == "Savings":
+ # Get daily savings data (historical)
+ savings_predbat_hist = history_attribute(self.get_history_wrapper(self.prefix + ".savings_yesterday_predbat", 28, required=False), daily=True, offset_days=-1, pounds=True)
+ savings_pvbat_hist = history_attribute(self.get_history_wrapper(self.prefix + ".savings_yesterday_pvbat", 28, required=False), daily=True, offset_days=-1, pounds=True)
+ cost_yesterday_hist = history_attribute(self.get_history_wrapper(self.prefix + ".cost_yesterday", 28, required=False), daily=True, offset_days=-1, pounds=True)
+
+ # Get cumulative/total savings over time (historical)
+ savings_total_predbat_hist = history_attribute(self.get_history_wrapper(self.prefix + ".savings_total_predbat", 28, required=False), daily=True, pounds=True)
+ savings_total_pvbat_hist = history_attribute(self.get_history_wrapper(self.prefix + ".savings_total_pvbat", 28, required=False), daily=True, pounds=True)
+
+ series_data = [
+ # Daily savings (bars) on primary axis
+ {"name": "Daily Predbat Saving", "data": savings_predbat_hist, "opacity": "1.0", "stroke_width": "2", "chart_type": "bar", "color": "#f5a442", "unit": self.currency_symbols[0]},
+ {"name": "Daily PV/Battery Saving", "data": savings_pvbat_hist, "opacity": "1.0", "stroke_width": "2", "chart_type": "bar", "color": "#3291a8", "unit": self.currency_symbols[0]},
+ {"name": "Daily Actual Cost", "data": cost_yesterday_hist, "opacity": "1.0", "stroke_width": "2", "chart_type": "bar", "color": "#eb2323", "unit": self.currency_symbols[0]},
+ # Cumulative savings (lines) on secondary axis
+ {"name": "Total Predbat Saving", "data": savings_total_predbat_hist, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "smooth", "color": "#f5c43d", "unit": self.currency_symbols[0]},
+ {"name": "Total PV/Battery Saving", "data": savings_total_pvbat_hist, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "smooth", "color": "#15eb8b", "unit": self.currency_symbols[0]},
+ ]
+
+ # Configure secondary axis for cumulative totals
+ secondary_axis = [
+ {
+ "title": f"Total Savings ({self.currency_symbols[0]})",
+ "series_names": ["Total Predbat Saving", "Total PV/Battery Saving"],
+ "decimals": 0,
+ "opposite": True,
+ "labels_formatter": f"return val.toFixed(0);",
+ }
+ ]
+
+ text += self.render_chart(series_data, f"Daily Savings ({self.currency_symbols[0]})", "Cost Savings Analysis", now_str, daily_chart=False, extra_yaxis=secondary_axis)
else:
text += "