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 += "

Unknown chart type

" @@ -2695,49 +2729,20 @@ async def html_charts(self, request): text += "\n" text += get_charts_css() - # Define which chart is active - active_battery = "" - active_power = "" - active_cost = "" - active_rates = "" - active_inday = "" - active_pv = "" - active_pv7 = "" - active_loadml = "" - active_loadmlpower = "" - - if chart == "Battery": - active_battery = "active" - elif chart == "Power": - active_power = "active" - elif chart == "Cost": - active_cost = "active" - elif chart == "Rates": - active_rates = "active" - elif chart == "InDay": - active_inday = "active" - elif chart == "PV": - active_pv = "active" - elif chart == "PV7": - active_pv7 = "active" - elif chart == "LoadML": - active_loadml = "active" - elif chart == "LoadMLPower": - active_loadmlpower = "active" - text += '
' text += "

Charts

" - text += f'Battery' - text += f'Power' - text += f'Cost' - text += f'Rates' - text += f'InDay' - text += f'PV' - text += f'PV7' + text += f'Battery' + text += f'Power' + text += f'Cost' + text += f'Rates' + text += f'InDay' + text += f'PV' + text += f'PV7' + text += f'Savings' # Only show LoadML chart if ML is enabled if self.base.get_arg("load_ml_enable", False): - text += f'LoadML' - text += f'LoadMLPower' + text += f'LoadML' + text += f'LoadMLPower' text += "
" text += '
' @@ -3335,6 +3340,9 @@ async def html_apps_editor(self, request): except Exception as e: file_error = f"Error reading apps.yaml: {str(e)}" + # Calculate MD5 checksum of the content for external change detection + file_checksum = hashlib.md5(apps_yaml_content.encode("utf-8")).hexdigest() if apps_yaml_content else "" + text += get_editor_css() text += """ @@ -3344,10 +3352,10 @@ async def html_apps_editor(self, request):
""" - text += """ + text += f""" -
+