From 6dcefb6552e91b702d45c8d59370b9e395bb2805 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Mon, 22 Sep 2025 21:46:12 +0200 Subject: [PATCH 001/132] fix: improve error handling for missing solar forecast data in EVCC API --- src/eos_connect.py | 10 ++++------ src/interfaces/pv_interface.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/eos_connect.py b/src/eos_connect.py index 272da89e..a8620ba7 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -29,13 +29,11 @@ # Check Python version early if sys.version_info < (3, 11): - print( - ( - f"ERROR: Python 3.11 or higher is required. " - f"You are running Python {sys.version_info.major}.{sys.version_info.minor}" - ) + sys.stderr.write( + f"ERROR: Python 3.11 or higher is required. " + f"You are running Python {sys.version_info.major}.{sys.version_info.minor}\n" ) - print("Please upgrade your Python installation.") + sys.stderr.write("Please upgrade your Python installation.\n") sys.exit(1) EOS_TGT_DURATION = 48 diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index 93de1eab..de24ff46 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -857,6 +857,16 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): pv_forecast, ) return pv_forecast + else: + logger.error("[PV-IF] No valid solar forecast data found in EVCC API.") + self.pv_forcast_request_error["error"] = "no_valid_data" + self.pv_forcast_request_error["timestamp"] = datetime.now().isoformat() + self.pv_forcast_request_error["message"] = ( + "No valid solar forecast data found in EVCC API." + ) + self.pv_forcast_request_error["config_entry"] = pv_config_entry + self.pv_forcast_request_error["source"] = "evcc" + return [] def test_output(self): """ From 74e93ad2c1776bfcce10fd0194dae4eb0a56328a Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Tue, 23 Sep 2025 07:18:19 +0200 Subject: [PATCH 002/132] fix: enhance logging for invalid sensor data processing in LoadInterface --- src/interfaces/load_interface.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/interfaces/load_interface.py b/src/interfaces/load_interface.py index a20f2c33..b342200e 100644 --- a/src/interfaces/load_interface.py +++ b/src/interfaces/load_interface.py @@ -250,13 +250,15 @@ def __process_energy_data(self, data, debug_sensor=None): + quote((current_time + timedelta(hours=2)).isoformat()) + ")" ) - logger.error( - "[LOAD-IF] Error processing energy ('%s') data" - + " at index %d: %s (next: %s) - %s %s", - debug_sensor if debug_sensor is not None else "", - i, - data["data"][i], - data["data"][i + 1], + logger.warning( + "[LOAD-IF] Skipping invalid sensor data for '%s' at %s: state '%s' cannot be" + + " processed (%s). " + "This may indicate missing or corrupted data in the database. %s", + debug_sensor if debug_sensor is not None else "unknown sensor", + datetime.fromisoformat(data["data"][i]["last_updated"]).strftime( + "%Y-%m-%d %H:%M:%S" + ), + data["data"][i]["state"], str(e), debug_url if debug_url is not None else "", ) From 50268e222707200722718c6780a8f7a4131ca419 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 27 Sep 2025 10:06:21 +0200 Subject: [PATCH 003/132] fix: improve error handling and logging for EVCC API forecast retrieval --- .github/workflows/docker_develop.yml | 2 +- src/interfaces/pv_interface.py | 109 +++++++++++++++++---------- 2 files changed, 71 insertions(+), 40 deletions(-) diff --git a/.github/workflows/docker_develop.yml b/.github/workflows/docker_develop.yml index e8f26032..58229177 100644 --- a/.github/workflows/docker_develop.yml +++ b/.github/workflows/docker_develop.yml @@ -14,7 +14,7 @@ on: workflow_dispatch: # allows manual triggering of the workflow env: - VERSION_PREFIX: 0.1.23. + VERSION_PREFIX: 0.1.24. VERSION_SUFFIX: -develop jobs: diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index de24ff46..6f94e123 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -178,7 +178,9 @@ def __update_pv_state_loop(self): # special temp forecast if pv config is not given in detail if self.config and self.config[0]: self.temp_forecast_array = self.__get_pv_forecast_akkudoktor_api( - tgt_value="temperature", pv_config_entry=self.config[0], tgt_duration=48 + tgt_value="temperature", + pv_config_entry=self.config[0], + tgt_duration=48, ) else: self.temp_forecast_array = self.__get_default_temperature_forecast() @@ -800,73 +802,102 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): """ if self.config_special.get("url", "") == "": logger.error( - "[PV-IF] No EVCC URL configured for EVCC PV forecast - using default" + "[PV-IF] No EVCC URL configured for EVCC PV forecast - using default PV forecast" ) return self.__get_default_pv_forcast(pv_config_entry.get("power", 200)) url = self.config_special.get("url", "").rstrip("/") + "/api/state" logger.debug("[PV-IF] Fetching PV forecast from EVCC API: %s", url) + + # Network request handling try: response = requests.get(url, timeout=5) response.raise_for_status() - self.pv_forcast_request_error["error"] = None except requests.exceptions.Timeout: - logger.error("[PV-IF] EVCC API request timed out.") - self.pv_forcast_request_error["error"] = "timeout" - self.pv_forcast_request_error["timestamp"] = datetime.now().isoformat() - self.pv_forcast_request_error["message"] = "EVCC API request timed out." - self.pv_forcast_request_error["config_entry"] = pv_config_entry - self.pv_forcast_request_error["source"] = "evcc" - return [] + return self._handle_interface_error( + "timeout", "EVCC API request timed out.", pv_config_entry + ) except requests.exceptions.RequestException as e: - logger.error("[PV-IF] EVCC API request failed: %s", e) - self.pv_forcast_request_error["error"] = "request_failed" - self.pv_forcast_request_error["timestamp"] = datetime.now().isoformat() - self.pv_forcast_request_error["message"] = f"EVCC API request failed: {e}" - self.pv_forcast_request_error["config_entry"] = pv_config_entry - self.pv_forcast_request_error["source"] = "evcc" - return [] - data = response.json() - # print("raw evcc api data: %s", data) - solar_forecast_all = data.get("forecast", []).get("solar", []) - solar_forecast_scale = solar_forecast_all.get("scale", "unknown") - logger.debug( - "[PV-IF] EVCC API solar forecast received with scale: %s", - solar_forecast_scale, - ) - solar_forecast = solar_forecast_all.get("timeseries", []) + return self._handle_interface_error( + "request_failed", f"EVCC API request failed: {e}", pv_config_entry + ) + + # JSON parsing and data extraction + try: + data = response.json() + solar_forecast_all = data.get("forecast", {}).get("solar", {}) + solar_forecast_scale = solar_forecast_all.get("scale", "unknown") + solar_forecast = solar_forecast_all.get("timeseries", []) + + logger.debug( + "[PV-IF] EVCC API solar forecast received with scale: %s", + solar_forecast_scale, + ) + except (ValueError, TypeError) as e: + return self._handle_interface_error( + "invalid_json", f"Invalid JSON response: {e}", pv_config_entry + ) + except (KeyError, AttributeError) as e: + return self._handle_interface_error( + "parsing_error", f"Error parsing forecast data: {e}", pv_config_entry + ) - if solar_forecast and isinstance(solar_forecast, list): - # Extract values from the timeseries format + # Data validation and processing + if not solar_forecast or not isinstance(solar_forecast, list): + return self._handle_interface_error( + "no_valid_data", + "No valid solar forecast data found in EVCC API.", + pv_config_entry, + ) + + try: + # Extract and process forecast values pv_forecast = [item.get("val", 0) for item in solar_forecast[:hours]] - # Ensure the list has exactly 'hours' entries + + # Ensure correct array length if len(pv_forecast) < hours: pv_forecast.extend([0] * (hours - len(pv_forecast))) elif len(pv_forecast) > hours: pv_forecast = pv_forecast[:hours] - # Scale each value according to the solar_forecast_scale (e.g., 0.5 or 0.75) + # Apply scaling factor try: scale_factor = float(solar_forecast_scale) except (TypeError, ValueError): scale_factor = 1.0 + pv_forecast = [val * scale_factor for val in pv_forecast] + + # Clear any previous errors on success + self.pv_forcast_request_error["error"] = None + logger.debug( "[PV-IF] EVCC PV forecast for given evcc pv config (Wh): %s", pv_forecast, ) return pv_forecast - else: - logger.error("[PV-IF] No valid solar forecast data found in EVCC API.") - self.pv_forcast_request_error["error"] = "no_valid_data" - self.pv_forcast_request_error["timestamp"] = datetime.now().isoformat() - self.pv_forcast_request_error["message"] = ( - "No valid solar forecast data found in EVCC API." + + except (TypeError, ValueError, AttributeError) as e: + return self._handle_interface_error( + "processing_error", + f"Error processing forecast values: {e}", + pv_config_entry, ) - self.pv_forcast_request_error["config_entry"] = pv_config_entry - self.pv_forcast_request_error["source"] = "evcc" - return [] + + def _handle_interface_error(self, error_type, message, pv_config_entry): + """ + Centralized error handling for API errors. + """ + logger.error("[PV-IF] %s", message) + self.pv_forcast_request_error.update({ + "error": error_type, + "timestamp": datetime.now().isoformat(), + "message": message, + "config_entry": pv_config_entry, + "source": "evcc" + }) + return [] def test_output(self): """ From b8601c3d8a0e5cc281b96cc8a3b5beec6b3c8ff8 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 27 Sep 2025 10:29:59 +0200 Subject: [PATCH 004/132] fix: enhance error handling and logging in PV forecast_solar retrieval --- src/interfaces/pv_interface.py | 111 +++++++++++++++++---------------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index 6f94e123..7d1e4a3f 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -723,7 +723,6 @@ def __get_pv_forecast_forecast_solar_api(self, pv_config_entry, hours=48): ] # Ensure the list has 24 values, repeating if necessary horizon = (horizon * (24 // len(horizon) + 1))[:24] - # logger.debug("[PV-IF] Horizon values: %s", horizon) else: logger.debug( "[PV-IF] No horizon values provided, using default empty list" @@ -735,66 +734,68 @@ def __get_pv_forecast_forecast_solar_api(self, pv_config_entry, hours=48): f"?horizon={','.join(map(str, horizon))}" ) logger.debug("[PV-IF] Fetching PV forecast from Forecast.Solar API: %s", url) + + # Network request handling try: response = requests.get(url, timeout=5) response.raise_for_status() - self.pv_forcast_request_error["error"] = None except requests.exceptions.Timeout: - logger.error("[PV-IF] Forecast.Solar API request timed out.") - self.pv_forcast_request_error["error"] = "timeout" - self.pv_forcast_request_error["timestamp"] = datetime.now().isoformat() - self.pv_forcast_request_error["message"] = ( - "Forecast.Solar API request timed out." + return self._handle_interface_error( + "timeout", "Forecast.Solar API request timed out.", pv_config_entry, "forecast_solar" ) - self.pv_forcast_request_error["config_entry"] = pv_config_entry - self.pv_forcast_request_error["source"] = "forecast_solar" - return [] except requests.exceptions.RequestException as e: - logger.error("[PV-IF] Forecast.Solar API request failed: %s", e) - # logger.error("[PV-IF] Forecast.Solar API error response: %s", response.json()) - self.pv_forcast_request_error["error"] = "request_failed" - self.pv_forcast_request_error["timestamp"] = datetime.now().isoformat() - self.pv_forcast_request_error["message"] = ( - f"Forecast.Solar API request failed: {e}" + return self._handle_interface_error( + "request_failed", f"Forecast.Solar API request failed: {e}", pv_config_entry, "forecast_solar" + ) + + # JSON parsing and data extraction + try: + data = response.json() + watt_hours_period = data.get("result", {}).get("watt_hours_period", {}) + except (ValueError, TypeError) as e: + return self._handle_interface_error( + "invalid_json", f"Invalid JSON response: {e}", pv_config_entry, "forecast_solar" + ) + except (KeyError, AttributeError) as e: + return self._handle_interface_error( + "parsing_error", f"Error parsing forecast data: {e}", pv_config_entry, "forecast_solar" ) - self.pv_forcast_request_error["config_entry"] = pv_config_entry - self.pv_forcast_request_error["source"] = "forecast_solar" - return [] - data = response.json() - # logger.debug("[PV-IF] Forecast.Solar API response: %s", data) - watt_hours_period = data.get("result", {}).get("watt_hours_period", {}) + # Data validation if not watt_hours_period: - logger.error("[PV-IF] No valid watt_hours_period data found.") - self.pv_forcast_request_error["error"] = "no_valid_data" - self.pv_forcast_request_error["timestamp"] = datetime.now().isoformat() - self.pv_forcast_request_error["message"] = ( - "No valid watt_hours_period data found." + return self._handle_interface_error( + "no_valid_data", "No valid watt_hours_period data found.", pv_config_entry, "forecast_solar" ) - self.pv_forcast_request_error["config_entry"] = pv_config_entry - self.pv_forcast_request_error["source"] = "forecast_solar" - return [] - parsed = [ - (datetime.strptime(ts, "%Y-%m-%d %H:%M:%S"), v) - for ts, v in watt_hours_period.items() - ] - min_time = min(dt for dt, _ in parsed) - # Align to midnight of the first day - midnight = min_time.replace(hour=0, minute=0, second=0, microsecond=0) - # Build list of 48 hourly timestamps - hours = [midnight + timedelta(hours=i) for i in range(48)] - # Build a lookup dict for fast access - lookup = {dt: v for dt, v in parsed} - # Fill the forecast array - forecast_wh = [] - for h in hours: - # Use value if exact hour exists, else 0 - forecast_wh.append(lookup.get(h, 0)) - - pv_forecast = forecast_wh - # logger.debug("[PV-IF] Forecast.Solar PV forecast (Wh): %s", pv_forecast) - return pv_forecast + # Data processing + try: + parsed = [ + (datetime.strptime(ts, "%Y-%m-%d %H:%M:%S"), v) + for ts, v in watt_hours_period.items() + ] + min_time = min(dt for dt, _ in parsed) + # Align to midnight of the first day + midnight = min_time.replace(hour=0, minute=0, second=0, microsecond=0) + # Build list of 48 hourly timestamps + hours_list = [midnight + timedelta(hours=i) for i in range(48)] + # Build a lookup dict for fast access + lookup = {dt: v for dt, v in parsed} + # Fill the forecast array + forecast_wh = [] + for h in hours_list: + # Use value if exact hour exists, else 0 + forecast_wh.append(lookup.get(h, 0)) + + # Clear any previous errors on success + self.pv_forcast_request_error["error"] = None + + pv_forecast = forecast_wh + return pv_forecast + + except (ValueError, TypeError, AttributeError) as e: + return self._handle_interface_error( + "processing_error", f"Error processing forecast data: {e}", pv_config_entry, "forecast_solar" + ) def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): """ @@ -815,7 +816,7 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): response.raise_for_status() except requests.exceptions.Timeout: return self._handle_interface_error( - "timeout", "EVCC API request timed out.", pv_config_entry + "timeout", "EVCC API request timed out.", pv_config_entry, "evcc" ) except requests.exceptions.RequestException as e: return self._handle_interface_error( @@ -885,17 +886,17 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): pv_config_entry, ) - def _handle_interface_error(self, error_type, message, pv_config_entry): + def _handle_interface_error(self, error_type, message, pv_config_entry, source="unknown"): """ - Centralized error handling for API errors. + Centralized error handling for all API errors. """ - logger.error("[PV-IF] %s", message) + logger.error(f"[PV-IF] {message}") self.pv_forcast_request_error.update({ "error": error_type, "timestamp": datetime.now().isoformat(), "message": message, "config_entry": pv_config_entry, - "source": "evcc" + "source": source }) return [] From ad31b5172801be81edaae91c74b6030b155a0ee3 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 27 Sep 2025 10:51:03 +0200 Subject: [PATCH 005/132] fix: enhance error handling and logging for PV forecast akkudoktor and openmeteo lib retrieval --- src/interfaces/pv_interface.py | 389 +++++++++++++++++++-------------- 1 file changed, 220 insertions(+), 169 deletions(-) diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index 7d1e4a3f..b12fcd63 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -317,11 +317,11 @@ def get_pv_forecast(self, config_entry, tgt_duration=24): ) elif self.config_source.get("source") == "openmeteo": # return self.__get_pv_forecast_openmeteo_api(config_entry, tgt_duration) - return self.__get_pv_forecast_openmeteo_lib(config_entry, tgt_duration) + return self.__get_pv_forecast_openmeteo_lib(config_entry) elif self.config_source.get("source") == "openmeteo_local": return self.__get_pv_forecast_openmeteo_api(config_entry, tgt_duration) elif self.config_source.get("source") == "forecast_solar": - return self.__get_pv_forecast_forecast_solar_api(config_entry, tgt_duration) + return self.__get_pv_forecast_forecast_solar_api(config_entry) elif self.config_source.get("source") == "evcc": return self.__get_pv_forecast_evcc_api(config_entry, tgt_duration) elif self.config_source.get("source") == "default": @@ -361,119 +361,129 @@ def __get_pv_forecast_akkudoktor_api( power and temperature values for the specified duration starting from the current hour. """ if pv_config_entry is None: - logger.error( - "[PV-IF][akkudoktor] No PV config entry provided for target: %s", - tgt_value, + return self._handle_interface_error( + "config_error", + f"No PV config entry provided for target: {tgt_value}", + {}, + "akkudoktor", ) - return [] + forecast_request_payload = self.__create_forecast_request(pv_config_entry) - # print(forecast_request_payload) - recv_error = False + + # Network request handling try: response = requests.get(forecast_request_payload, timeout=5) response.raise_for_status() day_values = response.json() day_values = day_values["values"] except requests.exceptions.Timeout: - logger.error( - "[PV-IF][akkudoktor] Request timed out while fetching PV forecast. (%s)", - tgt_value, + return self._handle_interface_error( + "timeout", + f"Akkudoktor API request timed out for {tgt_value}.", + pv_config_entry, + "akkudoktor", ) - recv_error = True except requests.exceptions.RequestException as e: - logger.error( - "[PV-IF][akkudoktor] Request failed while fetching PV forecast (%s): %s", - tgt_value, - e, - ) - recv_error = True - if recv_error: - if tgt_value == "power": - logger.info( - "[PV-IF][akkudoktor] Using default PV forecast with max %s W for %s", - pv_config_entry["power"], - pv_config_entry["name"], + return self._handle_interface_error( + "request_failed", + f"Akkudoktor API request failed for {tgt_value}: {e}", + pv_config_entry, + "akkudoktor", + ) + except (ValueError, TypeError) as e: + return self._handle_interface_error( + "invalid_json", + f"Invalid JSON response for {tgt_value}: {e}", + pv_config_entry, + "akkudoktor", + ) + except (KeyError, AttributeError) as e: + return self._handle_interface_error( + "parsing_error", + f"Error parsing response structure for {tgt_value}: {e}", + pv_config_entry, + "akkudoktor", + ) + + # Data processing + try: + forecast_values = [] + tz = pytz.timezone(self.time_zone) + current_time = tz.localize( + datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + ) + end_time = current_time + timedelta(hours=tgt_duration) + + for forecast_entry in day_values: + for forecast in forecast_entry: + entry_time = datetime.fromisoformat(forecast["datetime"]) + if entry_time.tzinfo is None: + # If datetime is naive, localize it + entry_time = pytz.timezone(self.time_zone).localize(entry_time) + else: + # Convert to configured timezone + entry_time = entry_time.astimezone( + pytz.timezone(self.time_zone) + ) + if current_time <= entry_time < end_time: + value = forecast.get(tgt_value, 0) + # if power is negative, set it to 0 (fixing wrong values from api) + if tgt_value == "power" and value < 0: + value = 0 + forecast_values.append(value) + + # workaround for wrong time points in the forecast from akkudoktor + # remove first entry and append 0 to the end + if forecast_values: + forecast_values.pop(0) + forecast_values.append(0) + + # fix for time changes e.g. western europe then fill or reduce + # the array to target duration + if len(forecast_values) > tgt_duration: + forecast_values = forecast_values[:tgt_duration] + logger.debug( + "[PV-IF][akkudoktor] Day of time change - values reduced to %s for %s", + tgt_duration, + pv_config_entry.get("name", "unknown"), ) - # return a default forecast with 0% at night and 100% at noon - return self.__get_default_pv_forcast(pv_config_entry["power"]) - else: - logger.info( - "[PV-IF][akkudoktor] Using default temperature forecast for %s", - pv_config_entry["name"], + elif len(forecast_values) < tgt_duration: + if forecast_values: + forecast_values.extend( + [forecast_values[-1]] * (tgt_duration - len(forecast_values)) + ) + else: + forecast_values = [0] * tgt_duration + logger.debug( + "[PV-IF][akkudoktor] Day of time change - values extended to %s for %s", + tgt_duration, + pv_config_entry.get("name", "unknown"), ) - # return a default temperature forecast with 0% at night and 100% at noon - return self.__get_default_temperature_forecast() - forecast_values = [] - tz = pytz.timezone(self.time_zone) - current_time = tz.localize( - datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - ) - end_time = current_time + timedelta(hours=tgt_duration) - # logger.debug( - # "[PV-IF] Fetching %s forecast for %s from %s to %s", - # tgt_value, - # pv_config_entry["name"], - # current_time.isoformat(), - # end_time.isoformat(), - # ) + # Clear any previous errors on success + self.pv_forcast_request_error["error"] = None - for forecast_entry in day_values: - for forecast in forecast_entry: - entry_time = datetime.fromisoformat(forecast["datetime"]) - if entry_time.tzinfo is None: - # If datetime is naive, localize it - entry_time = pytz.timezone(self.time_zone).localize(entry_time) - else: - # Convert to configured timezone - entry_time = entry_time.astimezone(pytz.timezone(self.time_zone)) - if current_time <= entry_time < end_time: - value = forecast.get(tgt_value, 0) - - # logger.debug( - # "[PV-IF] Processing forecast entry at %s (%s) for value: %s", - # entry_time.isoformat(), forecast["datetime"], - # value, - # ) - # if power is negative, set it to 0 (fixing wrong values form api) - if tgt_value == "power" and value < 0: - value = 0 - forecast_values.append(value) - # workaround for wrong time points in the forecast from akkudoktor - # remove first entry and append 0 to the end - forecast_values.pop(0) - forecast_values.append(0) - - request_type = "PV forecast" - pv_config_name = "for " + pv_config_entry["name"] - if tgt_value == "temperature": - request_type = "Temperature forecast" - pv_config_name = "" - logger.debug( - "[PV-IF] %s fetched successfully %s", - request_type, - pv_config_name, - ) - # fix for time changes e.g. western europe then fill or reduce the array to 48 values - if len(forecast_values) > tgt_duration: - forecast_values = forecast_values[:tgt_duration] - logger.debug( - "[PV-IF][akkudoktor] Day of time change %s values reduced to %s for %s", - request_type, - tgt_duration, - pv_config_name, + request_type = ( + "PV forecast" if tgt_value == "power" else "Temperature forecast" ) - elif len(forecast_values) < tgt_duration: - forecast_values.extend( - [forecast_values[-1]] * (tgt_duration - len(forecast_values)) + pv_config_name = ( + f"for {pv_config_entry.get('name', 'unknown')}" + if tgt_value == "power" + else "" ) logger.debug( - "[PV-IF][akkudoktor] Day of time change %s values extended to %s for %s", - request_type, - tgt_duration, - pv_config_name, + "[PV-IF] %s fetched successfully %s", request_type, pv_config_name + ) + + return forecast_values + + except (ValueError, TypeError, AttributeError, KeyError) as e: + return self._handle_interface_error( + "processing_error", + f"Error processing {tgt_value} forecast data: {e}", + pv_config_entry, + "akkudoktor", ) - return forecast_values def __get_horizon_elevation(self, sun_azimuth, horizon): @@ -625,17 +635,17 @@ def __get_pv_forecast_openmeteo_api(self, pv_config_entry, hours=48): return pv_forecast - def __get_pv_forecast_openmeteo_lib(self, pv_config_entry, hours=48): + def __get_pv_forecast_openmeteo_lib(self, pv_config_entry): """ Synchronous wrapper for the async OpenMeteoSolarForecast. """ return asyncio.run( - self.__get_pv_forecast_openmeteo_lib_async(pv_config_entry, hours) + self.__get_pv_forecast_openmeteo_lib_async(pv_config_entry) ) - async def __get_pv_forecast_openmeteo_lib_async(self, pv_config_entry, hours=48): + async def __get_pv_forecast_openmeteo_lib_async(self, pv_config_entry): """ - Fetches PV forecast from Forecast.Solar LIB. + Fetches PV forecast from OpenMeteo Solar Forecast library. """ try: async with OpenMeteoSolarForecast( @@ -648,59 +658,78 @@ async def __get_pv_forecast_openmeteo_lib_async(self, pv_config_entry, hours=48) ) as forecast: estimate = await forecast.estimate() - # Build an array of hourly values from now (hour=0) up - # to tomorrow midnight (48 hours) - pv_forecast = [] - # Calculate the number of hours remaining until tomorrow midnight - # Use the current time in the forecast's timezone - # Always use the start of the current hour in the forecast's timezone - now = datetime.now(estimate.timezone).replace( - minute=0, second=0, microsecond=0 - ) - # Find tomorrow's midnight in the forecast's timezone - tomorrow_midnight = (now + timedelta(days=2)).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - hours_until_tomorrow_midnight = int( - (tomorrow_midnight - now).total_seconds() // 3600 - ) - hours_from_today_midnight = int( - ( - now - now.replace(hour=0, minute=0, second=0, microsecond=0) - ).total_seconds() - // 3600 - ) - - for hour in range( - -1 * hours_from_today_midnight, hours_until_tomorrow_midnight - ): - current_hour_energy = 0 - for minute in range(59): - current_hour_energy += estimate.power_production_at_time( - now + timedelta(hours=hour, minutes=minute) - ) - current_hour_energy = round(current_hour_energy / 60, 1) - # time_point = now + timedelta(hours=hour, minutes=0) - # logger.debug("TEST - : %s - %s", current_hour_energy, time_point) - pv_forecast.append(current_hour_energy) - - logger.debug( - "[PV-IF] Openmeteo Lib PV forecast (Wh) (length: %s): %s", - len(pv_forecast), - pv_forecast, - ) - return pv_forecast except (aiohttp.ClientError, ConnectionError) as e: - logger.error("[PV-IF] OpenMeteoLib SolarForecast connection error: %s", e) - # Return a default or empty forecast to avoid crashing the thread - return self.__get_default_pv_forcast(pv_config_entry.get("power", 200)) + return self._handle_interface_error( + "connection_error", + f"OpenMeteo Solar Forecast connection error: {e}", + pv_config_entry, + "openmeteo_lib", + ) except (ValueError, KeyError, AttributeError, TypeError) as e: - logger.error( - "[PV-IF] Unexpected error in OpenMeteoLib SolarForecast: %s", e + return self._handle_interface_error( + "api_error", + f"OpenMeteo Solar Forecast API error: {e}", + pv_config_entry, + "openmeteo_lib", ) - return self.__get_default_pv_forcast(pv_config_entry.get("power", 200)) - def __get_pv_forecast_forecast_solar_api(self, pv_config_entry, hours=48): + # Data processing + try: + # Build an array of hourly values from now (hour=0) up + # to tomorrow midnight (48 hours) + pv_forecast = [] + # Calculate the number of hours remaining until tomorrow midnight + # Use the current time in the forecast's timezone + # Always use the start of the current hour in the forecast's timezone + now = datetime.now(estimate.timezone).replace( + minute=0, second=0, microsecond=0 + ) + # Find tomorrow's midnight in the forecast's timezone + tomorrow_midnight = (now + timedelta(days=2)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + hours_until_tomorrow_midnight = int( + (tomorrow_midnight - now).total_seconds() // 3600 + ) + hours_from_today_midnight = int( + ( + now - now.replace(hour=0, minute=0, second=0, microsecond=0) + ).total_seconds() + // 3600 + ) + + for hour in range( + -1 * hours_from_today_midnight, hours_until_tomorrow_midnight + ): + current_hour_energy = 0 + for minute in range(59): + current_hour_energy += estimate.power_production_at_time( + now + timedelta(hours=hour, minutes=minute) + ) + current_hour_energy = round(current_hour_energy / 60, 1) + # time_point = now + timedelta(hours=hour, minutes=0) + # logger.debug("TEST - : %s - %s", current_hour_energy, time_point) + pv_forecast.append(current_hour_energy) + + # Clear any previous errors on success + self.pv_forcast_request_error["error"] = None + + logger.debug( + "[PV-IF] OpenMeteo Lib PV forecast (Wh) (length: %s): %s", + len(pv_forecast), + pv_forecast, + ) + return pv_forecast + + except (ValueError, TypeError, AttributeError) as e: + return self._handle_interface_error( + "processing_error", + f"Error processing OpenMeteo forecast data: {e}", + pv_config_entry, + "openmeteo_lib", + ) + + def __get_pv_forecast_forecast_solar_api(self, pv_config_entry): """ Fetches PV forecast from Forecast.Solar API. """ @@ -734,37 +763,52 @@ def __get_pv_forecast_forecast_solar_api(self, pv_config_entry, hours=48): f"?horizon={','.join(map(str, horizon))}" ) logger.debug("[PV-IF] Fetching PV forecast from Forecast.Solar API: %s", url) - + # Network request handling try: response = requests.get(url, timeout=5) response.raise_for_status() except requests.exceptions.Timeout: return self._handle_interface_error( - "timeout", "Forecast.Solar API request timed out.", pv_config_entry, "forecast_solar" + "timeout", + "Forecast.Solar API request timed out.", + pv_config_entry, + "forecast_solar", ) except requests.exceptions.RequestException as e: return self._handle_interface_error( - "request_failed", f"Forecast.Solar API request failed: {e}", pv_config_entry, "forecast_solar" + "request_failed", + f"Forecast.Solar API request failed: {e}", + pv_config_entry, + "forecast_solar", ) - + # JSON parsing and data extraction try: data = response.json() watt_hours_period = data.get("result", {}).get("watt_hours_period", {}) except (ValueError, TypeError) as e: return self._handle_interface_error( - "invalid_json", f"Invalid JSON response: {e}", pv_config_entry, "forecast_solar" + "invalid_json", + f"Invalid JSON response: {e}", + pv_config_entry, + "forecast_solar", ) except (KeyError, AttributeError) as e: return self._handle_interface_error( - "parsing_error", f"Error parsing forecast data: {e}", pv_config_entry, "forecast_solar" + "parsing_error", + f"Error parsing forecast data: {e}", + pv_config_entry, + "forecast_solar", ) # Data validation if not watt_hours_period: return self._handle_interface_error( - "no_valid_data", "No valid watt_hours_period data found.", pv_config_entry, "forecast_solar" + "no_valid_data", + "No valid watt_hours_period data found.", + pv_config_entry, + "forecast_solar", ) # Data processing @@ -788,13 +832,16 @@ def __get_pv_forecast_forecast_solar_api(self, pv_config_entry, hours=48): # Clear any previous errors on success self.pv_forcast_request_error["error"] = None - + pv_forecast = forecast_wh return pv_forecast - + except (ValueError, TypeError, AttributeError) as e: return self._handle_interface_error( - "processing_error", f"Error processing forecast data: {e}", pv_config_entry, "forecast_solar" + "processing_error", + f"Error processing forecast data: {e}", + pv_config_entry, + "forecast_solar", ) def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): @@ -886,18 +933,22 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): pv_config_entry, ) - def _handle_interface_error(self, error_type, message, pv_config_entry, source="unknown"): + def _handle_interface_error( + self, error_type, message, pv_config_entry, source="unknown" + ): """ Centralized error handling for all API errors. """ - logger.error(f"[PV-IF] {message}") - self.pv_forcast_request_error.update({ - "error": error_type, - "timestamp": datetime.now().isoformat(), - "message": message, - "config_entry": pv_config_entry, - "source": source - }) + logger.error("[PV-IF] %s", message) + self.pv_forcast_request_error.update( + { + "error": error_type, + "timestamp": datetime.now().isoformat(), + "message": message, + "config_entry": pv_config_entry, + "source": source, + } + ) return [] def test_output(self): From 686f01b6b486149487f810f80790d28c7424df14 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 27 Sep 2025 11:54:02 +0200 Subject: [PATCH 006/132] fix: improve error handling and logging for EOS version retrieval and connection issues - close Connection problem Fixes #72 --- src/interfaces/eos_interface.py | 81 ++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 26 deletions(-) diff --git a/src/interfaces/eos_interface.py b/src/interfaces/eos_interface.py index 16b02dfc..fc0fe483 100644 --- a/src/interfaces/eos_interface.py +++ b/src/interfaces/eos_interface.py @@ -71,7 +71,9 @@ def __init__(self, eos_server, eos_port, timezone): self.last_start_solution = None self.home_appliance_released = False self.home_appliance_start_hour = None - self.eos_version = None + self.eos_version = ( + ">=2025-04-09" # use as default value in case version check fails + ) self.eos_version = self.__retrieve_eos_version() # EOS basic API helper @@ -132,6 +134,7 @@ def eos_set_optimize_request(self, payload, timeout=180): request_url, timeout, ) + response = None # Initialize response variable try: start_time = time.time() response = requests.post( @@ -150,22 +153,33 @@ def eos_set_optimize_request(self, payload, timeout=180): except requests.exceptions.Timeout: logger.error("[EOS] OPTIMIZE Request timed out after %s seconds", timeout) return {"error": "Request timed out - trying again with next run"} - except requests.exceptions.RequestException as e: + except requests.exceptions.ConnectionError as e: logger.error( - "[EOS] OPTIMIZE Request failed: %s - response: %s", e, response + "[EOS] OPTIMIZE Connection error - EOS server not reachable at %s " + + "will try again with next cycle - error: %s", + request_url, + str(e), ) + return { + "error": f"EOS server not reachable at {self.base_url} " + + "will try again with next cycle" + } + except requests.exceptions.RequestException as e: + logger.error("[EOS] OPTIMIZE Request failed: %s", e) + if response is not None: + logger.error("[EOS] OPTIMIZE Response status: %s", response.status_code) + logger.debug( + "[EOS] OPTIMIZE ERROR - response of EOS is:" + + "\n---RESPONSE-------------------------------------------------\n %s" + + "\n------------------------------------------------------------", + response.text, + ) logger.debug( - "[EOS] OPTIMIZE ERROR - payload for the request was:"+ - "\n---REQUEST--------------------------------------------------\n %s"+ - "\n------------------------------------------------------------", - payload + "[EOS] OPTIMIZE ERROR - payload for the request was:" + + "\n---REQUEST--------------------------------------------------\n %s" + + "\n------------------------------------------------------------", + payload, ) - logger.debug( - "[EOS] OPTIMIZE ERROR - response of EOS is:"+ - "\n---RESPONSE-------------------------------------------------\n %s"+ - "\n------------------------------------------------------------", - response.text - ) return {"error": str(e)} def examine_response_to_control_data(self, optimized_response_in): @@ -248,7 +262,8 @@ def examine_response_to_control_data(self, optimized_response_in): else: self.home_appliance_released = False logger.debug( - "[EOS] RESPONSE Home appliance - current hour %s:00 - start hour %s - is Released: %s", + "[EOS] RESPONSE Home appliance - current hour %s:00" + + " - start hour %s - is Released: %s", current_hour, self.home_appliance_start_hour, self.home_appliance_released, @@ -315,7 +330,7 @@ def get_home_appliance_released(self): bool: True if the home appliance is released, False otherwise. """ return self.home_appliance_released - + def get_home_appliance_start_hour(self): """ Get the home appliance start hour. @@ -389,28 +404,42 @@ def __retrieve_eos_version(self): return eos_version else: logger.error( - "[EOS] HTTP error occurred while getting EOS version: %s", e + "[EOS] HTTP error occurred while getting EOS version" + + " - use preset version: %s : %s - Response: %s", + self.eos_version, + e, + e.response.text if e.response else "No response", ) - return None + return self.eos_version # return preset version if error occurs except requests.exceptions.ConnectTimeout: logger.error( - "[EOS] Failed to get EOS version - Server not reachable:"+ - " Connection to %s timed out", + "[EOS] Failed to get EOS version - use preset version: '%s'" + + " - Server not reachable: Connection to %s timed out", + self.eos_version, self.base_url, ) - return "Server not reachable" + return self.eos_version # return preset version if error occurs except requests.exceptions.ConnectionError as e: logger.error( - "[EOS] Failed to get EOS version - Server not reachable: Connection error: %s", + "[EOS] Failed to get EOS version - use preset version: '%s' - Connection error: %s", + self.eos_version, e, ) - return "Server not reachable" + return self.eos_version # return preset version if error occurs except requests.exceptions.RequestException as e: - logger.error("[EOS] Failed to get EOS version - Error: %s", e) - return None + logger.error( + "[EOS] Failed to get EOS version - use preset version: '%s' - Error: %s ", + self.eos_version, + e, + ) + return self.eos_version # return preset version if error occurs except json.JSONDecodeError as e: - logger.error("[EOS] Failed to decode EOS version response: %s", e) - return None + logger.error( + "[EOS] Failed to decode EOS version - use preset version: '%s' - response: %s ", + self.eos_version, + e, + ) + return self.eos_version # return preset version if error occurs def get_eos_version(self): """ From 37874c5f46578fb6ab673afd0cbfa3f491b1a196 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 27 Sep 2025 11:59:59 +0200 Subject: [PATCH 007/132] docs: update installation instructions for EOS Connect and Home Assistant add-ons - touches #72 Connection problem --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4215ec51..2e420a25 100644 --- a/README.md +++ b/README.md @@ -153,12 +153,13 @@ Get up and running with EOS Connect in just a few steps! - **Home Assistant** (recommended for most users) *(Or see [Installation and Running](#installation-and-running) for Docker and local options)* - **An already running instance of [EOS (Energy Optimization System)](https://github.com/Akkudoktor-EOS/EOS)** - EOS Connect acts as a client and requires a reachable EOS server for optimization and control. + EOS Connect acts as a client and requires a reachable EOS server for optimization and control. (Or use the EOS HA addon mentioned in next step.) - **Properly configured EOS for prediction** (see [EOS Configuration Requirements](#eos-configuration-requirements) below) ### 2. Install via Home Assistant Add-on - Add the [ohAnd/ha_addons](https://github.com/ohAnd/ha_addons) repository to your Home Assistant add-on store. +- [if needed] Add the [Duetting/ha_eos_addon](https://github.com/Duetting/ha_eos_addon) (or [thecem/ha_eos_addon](https://github.com/thecem/ha_eos_addon)) repository to your Home Assistant add-on store. - Install both the **EOS Add-on** and the **EOS Connect Add-on**. - Configure both add-ons via the Home Assistant UI. - Start both add-ons. From 58fc2a8c437d75ca6baa082e3ffb3816acfdf642 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 27 Sep 2025 12:26:43 +0200 Subject: [PATCH 008/132] fix: disable debug logging for loadpoints and EVCC state fetching --- src/interfaces/evcc_interface.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/interfaces/evcc_interface.py b/src/interfaces/evcc_interface.py index 7ac0a601..f40a9e88 100644 --- a/src/interfaces/evcc_interface.py +++ b/src/interfaces/evcc_interface.py @@ -308,10 +308,10 @@ def __get_evcc_loadpoints_vehicles(self): return None # check if there are more than one loadpoints loadpoints = data["loadpoints"] if data["loadpoints"] else None - logger.debug( - "[EVCC] got 1st loadpoint: %s", - loadpoints[0].get("title") if loadpoints else "None", - ) + # logger.debug( + # "[EVCC] got 1st loadpoint: %s", + # loadpoints[0].get("title") if loadpoints else "None", + # ) vehicles = data.get("vehicles", {}) @@ -363,10 +363,10 @@ def __get_summerized_charging_state_n_mode(self, collected_states_modes): ): sum_charging_mode = sum_charging_mode + "+now" - logger.debug( - "[EVCC] No charging loadpoints found." - + " Setting charging mode to first connected loadpoint." - ) + # logger.debug( + # "[EVCC] No charging loadpoints found." + # + " Setting charging mode to first connected loadpoint." + # ) # Check if the charging state has changed if sum_charging_state != self.last_known_charging_state: @@ -436,7 +436,7 @@ def __fetch_evcc_state_via_api(self): or None if the request fails or times out. """ evcc_url = self.url + "/api/state" - logger.debug("[EVCC] fetching evcc state with url: %s", evcc_url) + # logger.debug("[EVCC] fetching evcc state with url: %s", evcc_url) try: response = requests.get(evcc_url, timeout=6) response.raise_for_status() From 862bf2801bb2031b84b4da16e68904616e3fb727 Mon Sep 17 00:00:00 2001 From: ohAnd Date: Sat, 27 Sep 2025 12:41:12 +0000 Subject: [PATCH 009/132] [AUTO] Update version to 0.1.24.122-develop Files changed: M src/version.py --- src/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.py b/src/version.py index 8593a7b5..ae25beed 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = '0.1.23.121-develop' +__version__ = '0.1.24.122-develop' From 19bbc31a455ff09c536fc88482b6118c0ca05495 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 27 Sep 2025 21:08:16 +0200 Subject: [PATCH 010/132] fix: implement retry mechanism for price retrieval failures --- src/interfaces/price_interface.py | 48 +++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/interfaces/price_interface.py b/src/interfaces/price_interface.py index 3ef5b413..31ef73c3 100644 --- a/src/interfaces/price_interface.py +++ b/src/interfaces/price_interface.py @@ -104,6 +104,12 @@ def __init__( self.current_feedin = [] self.default_prices = [0.0001] * 48 # if external data are not available + # Add retry mechanism attributes + self.last_successful_prices = [] + self.last_successful_prices_direct = [] + self.consecutive_failures = 0 + self.max_failures = 5 + self.__check_config() # Validate configuration parameters logger.info( "[PRICE-IF] Initialized with" @@ -254,11 +260,43 @@ def __retrieve_prices(self, tgt_duration, start_time=None): ) if not prices: - logger.error( - "[PRICE-IF] No prices retrieved. Using default prices (0,10 ct/kWh)." - ) - prices = self.default_prices - self.current_prices_direct = self.default_prices.copy() + self.consecutive_failures += 1 + + if ( + self.consecutive_failures <= self.max_failures + and self.last_successful_prices + ): + logger.warning( + "[PRICE-IF] No prices retrieved (failure %d/%d). Using last successful prices.", + self.consecutive_failures, + self.max_failures, + ) + prices = self.last_successful_prices[:tgt_duration] + self.current_prices_direct = self.last_successful_prices_direct[ + :tgt_duration + ] + + # Extend if needed + if len(prices) < tgt_duration: + remaining_hours = tgt_duration - len(prices) + prices.extend(self.last_successful_prices[:remaining_hours]) + self.current_prices_direct.extend( + self.last_successful_prices_direct[:remaining_hours] + ) + else: + logger.error( + "[PRICE-IF] No prices retrieved after %d consecutive failures." + + " Using default prices (0.10 ct/kWh).", + self.consecutive_failures, + ) + prices = self.default_prices[:tgt_duration] + self.current_prices_direct = self.default_prices[:tgt_duration].copy() + else: + # Success - reset failure counter and store successful prices + self.consecutive_failures = 0 + self.last_successful_prices = prices.copy() + self.last_successful_prices_direct = self.current_prices_direct.copy() + logger.debug("[PRICE-IF] Prices retrieved successfully. Stored as backup.") return prices From 809d61c72b2d67e6d9130accad817f396ab797cc Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sun, 28 Sep 2025 08:37:55 +0200 Subject: [PATCH 011/132] fix: enhance PV forecast retrieval with timezone-aware processing and error handling - fixing evcc change at 0.208.1 - touches #89 --- src/interfaces/pv_interface.py | 42 +++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index b12fcd63..eea4b971 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -900,14 +900,40 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): ) try: - # Extract and process forecast values - pv_forecast = [item.get("val", 0) for item in solar_forecast[:hours]] - - # Ensure correct array length - if len(pv_forecast) < hours: - pv_forecast.extend([0] * (hours - len(pv_forecast))) - elif len(pv_forecast) > hours: - pv_forecast = pv_forecast[:hours] + # Get timezone-aware current time + tz = pytz.timezone(self.time_zone) + current_time = datetime.now(tz).replace(minute=0, second=0, microsecond=0) + + # Calculate midnight of today + midnight_today = current_time.replace(hour=0, minute=0, second=0, microsecond=0) + + # Create forecast array for 48 hours starting from midnight today + forecast_hours = [midnight_today + timedelta(hours=i) for i in range(hours)] + pv_forecast = [0.0] * hours # Initialize with zeros + + # Create lookup dictionary from API data + forecast_lookup = {} + for item in solar_forecast: + try: + # Parse timestamp from API + ts_str = item.get("ts", "") + if ts_str: + # Parse ISO format timestamp with timezone + ts = datetime.fromisoformat(ts_str.replace('Z', '+00:00')) + # Convert to configured timezone + ts = ts.astimezone(tz) + # Round down to hour + ts = ts.replace(minute=0, second=0, microsecond=0) + forecast_lookup[ts] = item.get("val", 0) + except (ValueError, TypeError) as e: + logger.warning("[PV-IF] Error parsing timestamp %s: %s", ts_str, e) + continue + + # Fill forecast array with values from API or keep zeros for missing hours + for i, hour in enumerate(forecast_hours): + if hour in forecast_lookup: + pv_forecast[i] = forecast_lookup[hour] + # else: keep the initialized zero value # Apply scaling factor try: From f22c095f36fde761a753e39f9d9a27e142787a30 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sun, 28 Sep 2025 08:55:13 +0200 Subject: [PATCH 012/132] fix: update datetime handling for historical energy data to use timezone-aware processing due to deprecated functions --- src/interfaces/load_interface.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/interfaces/load_interface.py b/src/interfaces/load_interface.py index b342200e..726c2afe 100644 --- a/src/interfaces/load_interface.py +++ b/src/interfaces/load_interface.py @@ -4,7 +4,7 @@ load profiles based on historical energy consumption data. """ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import logging from urllib.parse import quote import zoneinfo @@ -118,8 +118,8 @@ def __fetch_historical_energy_data_from_openhab( filtered_data = [ { "state": entry["state"], - "last_updated": datetime.utcfromtimestamp( - entry["time"] / 1000 + "last_updated": datetime.fromtimestamp( + entry["time"] / 1000, tz=timezone.utc ).isoformat(), } for entry in historical_data From d779c0f81d6535a3fc1bb989198358008c3829b2 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sun, 28 Sep 2025 09:56:54 +0200 Subject: [PATCH 013/132] refactor: remove unused load profile creation methods and improve fallback logic for historical data - fix In EOS connect Grafik wird Load nicht angezeigt. Fixes #97 --- src/interfaces/load_interface.py | 251 ++++++++----------------------- 1 file changed, 65 insertions(+), 186 deletions(-) diff --git a/src/interfaces/load_interface.py b/src/interfaces/load_interface.py index 726c2afe..32ab3a75 100644 --- a/src/interfaces/load_interface.py +++ b/src/interfaces/load_interface.py @@ -281,168 +281,6 @@ def __process_energy_data(self, data, debug_sensor=None): return round(total_energy / total_duration, 4) return 0 - # def create_load_profile_openhab_from_last_days(self, tgt_duration, start_time=None): - # """ - # Creates a load profile for energy consumption over the last `tgt_duration` hours. - - # The function calculates the energy consumption for each hour from the current hour - # going back `tgt_duration` hours. It fetches energy data for base load and - # additional loads, processes the data, and sums the energy values. If the total energy - # for an hour is zero, it skips that hour. The resulting load profile is a list of energy - # consumption values for each hour. - - # """ - # logger.info("[LOAD-IF] Creating load profile from openhab ...") - # current_time = datetime.now(self.time_zone).replace( - # minute=0, second=0, microsecond=0 - # ) - # if start_time is None: - # start_time = current_time.replace( - # hour=0, minute=0, second=0, microsecond=0 - # ) - timedelta(hours=tgt_duration) - # end_time = start_time + timedelta(hours=tgt_duration) - # else: - # start_time = current_time - timedelta(hours=tgt_duration) - # end_time = current_time - - # load_profile = [] - # current_hour = start_time - - # while current_hour < end_time: - # next_hour = current_hour + timedelta(hours=1) - # # logger.debug("[LOAD-IF] Fetching data for %s to %s",current_hour, next_hour) - - # energy_data = self.__fetch_historical_energy_data_from_openhab( - # self.load_sensor, current_hour, next_hour - # ) - # energy = self.__process_energy_data(energy_data) - # if energy == 0: - # logger.warning( - # "[LOAD-IF] load = 0 ... Energy for %s: %5.1f Wh", - # current_hour, - # round(energy, 1), - # ) - # # current_hour += timedelta(hours=1) - # # continue - - # energy_sum = abs(energy) - - # load_profile.append(energy_sum) - # logger.debug("[LOAD-IF] Energy for %s: %s", current_hour, energy_sum) - - # current_hour += timedelta(hours=1) - # logger.info("[LOAD-IF] Load profile created successfully.") - # return load_profile - - # def create_load_profile_homeassistant_from_last_days( - # self, tgt_duration, start_time=None - # ): - # """ - # Creates a load profile for energy consumption over the last `tgt_duration` hours. - - # This function calculates the energy consumption for each hour from the current hour - # going back `tgt_duration` hours. It fetches energy data from Home Assistant, - # processes the data, and sums the energy values. If the total energy for an hour is zero, - # it skips that hour. The resulting load profile is a list of energy consumption values - # for each hour. - - # Args: - # tgt_duration (int): The target duration in hours for which the load profile is needed. - # start_time (datetime, optional): The start time for fetching the load profile. - # Defaults to None. - - # Returns: - # list: A list of energy consumption values for the specified duration. - # """ - # logger.info("[LOAD-IF] Creating load profile from Home Assistant ...") - # current_time = datetime.now(self.time_zone).replace( - # minute=0, second=0, microsecond=0 - # ) - # if start_time is None: - # start_time = current_time.replace( - # hour=0, minute=0, second=0, microsecond=0 - # ) - timedelta(hours=tgt_duration) - # end_time = start_time + timedelta(hours=tgt_duration) - # else: - # start_time = current_time - timedelta(hours=tgt_duration) - # end_time = current_time - - # load_profile = [] - # current_hour = start_time - - # # current_hour = datetime.strptime("18.03.2025 11:00", "%d.%m.%Y %H:%M") - # # end_time = current_hour + timedelta(hours=tgt_duration) - - # # check car load data for W or kW - # car_load_data = self.__fetch_historical_energy_data_from_homeassistant( - # self.car_charge_load_sensor, current_hour, end_time - # ) - # # check for max value in car_load_data - # max_car_load = 0 - # car_load_unit_factor = 1 - # for data_entry in car_load_data: - # try: - # float(data_entry["state"]) - # except ValueError: - # continue - # max_car_load = max(max_car_load, float(data_entry["state"])) - # if 0 < max_car_load < 23: - # max_car_load = max_car_load * 1000 - # car_load_unit_factor = 1000 - # logger.debug("[LOAD-IF] Max car load: %s W", round(max_car_load, 0)) - - # while current_hour < end_time: - # next_hour = current_hour + timedelta(hours=1) - # # logger.debug("[LOAD-IF] Fetching data for %s to %s", current_hour, next_hour) - # energy_data = self.__fetch_historical_energy_data_from_homeassistant( - # self.load_sensor, current_hour, next_hour - # ) - # car_load_data = self.__fetch_historical_energy_data_from_homeassistant( - # self.car_charge_load_sensor, current_hour, next_hour - # ) - - # # print(f'HA Energy data: {car_load_data}') - # energy = abs(self.__process_energy_data({"data": energy_data})) - # car_load_energy = abs( - # self.__process_energy_data({"data": car_load_data}) * car_load_unit_factor - # ) - # car_load_energy = max(car_load_energy, 0) # prevent negative values - # # print(f'HA Energy Orig: {energy}') - # # print(f'HA Car load energy: {car_load_energy}') - # energy = energy - car_load_energy - # if energy < 0: - # logger.error( - # "[LOAD-IF] DATA ERROR Energy for %s: %5.1f Wh (car load: %5.1f Wh)", - # current_hour, - # round(energy, 1), - # round(car_load_energy, 1), - # ) - # # print(f'HA Energy Final: {energy}') - # if energy == 0: - # logger.warning( - # "[LOAD-IF] load = 0 ... Energy for %s: %5.1f Wh (car load: %5.1f Wh)", - # current_hour, - # round(energy, 1), - # round(car_load_energy, 1), - # ) - # # current_hour += timedelta(hours=1) - # # continue - - # energy_sum = energy - - # load_profile.append(energy_sum) - # logger.debug( - # "[LOAD-IF] Energy for %s: %5.1f Wh (car load: %5.1f Wh)", - # current_hour, - # round(energy, 1), - # round(car_load_energy, 1), - # ) - # # logger.debug("[LOAD-IF] Energy for %s: %s", current_hour, round(energy_sum,1)) - - # current_hour += timedelta(hours=1) - # logger.info("[LOAD-IF] Load profile created successfully.") - # return load_profile - def __get_additional_load_list_from_to(self, item, start_time, end_time): """ Retrieves and processes additional load data within a specified time range. @@ -639,7 +477,7 @@ def get_load_profile_for_day(self, start_time, end_time): debug_url, ) if energy == 0: - logger.warning( + logger.debug( "[LOAD-IF] load = 0 ... Energy for %s: %5.1f Wh" + " (sum add energy %5.1f Wh - car load: %5.1f Wh)", current_hour, @@ -647,12 +485,8 @@ def get_load_profile_for_day(self, start_time, end_time): round(sum_controlable_energy_load, 1), round(car_load_energy, 1), ) - # current_hour += timedelta(hours=1) - # continue - - energy_sum = energy - load_profile.append(energy_sum) + load_profile.append(energy) logger.debug( "[LOAD-IF] Energy for %s: %5.1f Wh (sum add energy %5.1f Wh - car load: %5.1f Wh)", current_hour, @@ -744,6 +578,43 @@ def __create_load_profile_weekdays(self): ) else: load_profile.append(value) + + # Check if load profile contains useful values (not all zeros) + if not load_profile or all(value == 0 for value in load_profile): + logger.info( + "[LOAD-IF] No historical data available from 7 and 14 days ago. " + + "This is normal for new installations - using yesterday's data as fallback. " + + "Load profiles will improve automatically as the system collects" + + " more historical data." + ) + # Get yesterday's load profile + yesterday = now.replace( + hour=0, minute=0, second=0, microsecond=0 + ) - timedelta(days=1) + yesterday_profile = self.get_load_profile_for_day( + yesterday, yesterday + timedelta(days=1) + ) + + # Double yesterday's profile to create 48 hours + if yesterday_profile and not all(value == 0 for value in yesterday_profile): + load_profile = yesterday_profile + yesterday_profile + logger.info( + "[LOAD-IF] Using yesterday's consumption pattern doubled" + + " for 48-hour forecast" + ) + else: + logger.info( + "[LOAD-IF] No recent consumption data available yet. " + + "Using built-in default profile as temporary fallback. " + + "This will automatically switch to real data as your system runs" + + " and collects sensor data." + ) + load_profile = self._get_default_profile() + logger.info( + "[LOAD-IF] Temporary default profile active -" + + " will improve with collected data" + ) + return load_profile def get_load_profile(self, tgt_duration, start_time=None): @@ -764,7 +635,32 @@ def get_load_profile(self, tgt_duration, start_time=None): Returns: list: A list of energy consumption values for the specified duration. """ - default_profile = [ + if self.src == "default": + logger.info("[LOAD-IF] Using load source default") + return self._get_default_profile()[:tgt_duration] + if self.src in ("openhab", "homeassistant"): + if self.load_sensor == "" or self.load_sensor is None: + logger.error( + "[LOAD-IF] Load sensor not configured for source '%s'. Using default.", + self.src, + ) + return self._get_default_profile()[:tgt_duration] + return self.__create_load_profile_weekdays() + + logger.error( + "[LOAD-IF] Load source '%s' currently not supported. Using default.", + self.src, + ) + return self._get_default_profile()[:tgt_duration] + + def _get_default_profile(self): + """ + Returns the default load profile that can be reused across methods. + + Returns: + list: A list of 48 default energy consumption values. + """ + return [ 200.0, # 0:00 - 1:00 -- day 1 200.0, # 1:00 - 2:00 200.0, # 2:00 - 3:00 @@ -814,20 +710,3 @@ def get_load_profile(self, tgt_duration, start_time=None): 300.0, # 22:00 - 23:00 200.0, # 23:00 - 0:00 ] - if self.src == "default": - logger.info("[LOAD-IF] Using load source default") - return default_profile[:tgt_duration] - if self.src in ("openhab", "homeassistant"): - if self.load_sensor == "" or self.load_sensor is None: - logger.error( - "[LOAD-IF] Load sensor not configured for source '%s'. Using default.", - self.src, - ) - return default_profile[:tgt_duration] - return self.__create_load_profile_weekdays() - - logger.error( - "[LOAD-IF] Load source '%s' currently not supported. Using default.", - self.src, - ) - return default_profile[:tgt_duration] From 7f6e4c984a03057bb64f0f16c3ae3b2d034da039 Mon Sep 17 00:00:00 2001 From: ohAnd Date: Sun, 28 Sep 2025 07:57:19 +0000 Subject: [PATCH 014/132] [AUTO] Update version to 0.1.24.123-develop Files changed: M src/version.py --- src/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.py b/src/version.py index ae25beed..79c12bfa 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = '0.1.24.122-develop' +__version__ = '0.1.24.123-develop' From e7b38fe1c3a87dce6df1948005cc75765627efdb Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sun, 28 Sep 2025 21:36:00 +0200 Subject: [PATCH 015/132] refactor: remove pvlib dependency and implement custom solar position and angle of incidence calculations --- requirements.txt | 2 +- src/interfaces/pv_interface.py | 171 ++++++++++++++++++++++++--------- 2 files changed, 129 insertions(+), 44 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1fba3c0a..3dd161af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,6 @@ pyyaml>=6.0 pytz>=2025.1 ruamel.yaml>=0.18.10 paho-mqtt>=2.1.0 -pvlib>=0.13.0 +# pvlib>=0.13.0 open-meteo-solar-forecast>=0.1.22 psutil>=7.0.0 \ No newline at end of file diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index eea4b971..5bb292cb 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -27,10 +27,10 @@ import logging import time import asyncio +import math import aiohttp import pytz import requests -import pvlib import pandas as pd import numpy as np from open_meteo_solar_forecast import OpenMeteoSolarForecast @@ -560,41 +560,37 @@ def __get_pv_forecast_openmeteo_api(self, pv_config_entry, hours=48): radiation = data["hourly"]["shortwave_radiation"][:hours] # W/m² cloudcover = data["hourly"]["cloudcover"][:hours] # % - # logger.debug( - # "[PV-IF] Open-Meteo radiation: %s", radiation - # ) - - # Prepare time index for pvlib - times = pd.date_range( - start=data["hourly"]["time"][0], periods=hours, freq="h", tz=timezone + # Prepare time index - create datetime objects instead of pandas DatetimeIndex + start_time = datetime.fromisoformat( + data["hourly"]["time"][0].replace("Z", "+00:00") ) + times = [start_time + timedelta(hours=i) for i in range(hours)] - # Get sun position - solpos = pvlib.solarposition.get_solarposition(times, latitude, longitude) - logger.debug("[PV-IF] Open-Meteo solar position calculated solpos - %s", solpos) - - # Calculate angle of incidence (AOI) - aoi = pvlib.irradiance.aoi( - surface_tilt=tilt, - surface_azimuth=azimuth, - solar_zenith=solpos["apparent_zenith"], - solar_azimuth=solpos["azimuth"], + # Get sun position using our custom function + solpos = self._solar_position(times, latitude, longitude) + logger.debug( + "[PV-IF] Open-Meteo solar position calculated - first entry: %s", solpos[0] ) # Calculate PV forecast pv_forecast = [] - for rad, cc, angle, sun_az, sun_el in zip( - radiation, - cloudcover, - aoi, - solpos["azimuth"], - 90 - solpos["apparent_zenith"], - ): + for i, (rad, cc) in enumerate(zip(radiation, cloudcover)): + # Calculate angle of incidence (AOI) using our custom function + aoi = self._angle_of_incidence( + surface_tilt=tilt, + surface_azimuth=azimuth, + solar_zenith=solpos[i]["apparent_zenith"], + solar_azimuth=solpos[i]["azimuth"], + ) + + sun_az = solpos[i]["azimuth"] + sun_el = 90 - solpos[i]["apparent_zenith"] + # Adjust radiation for cloud cover eff_rad = rad * (1 - cc / 100) + rad * cloud_factor * (cc / 100) # Project radiation onto panel - projection = max(np.cos(np.radians(angle)), 0) + projection = max(math.cos(math.radians(aoi)), 0) # Adjust for panel efficiency (22,5% is a common value) eff_rad_panel = eff_rad * projection * 0.225 @@ -602,12 +598,6 @@ def __get_pv_forecast_openmeteo_api(self, pv_config_entry, hours=48): # --- Horizon check --- horizon_elev = self.__get_horizon_elevation(sun_az, horizon) if sun_el < horizon_elev: - # logger.debug( - # "[PV-IF] Sun elevation %s° is below horizon elevation %s° at azimuth %s°", - # sun_el, - # horizon_elev, - # sun_az, - # ) eff_rad_panel = ( eff_rad_panel * 0.25 ) # Sun is behind local horizon - 25% of radiation @@ -618,12 +608,6 @@ def __get_pv_forecast_openmeteo_api(self, pv_config_entry, hours=48): ) # Assuming 220 W/m² as average panel efficiency for area estimation energy_wh = max(0, energy_wh) # Ensure no negative values - # logger.debug( - # "[PV-IF] Radiation: %s W/m², Cloud cover: %s%%, AOI: %s°, " - # "Sun azimuth: %s°, Sun elevation: %s° -> Energy output: %s Wh", - # round(rad, 2), round(cc, 2), round(angle, 2), - # round(sun_az, 2), round(sun_el, 2), round(energy_wh, 2) - # ) pv_forecast.append(round(energy_wh, 1)) pv_forecast = [float(x) for x in pv_forecast] @@ -639,9 +623,7 @@ def __get_pv_forecast_openmeteo_lib(self, pv_config_entry): """ Synchronous wrapper for the async OpenMeteoSolarForecast. """ - return asyncio.run( - self.__get_pv_forecast_openmeteo_lib_async(pv_config_entry) - ) + return asyncio.run(self.__get_pv_forecast_openmeteo_lib_async(pv_config_entry)) async def __get_pv_forecast_openmeteo_lib_async(self, pv_config_entry): """ @@ -905,7 +887,9 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): current_time = datetime.now(tz).replace(minute=0, second=0, microsecond=0) # Calculate midnight of today - midnight_today = current_time.replace(hour=0, minute=0, second=0, microsecond=0) + midnight_today = current_time.replace( + hour=0, minute=0, second=0, microsecond=0 + ) # Create forecast array for 48 hours starting from midnight today forecast_hours = [midnight_today + timedelta(hours=i) for i in range(hours)] @@ -919,7 +903,7 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): ts_str = item.get("ts", "") if ts_str: # Parse ISO format timestamp with timezone - ts = datetime.fromisoformat(ts_str.replace('Z', '+00:00')) + ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) # Convert to configured timezone ts = ts.astimezone(tz) # Round down to hour @@ -1024,3 +1008,104 @@ def test_output(self): logger.info( "[PV-IF] PV forecast test output saved to pv_forecast_test_output_2.csv" ) + + # Add these helper functions to replace pvlib functionality + def _solar_position(self, times, latitude, longitude): + """ + Calculate solar position (zenith and azimuth) for given times and location. + Simplified version of pvlib.solarposition.get_solarposition + """ + lat_rad = math.radians(latitude) + results = [] + + for time in times: + # Convert to Julian day number + a = (14 - time.month) // 12 + y = time.year - a + m = time.month + 12 * a - 3 + jdn = ( + time.day + + (153 * m + 2) // 5 + + 365 * y + + y // 4 + - y // 100 + + y // 400 + - 32045 + ) + + # Add fraction of day + hour_fraction = (time.hour + time.minute / 60 + time.second / 3600) / 24 + jd = jdn + hour_fraction - 0.5 + + # Number of days since J2000.0 + n = jd - 2451545.0 + + # Mean longitude of sun + L = (280.460 + 0.9856474 * n) % 360 + + # Mean anomaly of sun + g = math.radians((357.528 + 0.9856003 * n) % 360) + + # Ecliptic longitude of sun + lambda_sun = math.radians(L + 1.915 * math.sin(g) + 0.020 * math.sin(2 * g)) + + # Obliquity of ecliptic + epsilon = math.radians(23.439 - 0.0000004 * n) + + # Right ascension and declination + alpha = math.atan2( + math.cos(epsilon) * math.sin(lambda_sun), math.cos(lambda_sun) + ) + delta = math.asin(math.sin(epsilon) * math.sin(lambda_sun)) + + # Greenwich mean sidereal time + gmst = (18.697375 + 24.06570982441908 * n) % 24 + + # Local sidereal time + lst = gmst + longitude / 15 + + # Hour angle + h = math.radians(15 * (lst - math.degrees(alpha) / 15)) + + # Solar zenith and azimuth + sin_alt = math.sin(lat_rad) * math.sin(delta) + math.cos( + lat_rad + ) * math.cos(delta) * math.cos(h) + altitude = math.asin(max(-1, min(1, sin_alt))) + zenith = math.degrees(math.pi / 2 - altitude) + + cos_az = (math.sin(delta) - math.sin(altitude) * math.sin(lat_rad)) / ( + math.cos(altitude) * math.cos(lat_rad) + ) + azimuth = math.degrees(math.acos(max(-1, min(1, cos_az)))) + + if math.sin(h) > 0: + azimuth = 360 - azimuth + + results.append({"apparent_zenith": zenith, "azimuth": azimuth}) + + return results + + def _angle_of_incidence( + self, surface_tilt, surface_azimuth, solar_zenith, solar_azimuth + ): + """ + Calculate angle of incidence between sun and tilted surface. + Simplified version of pvlib.irradiance.aoi + """ + # Convert to radians + surf_tilt_rad = math.radians(surface_tilt) + surf_az_rad = math.radians(surface_azimuth) + sun_zen_rad = math.radians(solar_zenith) + sun_az_rad = math.radians(solar_azimuth) + + # Calculate angle of incidence + cos_aoi = math.sin(sun_zen_rad) * math.sin(surf_tilt_rad) * math.cos( + sun_az_rad - surf_az_rad + ) + math.cos(sun_zen_rad) * math.cos(surf_tilt_rad) + + # Ensure value is within valid range for acos + cos_aoi = max(-1, min(1, cos_aoi)) + aoi = math.degrees(math.acos(cos_aoi)) + + return aoi From ef4e524e415d83ed7bd6756ad8b8e9e9a14be306 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Tue, 30 Sep 2025 07:30:13 +0200 Subject: [PATCH 016/132] fix: enhance temperature forecast retrieval with error handling and default fallback --- src/interfaces/pv_interface.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index 5bb292cb..1c7122f4 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -177,11 +177,23 @@ def __update_pv_state_loop(self): ) # special temp forecast if pv config is not given in detail if self.config and self.config[0]: - self.temp_forecast_array = self.__get_pv_forecast_akkudoktor_api( + temp_result = self.__get_pv_forecast_akkudoktor_api( tgt_value="temperature", pv_config_entry=self.config[0], tgt_duration=48, ) + if not temp_result: # If empty array or None due to API error + logger.warning( + "[PV-IF] Temperature forecast API failed - using default" + + " temperature forecast" + ) + self.temp_forecast_array = self.__get_default_temperature_forecast() + else: + self.temp_forecast_array = temp_result + # logger.debug( + # "[PV-IF] Temperature forecast updated with %d values", + # len(temp_result), + # ) else: self.temp_forecast_array = self.__get_default_temperature_forecast() logger.info("[PV-IF] PV and Temperature updated") From c720f64a7c2d06542a306758432b56a04f7f7266 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:23:59 +0200 Subject: [PATCH 017/132] feat: add Solcast integration for high-precision solar forecasting and update configuration documentation - fix feature request: solcast data from home assistant Fixes #100 --- README.md | 5 +- src/CONFIG_README.md | 88 +++++++- src/config.py | 17 +- src/interfaces/pv_interface.py | 360 +++++++++++++++++++++++++++++---- 4 files changed, 422 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 2e420a25..b4a64d5b 100644 --- a/README.md +++ b/README.md @@ -294,11 +294,14 @@ EOS Connect supports multiple sources for solar (PV) production forecasts. You c - **Forecast.Solar** Connects to the [Forecast.Solar API](https://doc.forecast.solar/api) for detailed PV production forecasts. +- **Solcast** + Integrates with the [Solcast API](https://solcast.com/) for high-precision solar forecasting using satellite data and machine learning models. Requires creating a rooftop site in your Solcast account and using the resource ID (not location coordinates). Free Solcast API key provides up to 10 API calls per day. **Note: EOS Connect automatically uses extended update intervals (2.5 hours) when Solcast is selected to stay within rate limits.** + - **EVCC** Retrieves PV forecasts directly from an existing [EVCC](https://evcc.io/) installation via its API. This option leverages EVCC's built-in solar forecast capabilities, including its automatic scaling feature that adjusts forecasts based on your actual historical PV production data for improved accuracy. #### Energy Price Forecast -Energy price forecasts are retrieved from the chosen source (TIBBER or AKKUDOKTOR API). **Note**: Prices for tomorrow are available earliest at 1 PM. Until then, today's prices are used to feed the model. +Energy price forecasts are retrieved from the chosen source (e.g. tibber, Akkudoktor, Smartenergy, ...). **Note**: Prices for tomorrow are available earliest at 1 PM. Until then, today's prices are used to feed the model. --- diff --git a/src/CONFIG_README.md b/src/CONFIG_README.md index 4b754831..b6b2b43d 100644 --- a/src/CONFIG_README.md +++ b/src/CONFIG_README.md @@ -20,6 +20,7 @@ - [Full Config Example (will be generated at first startup)](#full-config-example-will-be-generated-at-first-startup) - [Minimal possible Config Example](#minimal-possible-config-example) - [Example: Using EVCC for PV Forecasts](#example-using-evcc-for-pv-forecasts) + - [Example: Using Solcast for PV Forecasts](#example-using-solcast-for-pv-forecasts) # Configuration @@ -176,6 +177,7 @@ This section contains two subsections: - `openmeteo` - https://open-meteo.com/en/docs - uses the [open-meteo-solar-forecast](https://github.com/rany2/open-meteo-solar-forecast) (no horizon possible by the lib at this time) - `openmeteo_local` - https://open-meteo.com/en/docs - gathering radiation and cloudcover data and calculating locally with an own model - still in dev to improve the calculation - `forecast_solar` - https://doc.forecast.solar/api - direct request and results +- `solcast` - https://solcast.com/ - high-precision solar forecasting using satellite data and machine learning models. Requires creating a rooftop site in your Solcast account and using the resource_id (not location coordinates). Free API key provides up to 10 calls per day. - `evcc` - retrieves forecasts from an existing EVCC installation via API - requires EVCC section to be configured default is uses akkudoktor @@ -185,7 +187,8 @@ default is uses akkudoktor ```yaml pv_forecast_source: - source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, default (default uses akkudoktor) + source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default (default uses akkudoktor) + api_key: "" # API key for Solcast (required only when source is 'solcast') pv_forecast: - name: myPvInstallation1 # User-defined identifier for the PV installation, must be unique if you use multiple installations lat: 47.5 # Latitude for PV forecast @ Akkudoktor API @@ -196,6 +199,7 @@ pv_forecast: powerInverter: 5000 # Power Inverter for PV forecast @ Akkudoktor API inverterEfficiency: 0.9 # Inverter Efficiency for PV forecast @ Akkudoktor API horizon: 10,20,10,15 # Horizon to calculate shading, up to 360 values to describe the shading situation for your PV. + resource_id: "" # Resource ID for Solcast (required only when source is 'solcast') ``` #### Parameters @@ -234,6 +238,26 @@ pv_forecast: - 10,20,10,15 – 0–90° is shadowed if sun elevation is below 10°, and so on. - 0,0,0,0,0,0,0,0,50,70,0,0,0,0,0,0,0,0 – 18 entries → 20° steps; here, 180°–200° requires 50° of sun elevation, otherwise the panel is shadowed. +- **`resource_id`**: + (Solcast only) The resource ID from your Solcast rooftop site configuration. Required when using Solcast as the PV forecast source. Not used by other providers. + + +**Special Configuration for Solcast:** + +When using `source: solcast`, the configuration requirements are different: + +- **`api_key`** (in `pv_forecast_source`): Required. Your Solcast API key obtained from your Solcast account. +- **`resource_id`** (in each `pv_forecast` entry): Required. The resource ID from your Solcast rooftop site configuration. +- **Location parameters for temperature forecasts**: While `azimuth`, `tilt`, and `horizon` are configured in your Solcast dashboard and ignored by EOS Connect, **`lat` and `lon` are still required** for fetching temperature forecasts that EOS needs for accurate optimization calculations. +- **`power`, `powerInverter`, `inverterEfficiency`**: Still required for system scaling and efficiency calculations. + +**Setting up Solcast:** +1. Create a free account at [solcast.com](https://solcast.com/) +2. Configure a "Rooftop Site" with your PV system details (location, tilt, azimuth, capacity) +3. Copy the Resource ID from your rooftop site +4. Get your API key from the account settings +5. Use these values in your EOS Connect configuration (including lat/lon for temperature forecasts) + --- ### Inverter Configuration @@ -375,9 +399,10 @@ battery: charging_curve_enabled: true # enable dynamic charging curve for battery # List of PV forecast source configuration pv_forecast_source: - source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, forecast_solar, default (default uses akkudoktor) + source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default (default uses akkudoktor) + api_key: "" # API key for Solcast (required only when source is 'solcast') # List of PV forecast configurations. Add multiple entries as needed. -# See Akkudoktor API (https://api.akkudoktor.net/#/pv%20generation%20calculation/getForecast) for more details. +# See Akkudtor API (https://api.akkudoktor.net/#/pv%20generation%20calculation/getForecast) for more details. pv_forecast: - name: myPvInstallation1 # User-defined identifier for the PV installation, have to be unique if you use more installations lat: 47.5 # Latitude for PV forecast @ Akkudoktor API @@ -388,6 +413,7 @@ pv_forecast: powerInverter: 5000 # Power Inverter for PV forecast @ Akkudoktor API inverterEfficiency: 0.9 # Inverter Efficiency for PV forecast @ Akkudoktor API horizon: 10,20,10,15 # Horizon to calculate shading up to 360 values to describe shading situation for your PV. + resource_id: "" # Resource ID for Solcast (required only when source is 'solcast') # Inverter configuration inverter: type: default # Type of inverter - fronius_gen24, fronius_gen24_legacy, evcc, default (default will disable inverter control - only displaying the target state) - preset: default @@ -445,9 +471,10 @@ battery: charging_curve_enabled: true # enable dynamic charging curve for battery # List of PV forecast source configuration pv_forecast_source: - source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, forecast_solar, default (default uses akkudoktor) + source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default (default uses akkudoktor) + api_key: "" # API key for Solcast (required only when source is 'solcast') # List of PV forecast configurations. Add multiple entries as needed. -# See Akkudoktor API (https://api.akkudoktor.net/#/pv%20generation%20calculation/getForecast) for more details. +# See Akkudtor API (https://api.akkudoktor.net/#/pv%20generation%20calculation/getForecast) for more details. pv_forecast: - name: myPvInstallation1 # User-defined identifier for the PV installation, have to be unique if you use more installations lat: 47.5 # Latitude for PV forecast @ Akkudoktor API @@ -482,7 +509,11 @@ When using EVCC as your PV forecast source, the configuration is simplified as E # PV forecast source configuration - using EVCC pv_forecast_source: source: evcc # Use EVCC for PV forecasts -pv_forecast: [] # Can be left empty when using EVCC +pv_forecast: + - name: "Location for Temperature" # At least one entry needed for temperature forecasts + lat: 47.5 # Required for temperature forecasts used by EOS optimization + lon: 8.5 # Required for temperature forecasts used by EOS optimization + # Other parameters (azimuth, tilt, power, etc.) not used for PV forecasts but can be included # EVCC configuration - REQUIRED when using evcc as pv_forecast_source evcc: url: http://192.168.1.100:7070 # URL to your EVCC installation @@ -490,6 +521,47 @@ evcc: In this configuration: - EVCC handles all PV installation details and provides aggregated forecasts -- The `pv_forecast` section can be empty, but adding location coordinates improves temperature forecasts +- The `pv_forecast` section requires at least one entry with valid `lat` and `lon` coordinates for temperature forecasts that EOS needs for accurate optimization - The `evcc.url` must point to a reachable EVCC instance with API access enabled -- Better temperature forecasts lead to more accurate energy optimization results \ No newline at end of file +- Temperature forecasts are essential for EOS optimization calculations, regardless of PV forecast source + +### Example: Using Solcast for PV Forecasts + +When using Solcast as your PV forecast source, you need to configure your rooftop sites in the Solcast dashboard first: + +```yaml +# PV forecast source configuration - using Solcast +pv_forecast_source: + source: solcast # Use Solcast for PV forecasts + api_key: "your_solcast_api_key_here" # Your Solcast API key (required) + +# PV forecast configurations using Solcast resource IDs +pv_forecast: + - name: "Main Roof South" + resource_id: "abcd-efgh-1234-5678" # Resource ID from Solcast dashboard + lat: 47.5 # Required for temperature forecasts used by EOS optimization + lon: 8.5 # Required for temperature forecasts used by EOS optimization + power: 5000 # Still needed for system scaling + powerInverter: 5000 + inverterEfficiency: 0.95 + # azimuth, tilt, horizon not used for PV forecasts - configured in Solcast dashboard + - name: "Garage East" + resource_id: "ijkl-mnop-9999-0000" # Different resource ID for second installation + lat: 47.5 # Same location coordinates can be used for multiple installations + lon: 8.5 + power: 2500 + powerInverter: 3000 + inverterEfficiency: 0.92 +``` + +**Important Solcast Rate Limiting Information:** + +- Each PV installation requires a separate rooftop site configured in your Solcast account +- Physical PV parameters (tilt, azimuth) are configured in the Solcast dashboard, not in EOS Connect +- **Location coordinates (lat, lon) are still required** for temperature forecasts that EOS uses for optimization calculations +- The `resource_id` is obtained from your Solcast rooftop site configuration +- `power`, `powerInverter`, and `inverterEfficiency` are still required for proper system scaling +- **Free Solcast accounts are limited to 10 API calls per day** +- **EOS Connect automatically extends update intervals to 2.5 hours when using Solcast** to stay within the 10 calls/day limit (9.6 calls/day actual usage) +- Multiple PV installations will result in multiple API calls per update cycle - consider this when planning your configuration +- If you exceed rate limits, EOS Connect will use the previous forecast data until the next successful API call \ No newline at end of file diff --git a/src/config.py b/src/config.py index f75b57a9..2b26b424 100644 --- a/src/config.py +++ b/src/config.py @@ -91,14 +91,14 @@ def create_default_config(self): "pv_forecast_source": CommentedMap( { # openmeteo, openmeteo_local, forecast_solar, akkudoktor - "source": "akkudoktor", + "source": "akkudoktor", # akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default + "api_key": "", # API key for solcast (required when source is solcast) } ), "pv_forecast": [ CommentedMap( { - "name": "myPvInstallation1", # Placeholder for user-defined - # configuration name + "name": "myPvInstallation1", # Placeholder for user-defined configuration name "lat": 47.5, # Latitude for PV forecast "lon": 8.5, # Longitude for PV forecast "azimuth": 90.0, # Azimuth for PV forecast @@ -107,6 +107,7 @@ def create_default_config(self): "powerInverter": 5000, # Inverter Power "inverterEfficiency": 0.9, # Inverter Efficiency for PV forecast "horizon": "10,20,10,15", # Horizon to calculate shading + "resource_id": "", # Resource ID for Solcast (optional, only needed for Solcast) } ) ], @@ -268,9 +269,13 @@ def create_default_config(self): ) config["pv_forecast_source"].yaml_add_eol_comment( "data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local," - + " forecast_solar, evcc, default (default uses akkudoktor)", + + " forecast_solar, evcc, solcast, default (default uses akkudoktor)", "source", ) + config["pv_forecast_source"].yaml_add_eol_comment( + "API key for Solcast (required only when source is 'solcast')", + "api_key", + ) # pv forecast configuration config.yaml_set_comment_before_after_key( "pv_forecast", @@ -312,6 +317,10 @@ def create_default_config(self): + " to describe the shading situation for your PV.", "horizon", ) + config["pv_forecast"][index].yaml_add_eol_comment( + "Resource ID for Solcast API (optional, only needed when using Solcast provider)", + "resource_id", + ) # inverter configuration config.yaml_set_comment_before_after_key( "inverter", before="Inverter configuration" diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index 1c7122f4..1ab797c9 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -33,6 +33,7 @@ import requests import pandas as pd import numpy as np +import sys from open_meteo_solar_forecast import OpenMeteoSolarForecast logger = logging.getLogger("__main__") @@ -59,13 +60,10 @@ def __init__( self.config_source = config_source self.config_special = config_special logger.debug( - "[PV-IF] Initializing with 1st source: %s" - # + " and 2nd source: %s" - , + "[PV-IF] Initializing with 1st source: %s", self.config_source.get("source", "akkudoktor"), # self.config_source.get("second_source", "openmeteo"), ) - self.__check_config() # Validate configuration parameters self.pv_forcast_array = [] self.pv_forcast_request_error = { @@ -79,7 +77,24 @@ def __init__( self._update_thread = None self._stop_event = threading.Event() - self.update_interval = 15 * 60 # Update 15 minutes (in seconds) + # Adjust update interval based on provider + if self.config_source.get("source") == "solcast": + self.update_interval = ( + 2.5 * 60 * 60 + ) # 2.5 hours (9.6 calls/day - under the 10 limit) + logger.info("[PV-IF] Using extended update interval for Solcast: 2.5 hours") + else: + self.update_interval = 15 * 60 # Standard 15 minutes + + try: + self.__check_config() # Validate configuration parameters + self.configuration_valid = True + logger.info("[PV-IF] Configuration validation successful") + except ValueError as e: + logger.error("[PV-IF] PV Interface configuration error: %s", str(e)) + logger.error("[PV-IF] We have to exit now ...") + sys.exit(1) # Exit if configuration is invalid + logger.info("[PV-IF] Initialized") self.__start_update_service() # Start the background thread for periodic updates @@ -95,39 +110,82 @@ def __check_config(self): """ if not len(self.config) > 0: logger.error("[PV-IF] Initialize - No pv entries found") - else: - logger.debug("[PV-IF] Initialize - pv entries found: %s", len(self.config)) - for config_entry in self.config: - # check for each entries the mandatory params - if config_entry.get("name", ""): - logger.debug( - "[PV-IF] Initialize - config entry name: %s", - config_entry.get("name", ""), + return + + logger.debug("[PV-IF] Initialize - pv entries found: %s", len(self.config)) + + for config_entry in self.config: + entry_name = config_entry.get("name", "unnamed") + logger.debug("[PV-IF] Initialize - config entry name: %s", entry_name) + + # Check for Solcast-specific requirements + if self.config_source.get("source") == "solcast": + # Check API key + if not self.config_source.get("api_key", "").strip(): + logger.error( + "[PV-IF] Solcast API key missing in pv_forecast_source section" + ) + logger.error( + '[PV-IF] Please add: api_key: "your_solcast_api_key" in config.yaml' + ) + raise ValueError( + "[PV-IF] Solcast API key required - see CONFIG_README.md for setup instructions" + ) + + # Check resource_id + if not config_entry.get("resource_id", "").strip(): + logger.error( + "[PV-IF] Resource ID missing for '%s' - required for Solcast", + entry_name, + ) + logger.error( + '[PV-IF] Please add: resource_id: "your_resource_id" in config.yaml' ) - else: - logger.debug("[PV-IF] Init - config entry name not found") - if config_entry.get("lat", None) is None: - raise ValueError("[PV-IF] Init - lat not found in config entry") - if config_entry.get("lon", None) is None: - raise ValueError("[PV-IF] Init - lon not found in config entry") - if config_entry.get("azimuth", None) is None: - raise ValueError("[PV-IF] Init - azimuth not found in config entry") - if config_entry.get("tilt", None) is None: - raise ValueError("[PV-IF] Init - tilt not found in config entry") - if config_entry.get("power", None) is None: - raise ValueError("[PV-IF] Init - power not found in config entry") - if config_entry.get("powerInverter", None) is None: raise ValueError( - "[PV-IF] Init - powerInverter not found in config entry" + f"[PV-IF] Solcast resource_id required for '{entry_name}' - see CONFIG_README.md for setup instructions" + ) + + logger.debug("[PV-IF] Solcast config validated for '%s'", entry_name) + else: + # Standard parameter validation for other sources + missing = [] + if config_entry.get("lat") is None: + missing.append("lat") + if config_entry.get("lon") is None: + missing.append("lon") + if config_entry.get("azimuth") is None: + missing.append("azimuth") + if config_entry.get("tilt") is None: + missing.append("tilt") + + if missing: + logger.error( + "[PV-IF] Missing parameters for '%s': %s", + entry_name, + ", ".join(missing), ) - if config_entry.get("inverterEfficiency", None) is None: raise ValueError( - "[PV-IF] Init - inverterEfficiency not found in config entry" + f"[PV-IF] Missing required parameters for '{entry_name}': {', '.join(missing)}" ) - # if config_entry.get("horizon", None) is None: - # config_entry["horizon"] = "" - # logger.debug("[PV-IF] Init - horizon not found in config entry,"+ - # "using default empty value") + + # Common parameters for all sources + missing_common = [] + if config_entry.get("power") is None: + missing_common.append("power") + if config_entry.get("powerInverter") is None: + missing_common.append("powerInverter") + if config_entry.get("inverterEfficiency") is None: + missing_common.append("inverterEfficiency") + + if missing_common: + logger.error( + "[PV-IF] Missing common parameters for '%s': %s", + entry_name, + ", ".join(missing_common), + ) + raise ValueError( + f"[PV-IF] Missing required parameters for '{entry_name}': {', '.join(missing_common)}" + ) def __start_update_service(self): """ @@ -318,7 +376,7 @@ def get_pv_forecast(self, config_entry, tgt_duration=24): Notes: - Supported sources: "akkudoktor", "openmeteo", "forecast_solar", - "default". + "solcast", "default". - Logs a warning if the default source is used. - Logs an error and falls back to the default forecast if no valid source is configured. @@ -336,6 +394,8 @@ def get_pv_forecast(self, config_entry, tgt_duration=24): return self.__get_pv_forecast_forecast_solar_api(config_entry) elif self.config_source.get("source") == "evcc": return self.__get_pv_forecast_evcc_api(config_entry, tgt_duration) + elif self.config_source.get("source") == "solcast": + return self.__get_pv_forecast_solcast_api(config_entry, tgt_duration) elif self.config_source.get("source") == "default": logger.warning("[PV-IF] Using default PV forecast source") return self.__get_default_pv_forcast(config_entry["power"]) @@ -398,7 +458,7 @@ def __get_pv_forecast_akkudoktor_api( except requests.exceptions.RequestException as e: return self._handle_interface_error( "request_failed", - f"Akkudoktor API request failed for {tgt_value}: {e}", + f"Akkudtoktor API request failed for {tgt_value}: {e}", pv_config_entry, "akkudoktor", ) @@ -955,6 +1015,236 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): pv_config_entry, ) + def __get_pv_forecast_solcast_api(self, pv_config_entry, tgt_duration=48): + """ + Fetches PV forecast from Solcast API using resource ID endpoint. + + Args: + pv_config_entry (dict): Configuration entry containing resource_id + tgt_duration (int): Target duration in hours (default 48) + + Returns: + list: PV forecast values in Wh for each hour + """ + api_key = self.config_source.get("api_key") + resource_id = pv_config_entry.get("resource_id") + + if not api_key: + return self._handle_interface_error( + "config_error", + "Solcast API key missing from pv_forecast_source configuration", + pv_config_entry, + "solcast", + ) + + if not resource_id: + return self._handle_interface_error( + "config_error", + "Resource ID missing from PV configuration for Solcast", + pv_config_entry, + "solcast", + ) + + # Solcast API endpoint for resource-based forecasts (free tier compatible) + url = f"https://api.solcast.com.au/rooftop_sites/{resource_id}/forecasts" + + # Parameters for the API request + params = { + "hours": min(tgt_duration, 168), # Solcast max is 168 hours (7 days) + "format": "json", + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + logger.debug( + "[PV-IF] Fetching PV forecast from Solcast API for resource: %s (hours: %d)", + resource_id, + params["hours"], + ) + + # Network request handling + try: + response = requests.get(url, params=params, headers=headers, timeout=15) + + # Enhanced error logging for debugging + logger.debug( + "[PV-IF] Solcast API response status: %d", response.status_code + ) + + # Check for API-specific error responses + if response.status_code == 429: + return self._handle_interface_error( + "rate_limit", + "Solcast API rate limit exceeded", + pv_config_entry, + "solcast", + ) + elif response.status_code == 403: + logger.error( + "[PV-IF] Solcast API 403 Forbidden - Response: %s", + response.text[:200], + ) + return self._handle_interface_error( + "auth_error", + "Solcast API authentication failed (403) - check API key and " + + "resource ID access. Response: {response.text[:100]}", + pv_config_entry, + "solcast", + ) + elif response.status_code == 404: + return self._handle_interface_error( + "not_found", + f"Solcast resource ID '{resource_id}' not found - check resource ID", + pv_config_entry, + "solcast", + ) + elif response.status_code == 400: + return self._handle_interface_error( + "bad_request", + "Solcast API bad request - check parameters", + pv_config_entry, + "solcast", + ) + + response.raise_for_status() + data = response.json() + + except requests.exceptions.Timeout: + return self._handle_interface_error( + "timeout", + "Solcast API request timed out.", + pv_config_entry, + "solcast", + ) + except requests.exceptions.RequestException as e: + return self._handle_interface_error( + "request_failed", + f"Solcast API request failed: {e}", + pv_config_entry, + "solcast", + ) + except (ValueError, TypeError) as e: + return self._handle_interface_error( + "invalid_json", + f"Invalid JSON response from Solcast: {e}", + pv_config_entry, + "solcast", + ) + + # Data processing + try: + forecasts = data.get("forecasts", []) + if not forecasts: + return self._handle_interface_error( + "no_valid_data", + "No forecast data received from Solcast API", + pv_config_entry, + "solcast", + ) + + # Get timezone-aware current time + tz = pytz.timezone(self.time_zone) + current_time = datetime.now(tz).replace(minute=0, second=0, microsecond=0) + + # Calculate midnight of today + midnight_today = current_time.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + # Create forecast array for target duration starting from midnight today + forecast_hours = [ + midnight_today + timedelta(hours=i) for i in range(tgt_duration) + ] + pv_forecast = [0.0] * tgt_duration # Initialize with zeros + + # Create hourly aggregation dictionary + hourly_power = {} + + # Process Solcast data (30-minute intervals) + for forecast_item in forecasts: + try: + # Parse timestamp from Solcast (ISO format with timezone) + period_end = forecast_item.get("period_end", "") + if not period_end: + continue + + # Convert to datetime - Solcast uses ISO format + if period_end.endswith("Z"): + forecast_time = datetime.fromisoformat( + period_end.replace("Z", "+00:00") + ) + else: + forecast_time = datetime.fromisoformat(period_end) + + # Convert to configured timezone + forecast_time = forecast_time.astimezone(tz) + + # IMPORTANT: period_end is the END of a 30-minute period + # We need to map it to the hour it belongs to + # For example: 06:30 period_end belongs to hour 06:00-07:00 + # So we subtract 30 minutes to get the start of the period + period_start = forecast_time - timedelta(minutes=30) + + # Round down to the hour for aggregation + hour_key = period_start.replace(minute=0, second=0, microsecond=0) + + # Get PV power estimate - Solcast provides kW values for the + # system capacity you configured + pv_estimate_kw = forecast_item.get("pv_estimate", 0) + + # Convert kW to Wh for 30-minute period + # kW * 0.5 hours = kWh, then * 1000 to get Wh + pv_estimate_wh = ( + pv_estimate_kw * 500 + ) # kW * 0.5h * 1000W/kW = Wh for 30min + + # Aggregate 30-minute values into hourly values + if hour_key in hourly_power: + hourly_power[hour_key] += pv_estimate_wh + else: + hourly_power[hour_key] = pv_estimate_wh + + except (ValueError, TypeError, AttributeError) as e: + logger.warning( + "[PV-IF] Error processing Solcast forecast item: %s", e + ) + continue + + # Fill forecast array with aggregated hourly values + for i, forecast_hour in enumerate(forecast_hours): + if forecast_hour in hourly_power: + power_wh = hourly_power[forecast_hour] + + # Apply inverter efficiency if configured + inverter_efficiency = pv_config_entry.get("inverterEfficiency", 1.0) + power_wh *= inverter_efficiency + + pv_forecast[i] = round(power_wh, 1) + + # Clear any previous errors on success + self.pv_forcast_request_error["error"] = None + + logger.debug( + "[PV-IF] Solcast PV forecast for resource '%s' received %d forecast points," + + " first 12h (Wh): %s", + resource_id, + len(forecasts), + pv_forecast[:12], # Log first 12 hours to avoid spam + ) + + return pv_forecast + + except (ValueError, TypeError, AttributeError, KeyError) as e: + return self._handle_interface_error( + "processing_error", + f"Error processing Solcast forecast data: {e}", + pv_config_entry, + "solcast", + ) + def _handle_interface_error( self, error_type, message, pv_config_entry, source="unknown" ): From 0361136d6f759b69b7664d693b47400ef88fd5ee Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Fri, 3 Oct 2025 00:03:31 +0200 Subject: [PATCH 018/132] fix: increase precision of hourly price calculations to 9 decimal places - fix smartenergy_at prices deviate Fixes #98 --- src/interfaces/price_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interfaces/price_interface.py b/src/interfaces/price_interface.py index 31ef73c3..416b499b 100644 --- a/src/interfaces/price_interface.py +++ b/src/interfaces/price_interface.py @@ -555,7 +555,7 @@ def __retrieve_prices_from_smartenergy_at(self, tgt_duration, start_time=None): for hour in range(24): values = hourly.get(hour, []) avg = sum(values) / len(values) if values else 0 - hourly_prices.append(round(avg, 6)) + hourly_prices.append(round(avg, 9)) # Optionally extend to tgt_duration if needed extended_prices = hourly_prices From e88f3bf9e92066a7fba0f37fb0bc4d4f107d6361 Mon Sep 17 00:00:00 2001 From: ohAnd Date: Thu, 2 Oct 2025 22:04:46 +0000 Subject: [PATCH 019/132] [AUTO] Update version to 0.1.24.124-develop Files changed: M src/version.py --- src/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.py b/src/version.py index 79c12bfa..4966ea91 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = '0.1.24.123-develop' +__version__ = '0.1.24.124-develop' From 7bf6a2a7afca1d14df705d587a013ce84b3994ad Mon Sep 17 00:00:00 2001 From: Paul Elsner Date: Fri, 3 Oct 2025 08:47:22 +0200 Subject: [PATCH 020/132] fix: handle scale factor 0 from EVCC --- src/interfaces/pv_interface.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index 1ab797c9..16e7bbfd 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -997,6 +997,13 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): except (TypeError, ValueError): scale_factor = 1.0 + if scale_factor <= 0: + logger.debug( + "[PV-IF] EVCC PV forecast scale factor invalid (%s) - using 1.0", + scale_factor, + ) + scale_factor = 1.0 + pv_forecast = [val * scale_factor for val in pv_forecast] # Clear any previous errors on success From 62b2422b64fd97945b04465d8e65ec3d4935d7ac Mon Sep 17 00:00:00 2001 From: ohAnd Date: Fri, 3 Oct 2025 07:34:15 +0000 Subject: [PATCH 021/132] [AUTO] Update version to 0.1.24.126-develop Files changed: M src/version.py --- src/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.py b/src/version.py index 4966ea91..9b36e344 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = '0.1.24.124-develop' +__version__ = '0.1.24.126-develop' From 790234d11f445fa116301283026631abfce7b863 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Fri, 3 Oct 2025 09:43:03 +0200 Subject: [PATCH 022/132] fix: improve error messages and refactor variable names for clarity in PvInterface --- src/interfaces/pv_interface.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index 16e7bbfd..4827a9b3 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -28,12 +28,12 @@ import time import asyncio import math +import sys import aiohttp import pytz import requests import pandas as pd import numpy as np -import sys from open_meteo_solar_forecast import OpenMeteoSolarForecast logger = logging.getLogger("__main__") @@ -129,7 +129,8 @@ def __check_config(self): '[PV-IF] Please add: api_key: "your_solcast_api_key" in config.yaml' ) raise ValueError( - "[PV-IF] Solcast API key required - see CONFIG_README.md for setup instructions" + "[PV-IF] Solcast API key required - see CONFIG_README.md" + + " for setup instructions" ) # Check resource_id @@ -142,7 +143,8 @@ def __check_config(self): '[PV-IF] Please add: resource_id: "your_resource_id" in config.yaml' ) raise ValueError( - f"[PV-IF] Solcast resource_id required for '{entry_name}' - see CONFIG_README.md for setup instructions" + f"[PV-IF] Solcast resource_id required for '{entry_name}' - see" + + " CONFIG_README.md for setup instructions" ) logger.debug("[PV-IF] Solcast config validated for '%s'", entry_name) @@ -165,7 +167,8 @@ def __check_config(self): ", ".join(missing), ) raise ValueError( - f"[PV-IF] Missing required parameters for '{entry_name}': {', '.join(missing)}" + "[PV-IF] Missing required parameters " + + f"for '{entry_name}': {', '.join(missing)}" ) # Common parameters for all sources @@ -184,7 +187,8 @@ def __check_config(self): ", ".join(missing_common), ) raise ValueError( - f"[PV-IF] Missing required parameters for '{entry_name}': {', '.join(missing_common)}" + "[PV-IF] Missing required parameters" + + f" for '{entry_name}': {', '.join(missing_common)}" ) def __start_update_service(self): @@ -1327,13 +1331,13 @@ def _solar_position(self, times, latitude, longitude): lat_rad = math.radians(latitude) results = [] - for time in times: + for t in times: # Convert to Julian day number - a = (14 - time.month) // 12 - y = time.year - a - m = time.month + 12 * a - 3 + a = (14 - t.month) // 12 + y = t.year - a + m = t.month + 12 * a - 3 jdn = ( - time.day + t.day + (153 * m + 2) // 5 + 365 * y + y // 4 @@ -1343,20 +1347,20 @@ def _solar_position(self, times, latitude, longitude): ) # Add fraction of day - hour_fraction = (time.hour + time.minute / 60 + time.second / 3600) / 24 + hour_fraction = (t.hour + t.minute / 60 + t.second / 3600) / 24 jd = jdn + hour_fraction - 0.5 # Number of days since J2000.0 n = jd - 2451545.0 # Mean longitude of sun - L = (280.460 + 0.9856474 * n) % 360 + long_of_sun = (280.460 + 0.9856474 * n) % 360 # Mean anomaly of sun g = math.radians((357.528 + 0.9856003 * n) % 360) # Ecliptic longitude of sun - lambda_sun = math.radians(L + 1.915 * math.sin(g) + 0.020 * math.sin(2 * g)) + lambda_sun = math.radians(long_of_sun + 1.915 * math.sin(g) + 0.020 * math.sin(2 * g)) # Obliquity of ecliptic epsilon = math.radians(23.439 - 0.0000004 * n) From 54ac65c520ed3ac0fa5560740bc2b5be73dc3ca2 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Fri, 3 Oct 2025 11:10:52 +0200 Subject: [PATCH 023/132] fix: rename timezone parameter to tz_name for consistency and clarity --- src/interfaces/load_interface.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/interfaces/load_interface.py b/src/interfaces/load_interface.py index 32ab3a75..83a09249 100644 --- a/src/interfaces/load_interface.py +++ b/src/interfaces/load_interface.py @@ -25,7 +25,7 @@ class LoadInterface: def __init__( self, config, - timezone=None, # Changed default to None + tz_name=None, # Changed default to None ): self.src = config.get("source", "") self.url = config.get("url", "") @@ -35,24 +35,24 @@ def __init__( self.access_token = config.get("access_token", "") # Handle timezone properly - if timezone == "UTC" or timezone is None: + if tz_name == "UTC" or tz_name is None: self.time_zone = None # Use local timezone - elif isinstance(timezone, str): + elif isinstance(tz_name, str): # Try to convert string timezone to proper timezone object try: - self.time_zone = zoneinfo.ZoneInfo(timezone) + self.time_zone = zoneinfo.ZoneInfo(tz_name) except ImportError: # Fallback for older Python versions try: - self.time_zone = pytz.timezone(timezone) + self.time_zone = pytz.timezone(tz_name) except ImportError: logger.warning( "[LOAD-IF] Cannot parse timezone '%s', using local time", - timezone, + tz_name, ) self.time_zone = None else: - self.time_zone = timezone + self.time_zone = tz_name self.__check_config() @@ -250,7 +250,7 @@ def __process_energy_data(self, data, debug_sensor=None): + quote((current_time + timedelta(hours=2)).isoformat()) + ")" ) - logger.warning( + logger.info( "[LOAD-IF] Skipping invalid sensor data for '%s' at %s: state '%s' cannot be" + " processed (%s). " "This may indicate missing or corrupted data in the database. %s", From 2148bf64f1a7f66e32a0c119a13d40ea75121dbf Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Fri, 3 Oct 2025 12:02:00 +0200 Subject: [PATCH 024/132] feat: implement in-memory logging handler with API endpoints for log retrieval and management --- README.md | 316 ++++++++++++++++++++++++++++++++++++++++++--- src/eos_connect.py | 180 +++++++++++++++++++++++++- src/log_handler.py | 275 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 748 insertions(+), 23 deletions(-) create mode 100644 src/log_handler.py diff --git a/README.md b/README.md index b4a64d5b..a0eb8431 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,10 @@ EOS Connect helps you get the most out of your solar and storage systems—wheth - [Webpage Example](#webpage-example) - [Provided Data per **EOS connect** API](#provided-data-per-eos-connect-api) - [Web API (REST/JSON)](#web-api-restjson) - - [Main Endpoints](#main-endpoints) + - [Main Endpoints](#main-endpoints) - [MQTT - provided data and possible commands](#mqtt---provided-data-and-possible-commands) - - [Published Topics](#published-topics) - - [Example Usage](#example-usage) + - [Published Topics](#published-topics) + - [Example Usage](#example-usage) - [Subscribed Topics](#subscribed-topics) - [System Mode Control (`control/overall_state/set`)](#system-mode-control-controloverall_stateset) - [How to Use](#how-to-use) @@ -329,20 +329,30 @@ All endpoints return JSON and can be accessed via HTTP requests. --- -### Main Endpoints +#### Main Endpoints | Endpoint | Method | Returns / Accepts | Description | |------------------------------------|--------|-----------------------------|------------------------------------------------------------------| | `/json/current_controls.json` | GET | JSON | Current system control states (AC/DC charge, mode, etc.) | | `/json/optimize_request.json` | GET | JSON | Last optimization request sent to EOS | | `/json/optimize_response.json` | GET | JSON | Last optimization response from EOS | +| `/json/optimize_request.test.json` | GET | JSON | Test optimization request (static file) | +| `/json/optimize_response.test.json`| GET | JSON | Test optimization response (static file) | | `/controls/mode_override` | POST | JSON (see below) | Override system mode, duration, and grid charge power | +| `/logs` | GET | JSON | Retrieve application logs with optional filtering | +| `/logs/alerts` | GET | JSON | Retrieve warning and error logs for alert system | +| `/logs/clear` | POST | JSON | Clear all stored logs from memory (file logs remain intact) | +| `/logs/alerts/clear` | POST | JSON | Clear only alert logs from memory, keeping regular logs intact | +| `/logs/stats` | GET | JSON | Get buffer usage statistics for log storage | ---
Show Example: /json/current_controls.json (GET) +Get current system control states and battery information. + +**Response:** ```json { "current_states": { @@ -362,14 +372,91 @@ All endpoints return JSON and can be accessed via HTTP requests. "evcc": { "charging_mode": "pv", "charging_state": true, - "current_sessions": [ ... ] + "current_sessions": [ + { + "vehicle": "Tesla Model 3", + "charging_power": 1500, + "session_energy": 12.5 + } + ] }, "state": { "last_response_timestamp": "2024-06-01T12:00:00+02:00", "next_run": "2024-06-01T12:03:00+02:00" }, "timestamp": "2024-06-01T12:00:00+02:00", - "api_version": "0.0.1" + "api_version": "0.1.24" +} +``` +
+ +--- + +
+Show Example: /json/optimize_request.json (GET) + +Get the last optimization request sent to EOS. + +**Response:** +```json +{ + "request": { + "pv_forecast": [ + {"time": "2024-06-01T13:00:00+02:00", "power": 3500}, + {"time": "2024-06-01T14:00:00+02:00", "power": 4200}, + {"time": "2024-06-01T15:00:00+02:00", "power": 3800} + ], + "load_forecast": [ + {"time": "2024-06-01T13:00:00+02:00", "power": 800}, + {"time": "2024-06-01T14:00:00+02:00", "power": 1200}, + {"time": "2024-06-01T15:00:00+02:00", "power": 900} + ], + "price_forecast": [ + {"time": "2024-06-01T13:00:00+02:00", "price": 0.25}, + {"time": "2024-06-01T14:00:00+02:00", "price": 0.28}, + {"time": "2024-06-01T15:00:00+02:00", "price": 0.22} + ], + "battery_soc": 85.5, + "optimization_hours": 48 + }, + "timestamp": "2024-06-01T12:00:00+02:00" +} +``` +
+ +--- + +
+Show Example: /json/optimize_response.json (GET) + +Get the last optimization response received from EOS. + +**Response:** +```json +{ + "response": { + "status": "success", + "optimization_result": [ + { + "time": "2024-06-01T13:00:00+02:00", + "battery_charge_power": 2000, + "battery_discharge_power": 0, + "grid_power": -1500, + "mode": "charge" + }, + { + "time": "2024-06-01T14:00:00+02:00", + "battery_charge_power": 0, + "battery_discharge_power": 1000, + "grid_power": 200, + "mode": "discharge" + } + ], + "total_cost": 12.45, + "self_consumption": 78.5, + "processing_time": "00:02:15" + }, + "timestamp": "2024-06-01T12:02:15+02:00" } ```
@@ -381,7 +468,7 @@ All endpoints return JSON and can be accessed via HTTP requests. Override the system mode, duration, and grid charge power. -**Payload:** +**Request Payload:** ```json { "mode": 1, // Integer, see mode table below @@ -393,11 +480,24 @@ Override the system mode, duration, and grid charge power. **Response:** - On success: ```json - { "status": "success", "message": "Mode override applied" } + { + "status": "success", + "message": "Mode override applied", + "applied_settings": { + "mode": 1, + "mode_name": "ChargeFromGrid", + "duration": "02:00", + "grid_charge_power": 2000, + "end_time": "2024-06-01T14:00:00+02:00" + } + } ``` - On error: ```json - { "error": "Invalid mode value" } + { + "error": "Invalid mode value", + "details": "Mode must be between 0 and 4" + } ``` **System Modes (`mode` field):** @@ -414,20 +514,204 @@ Override the system mode, duration, and grid charge power. --- +
+Show Example: /logs (GET) + +Retrieve application logs with optional filtering. + +**Query Parameters:** +- `level`: Filter by log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) +- `limit`: Maximum number of records to return (default: 100) +- `since`: ISO timestamp to get logs since that time + +**Examples:** +- Get last 50 logs: `GET /logs?limit=50` +- Get only error logs: `GET /logs?level=ERROR` +- Get logs since 1 hour ago: `GET /logs?since=2024-06-01T11:00:00Z` + +**Response:** +```json +{ + "logs": [ + { + "timestamp": "2024-06-01T12:00:00.123456", + "level": "INFO", + "message": "[Main] Starting optimization run", + "module": "__main__", + "funcName": "run_optimization", + "lineno": 542, + "severity": 20 + } + ], + "total_count": 1, + "timestamp": "2024-06-01T12:00:00+02:00", + "filters_applied": { + "level": null, + "limit": 100, + "since": null + } +} +``` +
+ +--- + +
+Show Example: /logs/alerts (GET) + +Retrieve warning and error logs for alert system. + +**Response:** +```json +{ + "alerts": [ + { + "timestamp": "2024-06-01T12:00:00.123456", + "level": "WARNING", + "message": "[Battery] SOC exceeded maximum threshold", + "module": "__main__", + "funcName": "setting_control_data", + "lineno": 234, + "severity": 30 + } + ], + "grouped_alerts": { + "WARNING": [ ... ], + "ERROR": [ ... ], + "CRITICAL": [ ... ] + }, + "alert_counts": { + "WARNING": 1, + "ERROR": 0, + "CRITICAL": 0 + }, + "timestamp": "2024-06-01T12:00:00+02:00" +} +``` +
+ +--- + +
+Show Example: /logs/stats (GET) + +Get buffer usage statistics for log storage monitoring. + +**Response:** +```json +{ + "buffer_stats": { + "main_buffer": { + "current_size": 3456, + "max_size": 5000, + "usage_percent": 69.1 + }, + "alert_buffer": { + "current_size": 23, + "max_size": 2000, + "usage_percent": 1.2 + }, + "alert_levels": ["WARNING", "ERROR", "CRITICAL"] + }, + "timestamp": "2024-06-01T12:00:00+02:00" +} +``` +
+ +--- + +
+Show Example: /logs/clear (POST) + +Clear all stored logs from memory (file logs remain intact). + +**Response:** +- On success: + ```json + { "status": "success", "message": "Logs cleared" } + ``` +- On error: + ```json + { "error": "Failed to clear logs" } + ``` + +**Note:** This clears both the main log buffer (5000 entries) and the alert buffer (2000 entries). +
+ +--- + +
+Show Example: /logs/alerts/clear (POST) + +Clear only alert logs from memory, keeping regular logs intact. + +**Response:** +- On success: + ```json + { "status": "success", "message": "Alert logs cleared" } + ``` +- On error: + ```json + { "error": "Failed to clear alert logs" } + ``` + +**Note:** This only clears the dedicated alert buffer (2000 entries), leaving the main log buffer untouched. +
+ +--- + **How to Use:** - **Get current system state:** `GET http://localhost:8081/json/current_controls.json` - **Override mode and charge power:** `POST http://localhost:8081/controls/mode_override` with JSON body as shown above. +- **Monitor application logs:** + `GET http://localhost:8081/logs?level=ERROR&limit=20` +- **Get system alerts:** + `GET http://localhost:8081/logs/alerts` +- **Get log buffer statistics:** + `GET http://localhost:8081/logs/stats` +- **Clear memory logs:** + `POST http://localhost:8081/logs/clear` +- **Clear only alerts:** + `POST http://localhost:8081/logs/alerts/clear` You can use `curl`, Postman, or any HTTP client to interact with these endpoints. -**Notes:** -- The web API is always available on the configured port. -- All responses are in JSON format. -- The override will be active for the specified duration, after which the system returns to automatic mode. -- Invalid values will result in an error response. +**Examples using curl:** +```bash +# Get last 10 error logs +curl "http://localhost:8081/logs?level=ERROR&limit=10" + +# Get current system alerts +curl "http://localhost:8081/logs/alerts" + +# Get log buffer usage statistics +curl "http://localhost:8081/logs/stats" + +# Clear all memory logs +curl -X POST "http://localhost:8081/logs/clear" + +# Clear only alert logs +curl -X POST "http://localhost:8081/logs/alerts/clear" + +# Override system mode +curl -X POST "http://localhost:8081/controls/mode_override" \ + -H "Content-Type: application/json" \ + -d '{"mode": 1, "duration": "02:00", "grid_charge_power": 2.0}' +``` + +**Memory Log System Notes:** +- **Main buffer**: Stores the last 5000 log entries (all levels mixed) +- **Alert buffer**: Stores the last 2000 alert entries (WARNING/ERROR/CRITICAL only) +- **Persistent storage**: File-based logs are not affected by memory operations +- **Timezone aware**: All timestamps use the configured timezone +- **Thread-safe**: Safe for concurrent access from multiple clients +- **Performance**: Memory-based access provides fast response times +- **Monitoring**: Use `/logs/stats` to monitor buffer usage and plan capacity + +The logging API enables real-time monitoring, alerting systems, and debugging without affecting the persistent file-based logging system. @@ -446,7 +730,7 @@ EOS Connect publishes a wide range of real-time system data and control states t --- -### Published Topics +#### Published Topics | Topic Suffix | Full Topic Example | Payload Type / Example | Description | |---------------------------------------------------|---------------------------------------------------------------|-------------------------------|----------------------------------------------------------| @@ -476,7 +760,7 @@ EOS Connect publishes a wide range of real-time system data and control states t --- -### Example Usage +#### Example Usage - **Monitor battery SOC in Home Assistant:** - Subscribe to `myhome/eos_connect/battery/soc` to get real-time battery state of charge. diff --git a/src/eos_connect.py b/src/eos_connect.py index a8620ba7..2a00b8ca 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -12,9 +12,9 @@ import pytz import requests from flask import Flask, Response, render_template_string, request -from gevent.pywsgi import WSGIServer from version import __version__ from config import ConfigManager +from log_handler import MemoryLogHandler from interfaces.base_control import BaseControl from interfaces.load_interface import LoadInterface from interfaces.battery_interface import BatteryInterface @@ -56,18 +56,20 @@ def formatTime(self, record, datefmt=None): return record_time.strftime(datefmt or self.default_time_format) -################################################################################################### +################################################################################################## LOGLEVEL = logging.DEBUG # start before reading the config file logger = logging.getLogger(__name__) -formatter = logging.Formatter( + +# Basic formatter for startup logging (before config/timezone is available) +basic_formatter = logging.Formatter( "%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S" ) streamhandler = logging.StreamHandler(sys.stdout) - -streamhandler.setFormatter(formatter) +streamhandler.setFormatter(basic_formatter) logger.addHandler(streamhandler) logger.setLevel(LOGLEVEL) logger.info("[Main] Starting eos_connect - version: %s", __version__) + ################################################################################################### base_path = os.path.dirname(os.path.abspath(__file__)) # get param to set a specific path @@ -75,16 +77,28 @@ def formatTime(self, record, datefmt=None): current_dir = sys.argv[1] else: current_dir = base_path + ################################################################################################### config_manager = ConfigManager(current_dir) time_zone = pytz.timezone(config_manager.config["time_zone"]) LOGLEVEL = config_manager.config["log_level"].upper() logger.setLevel(LOGLEVEL) -formatter = TimezoneFormatter( + +# Now upgrade to timezone-aware formatter after config is loaded +timezone_formatter = TimezoneFormatter( "%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S", tz=time_zone ) -streamhandler.setFormatter(formatter) +streamhandler.setFormatter(timezone_formatter) + +memory_handler = MemoryLogHandler( + max_records=5000, # All log entries (mixed levels) + max_alerts=2000 # Dedicated alert buffer (WARNING/ERROR/CRITICAL only) +) +memory_handler.setFormatter(timezone_formatter) # Use timezone formatter for web logs +logger.addHandler(memory_handler) +logger.debug("[Main] Memory log handler initialized successfully") + logger.info( "[Main] set user defined time zone to %s and loglevel to %s", config_manager.config["time_zone"], @@ -1123,6 +1137,157 @@ def handle_mode_override(): content_type="application/json", ) +@app.route("/logs", methods=["GET"]) +def get_logs(): + """ + Retrieve application logs with optional filtering. + + Query parameters: + - level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + - limit: Maximum number of records to return (default: 100) + - since: ISO timestamp to get logs since that time + """ + try: + level_filter = request.args.get('level') + limit = int(request.args.get('limit', 100)) + since = request.args.get('since') + + logs = memory_handler.get_logs( + level_filter=level_filter, + limit=limit, + since=since + ) + + response_data = { + "logs": logs, + "total_count": len(logs), + "timestamp": datetime.now(time_zone).isoformat(), + "filters_applied": { + "level": level_filter, + "limit": limit, + "since": since + } + } + + return Response( + json.dumps(response_data, indent=2), + content_type="application/json" + ) + + except (ValueError, TypeError, KeyError) as e: + logger.error("[API] Error retrieving logs: %s", e) + return Response( + json.dumps({"error": "Failed to retrieve logs"}), + status=500, + content_type="application/json" + ) + +@app.route("/logs/alerts", methods=["GET"]) +def get_alerts(): + """ + Retrieve warning and error logs for alert system. + """ + try: + alerts = memory_handler.get_alerts() + + # Group alerts by level for easier processing + grouped_alerts = { + 'WARNING': [a for a in alerts if a['level'] == 'WARNING'], + 'ERROR': [a for a in alerts if a['level'] == 'ERROR'], + 'CRITICAL': [a for a in alerts if a['level'] == 'CRITICAL'] + } + + response_data = { + "alerts": alerts, + "grouped_alerts": grouped_alerts, + "alert_counts": { + level: len(items) for level, items in grouped_alerts.items() + }, + "timestamp": datetime.now(time_zone).isoformat() + } + + return Response( + json.dumps(response_data, indent=2), + content_type="application/json" + ) + + except (ValueError, TypeError, KeyError) as e: + logger.error("[API] Error retrieving alerts: %s", e) + return Response( + json.dumps({"error": "Failed to retrieve alerts"}), + status=500, + content_type="application/json" + ) + +@app.route("/logs/clear", methods=["POST"]) +def clear_logs(): + """ + Clear all stored logs from memory (file logs remain intact). + """ + try: + memory_handler.clear_logs() + logger.info("[API] Memory logs cleared via web API") + + return Response( + json.dumps({"status": "success", "message": "Logs cleared"}), + content_type="application/json" + ) + + except (RuntimeError, ValueError, TypeError, KeyError) as e: + logger.error("[API] Error clearing logs: %s", e) + return Response( + json.dumps({"error": "Failed to clear logs"}), + status=500, + content_type="application/json" + ) + +@app.route("/logs/alerts/clear", methods=["POST"]) +def clear_alerts_only(): + """ + Clear only alert logs from memory, keeping regular logs intact. + """ + try: + memory_handler.clear_alerts_only() + logger.info("[API] Alert logs cleared via web API") + + return Response( + json.dumps({"status": "success", "message": "Alert logs cleared"}), + content_type="application/json" + ) + + except (RuntimeError, ValueError, TypeError, KeyError) as e: + logger.error("[API] Error clearing alert logs: %s", e) + return Response( + json.dumps({"error": "Failed to clear alert logs"}), + status=500, + content_type="application/json" + ) + +@app.route("/logs/stats", methods=["GET"]) +def get_log_stats(): + """ + Get buffer usage statistics. + """ + try: + stats = memory_handler.get_buffer_stats() + + response_data = { + "buffer_stats": stats, + "timestamp": datetime.now(time_zone).isoformat() + } + + return Response( + json.dumps(response_data, indent=2), + content_type="application/json" + ) + + except (ValueError, TypeError, KeyError) as e: + logger.error("[API] Error retrieving buffer stats: %s", e) + return Response( + json.dumps({"error": "Failed to retrieve buffer stats"}), + status=500, + content_type="application/json" + ) if __name__ == "__main__": http_server = None @@ -1182,5 +1347,6 @@ def handle_mode_override(): battery_interface.shutdown() logger.info("[Main] Server stopped gracefully") finally: + logging.shutdown() # This will call close() on all handlers logger.info("[Main] Cleanup complete. Goodbye!") sys.exit(0) diff --git a/src/log_handler.py b/src/log_handler.py new file mode 100644 index 00000000..e27b2e97 --- /dev/null +++ b/src/log_handler.py @@ -0,0 +1,275 @@ +""" +Custom in-memory logging handler for thread-safe log storage and retrieval. +""" + +import logging +import collections +from datetime import datetime +from threading import RLock +import sys + + +class MemoryLogHandler(logging.Handler): + """ + Custom logging handler that stores log records in memory for web API access. + Thread-safe implementation with configurable buffer size and separate alert storage. + """ + + def __init__(self, max_records=1000, max_alerts=1000): + super().__init__() + self.max_records = max_records + self.max_alerts = max_alerts + + # Main log buffer (all log levels) + self.records = collections.deque(maxlen=max_records) + + # Dedicated alert buffer (WARNING, ERROR, CRITICAL only) + self.alert_records = collections.deque(maxlen=max_alerts) + + # Use RLock instead of Lock to prevent deadlocks in recursive calls + self.lock = RLock() + # Prevent infinite recursion by tracking if we're already in emit() + self._in_emit = False + # Track shutdown state + self._shutdown = False + + # Define alert levels + self.alert_levels = {'WARNING', 'ERROR', 'CRITICAL'} + + def emit(self, record): + """Store the log record in memory - completely non-blocking version""" + # Don't process logs if we're shutting down + if self._shutdown or self._in_emit: + return + + try: + self._in_emit = True + + # Try to acquire lock with timeout to prevent hanging + if not self.lock.acquire(blocking=False): + return # Skip this log entry if we can't get the lock immediately + + try: + # Double-check shutdown state after acquiring lock + if self._shutdown: + return + + # Create log entry with minimal processing + log_entry = { + "timestamp": datetime.fromtimestamp(record.created).isoformat(), + "level": record.levelname, + "message": ( + str(record.msg) if hasattr(record, "msg") else "No message" + ), + "module": record.name if hasattr(record, "name") else "unknown", + "funcName": ( + record.funcName if hasattr(record, "funcName") else "unknown" + ), + "lineno": record.lineno if hasattr(record, "lineno") else 0, + "severity": self._get_severity_level(record.levelname), + } + + # Handle message formatting safely + if hasattr(record, "args") and record.args: + try: + log_entry["message"] = str(record.msg) % record.args + except (TypeError, ValueError): + log_entry["message"] = f"{record.msg} {record.args}" + + # Store in main buffer (all logs) + self.records.append(log_entry) + + # Store in dedicated alert buffer if it's an alert level + if record.levelname in self.alert_levels: + self.alert_records.append(log_entry) + + finally: + self.lock.release() + + except (OSError, IOError): + # Absolutely no exceptions should escape from a log handler + try: + sys.stderr.write("MemoryLogHandler error: failed to store log entry\n") + sys.stderr.flush() + except (RuntimeError, ValueError): + pass # If even stderr fails, give up silently + finally: + self._in_emit = False + + def get_logs(self, level_filter=None, limit=None, since=None): + """Retrieve logs with optional filtering from main buffer""" + if self._shutdown: + return [] + + try: + if not self.lock.acquire(blocking=False): + return [] # Return empty list if can't get lock + + try: + logs = list(self.records) + finally: + self.lock.release() + except RuntimeError: + return [] + + # Apply filters safely + try: + if level_filter: + logs = [ + log for log in logs if log.get("level", "") == level_filter.upper() + ] + + if since: + try: + since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) + logs = [ + log + for log in logs + if datetime.fromisoformat(log.get("timestamp", "")) >= since_dt + ] + except (ValueError, TypeError): + pass + + if limit and limit > 0: + logs = logs[-limit:] + except (RuntimeError, ValueError): + pass + + return logs + + def get_alerts(self, levels=None, limit=None, since=None): + """Get logs that should be treated as alerts from dedicated alert buffer""" + if self._shutdown: + return [] + + # Use dedicated alert levels if none specified + if levels is None: + levels = list(self.alert_levels) + + try: + if not self.lock.acquire(blocking=False): + return [] + + try: + # Use dedicated alert buffer instead of main buffer + alerts = list(self.alert_records) + finally: + self.lock.release() + + # Apply additional filtering if requested + try: + # Filter by specific levels if provided + if levels != list(self.alert_levels): + alerts = [alert for alert in alerts if alert.get("level", "") in levels] + + # Filter by time if provided + if since: + try: + since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) + alerts = [ + alert + for alert in alerts + if datetime.fromisoformat(alert.get("timestamp", "")) >= since_dt + ] + except (ValueError, TypeError): + pass + + # Apply limit if provided + if limit and limit > 0: + alerts = alerts[-limit:] + + except (RuntimeError, ValueError): + pass + + return alerts + except (RuntimeError, ValueError): + return [] + + def clear_logs(self): + """Clear all stored logs from both buffers""" + if self._shutdown: + return + + try: + if not self.lock.acquire(blocking=False): + return # Skip if can't get lock + + try: + self.records.clear() + self.alert_records.clear() + finally: + self.lock.release() + except (RuntimeError, ValueError): + pass + + def clear_alerts_only(self): + """Clear only the alert buffer, keeping main logs intact""" + if self._shutdown: + return + + try: + if not self.lock.acquire(blocking=False): + return # Skip if can't get lock + + try: + self.alert_records.clear() + finally: + self.lock.release() + except (RuntimeError, ValueError): + pass + + def get_buffer_stats(self): + """Get statistics about buffer usage""" + if self._shutdown: + return {"error": "Handler is shutdown"} + + try: + if not self.lock.acquire(blocking=False): + return {"error": "Could not acquire lock"} + + try: + stats = { + "main_buffer": { + "current_size": len(self.records), + "max_size": self.max_records, + "usage_percent": round((len(self.records) / self.max_records) * 100, 1) + }, + "alert_buffer": { + "current_size": len(self.alert_records), + "max_size": self.max_alerts, + "usage_percent": round((len(self.alert_records) / self.max_alerts) * 100, 1) + }, + "alert_levels": list(self.alert_levels) + } + return stats + finally: + self.lock.release() + except (RuntimeError, ValueError): + return {"error": "Failed to get stats"} + + def shutdown(self): + """ + Shutdown the handler gracefully. + Stops accepting new log entries and clears resources. + """ + try: + with self.lock: + self._shutdown = True + # Optionally clear records to free memory + self.records.clear() + self.alert_records.clear() + except (RuntimeError, ValueError): + # Even shutdown shouldn't raise exceptions + pass + + def close(self): + """ + Close the handler (called by logging framework). + """ + self.shutdown() + super().close() + + def _get_severity_level(self, level_name): + """Convert log level to numeric severity for sorting/filtering""" + levels = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50} + return levels.get(level_name, 0) From a85544943be339e77dc533998474691afa1e1aa8 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Fri, 3 Oct 2025 22:45:51 +0200 Subject: [PATCH 025/132] full refactoring of UI backend + integration of ui log viewer --- src/eos_connect.py | 208 ++- .../current_controls_multi_evcc.test.json | 87 ++ .../test/current_controls_no_evcc.test.json | 41 + .../current_controls_single_evcc.test.json | 57 + src/json/test/optimize_request.test.json | 336 +++++ src/json/test/optimize_response.test.json | 652 +++++++++ src/version.py | 2 +- src/web/{ => css}/style.css | 0 src/web/css/style_legacy.css | 267 ++++ src/web/index.html | 947 +------------ src/web/index_legacy.html | 1191 +++++++++++++++++ src/web/js/battery.js | 99 ++ src/web/js/chart.js | 186 +++ src/web/js/constants.js | 90 ++ src/web/js/controls.js | 340 +++++ src/web/js/data.js | 208 +++ src/web/js/evcc.js | 258 ++++ src/web/js/logging.js | Bin 0 -> 85590 bytes src/web/js/main.js | 222 +++ src/web/js/schedule.js | 143 ++ src/web/js/statistics.js | 58 + src/web/js/ui.js | 387 ++++++ 22 files changed, 4815 insertions(+), 964 deletions(-) create mode 100644 src/json/test/current_controls_multi_evcc.test.json create mode 100644 src/json/test/current_controls_no_evcc.test.json create mode 100644 src/json/test/current_controls_single_evcc.test.json create mode 100644 src/json/test/optimize_request.test.json create mode 100644 src/json/test/optimize_response.test.json rename src/web/{ => css}/style.css (100%) create mode 100644 src/web/css/style_legacy.css create mode 100644 src/web/index_legacy.html create mode 100644 src/web/js/battery.js create mode 100644 src/web/js/chart.js create mode 100644 src/web/js/constants.js create mode 100644 src/web/js/controls.js create mode 100644 src/web/js/data.js create mode 100644 src/web/js/evcc.js create mode 100644 src/web/js/logging.js create mode 100644 src/web/js/main.js create mode 100644 src/web/js/schedule.js create mode 100644 src/web/js/statistics.js create mode 100644 src/web/js/ui.js diff --git a/src/eos_connect.py b/src/eos_connect.py index 2a00b8ca..c5915735 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -11,7 +11,7 @@ import threading import pytz import requests -from flask import Flask, Response, render_template_string, request +from flask import Flask, Response, render_template_string, request, send_from_directory from version import __version__ from config import ConfigManager from log_handler import MemoryLogHandler @@ -92,8 +92,8 @@ def formatTime(self, record, datefmt=None): streamhandler.setFormatter(timezone_formatter) memory_handler = MemoryLogHandler( - max_records=5000, # All log entries (mixed levels) - max_alerts=2000 # Dedicated alert buffer (WARNING/ERROR/CRITICAL only) + max_records=5000, # All log entries (mixed levels) + max_alerts=2000, # Dedicated alert buffer (WARNING/ERROR/CRITICAL only) ) memory_handler.setFormatter(timezone_formatter) # Use timezone formatter for web logs logger.addHandler(memory_handler) @@ -909,6 +909,22 @@ def change_control_state(): app = Flask(__name__) +# legacy web site support +@app.route("/index_legacy.html", methods=["GET"]) +def main_page_legacy(): + """ + Renders the main page of the web application. + + This function reads the content of the 'index.html' file located in the 'web' directory + and returns it as a rendered template string. + """ + with open(base_path + "/web/index_legacy.html", "r", encoding="utf-8") as html_file: + return render_template_string(html_file.read()) + + +# new web site support + + @app.route("/", methods=["GET"]) def main_page(): """ @@ -921,16 +937,62 @@ def main_page(): return render_template_string(html_file.read()) -@app.route("/style.css", methods=["GET"]) -def style_css(): +@app.route("/js/") +def serve_js_files(filename): """ - Serves the CSS file for styling the web application. + Dynamically serve JavaScript files from the js directory. + This allows adding new JS modules without modifying the server code. + """ + try: + js_directory = os.path.join(os.path.dirname(__file__), "web", "js") + + # Security check: only allow .js files + if not filename.endswith(".js"): + logger.warning("[Web] Blocked attempt to serve non-JS file: %s", filename) + return "Not Found", 404 + + # Check if file exists + file_path = os.path.join(js_directory, filename) + if not os.path.exists(file_path): + logger.warning("[Web] JavaScript file not found: %s", filename) + return "Not Found", 404 + + # logger.debug("[Web] Serving JavaScript file: %s", filename) + return send_from_directory( + js_directory, filename, mimetype="application/javascript" + ) + + except (OSError, IOError, ValueError) as e: + logger.error("[Web] Error serving JavaScript file %s: %s", filename, e) + return "Server Error", 500 - This function reads the content of the 'style.css' file located in the 'web' directory - and returns it as a response with the appropriate content type. + +# Also add CSS file serving for completeness +@app.route("/css/") +def serve_css_files(filename): + """ + Dynamically serve CSS files from the web directory. """ - with open(base_path + "/web/style.css", "r", encoding="utf-8") as css_file: - return Response(css_file.read(), content_type="text/css") + try: + web_directory = os.path.join(os.path.dirname(__file__), "web", "css") + + # Security check: only allow .css files + if not filename.endswith(".css"): + logger.warning("[Web] Blocked attempt to serve non-CSS file: %s", filename) + return "Not Found", 404 + + # Check if file exists + file_path = os.path.join(web_directory, filename) + if not os.path.exists(file_path): + logger.warning("[Web] CSS file not found: %s", filename) + return "Not Found", 404 + + # logger.debug("[Web] Serving CSS file: %s", filename) + return send_from_directory(web_directory, filename, mimetype="text/css") + + except (OSError, IOError, ValueError) as e: + logger.error("[Web] Error serving CSS file %s: %s", filename, e) + return "Server Error", 500 @app.route("/json/optimize_request.json", methods=["GET"]) @@ -954,23 +1016,29 @@ def get_optimize_response(): content_type="application/json", ) + @app.route("/json/optimize_request.test.json", methods=["GET"]) def get_optimize_request_test(): """ Retrieves the last optimization request and returns it as a JSON response. """ - with open(base_path + "/json/optimize_request.test.json", "r", encoding="utf-8") as file: + with open( + base_path + "/json/optimize_request.test.json", "r", encoding="utf-8" + ) as file: return Response( file.read(), content_type="application/json", ) + @app.route("/json/optimize_response.test.json", methods=["GET"]) def get_optimize_response_test(): """ Retrieves the last optimization response and returns it as a JSON response. """ - with open(base_path + "/json/optimize_response.test.json", "r", encoding="utf-8") as file: + with open( + base_path + "/json/optimize_response.test.json", "r", encoding="utf-8" + ) as file: return Response( file.read(), content_type="application/json", @@ -1031,6 +1099,61 @@ def get_controls(): ) +@app.route("/json/test/") +def serve_test_json_files(filename): + """ + Dynamically serve test JSON files from the json directory. + This allows adding new test JSON files without modifying the server code. + Supports all test files like current_controls.test.json, optimize_request.test.json, etc. + """ + try: + # Test files are in the json/test/ subdirectory + json_test_directory = os.path.join(os.path.dirname(__file__), "json", "test") + + # Security check: only allow .json files + if not filename.endswith(".json"): + logger.warning("[Web] Blocked attempt to serve non-JSON file: %s", filename) + return Response( + '{"error": "Invalid file type"}', + status=400, + content_type="application/json", + ) + + # Additional security: only allow files with .test.json ending + # (all test files must follow this naming convention) + if not filename.endswith(".test.json"): + logger.warning( + "[Web] Blocked attempt to serve non-test JSON file: %s", filename + ) + return Response( + '{"error": "Access denied - not a test file"}', + status=403, + content_type="application/json", + ) + + # Check if file exists in test directory + file_path = os.path.join(json_test_directory, filename) + if not os.path.exists(file_path): + logger.warning("[Web] Test JSON file not found: %s", filename) + logger.debug("[Web] Looked in directory: %s", json_test_directory) + return Response( + '{"error": "Test file not found"}', + status=404, + content_type="application/json", + ) + + # logger.info("[Web] Serving test JSON file: %s from %s", filename, json_test_directory) + return send_from_directory( + json_test_directory, filename, mimetype="application/json" + ) + + except (OSError, IOError, ValueError) as e: + logger.error("[Web] Error serving test JSON file %s: %s", filename, e) + return Response( + '{"error": "Server error"}', status=500, content_type="application/json" + ) + + @app.route("/controls/mode_override", methods=["POST"]) def handle_mode_override(): """ @@ -1137,41 +1260,35 @@ def handle_mode_override(): content_type="application/json", ) + @app.route("/logs", methods=["GET"]) def get_logs(): """ Retrieve application logs with optional filtering. - + Query parameters: - level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) - limit: Maximum number of records to return (default: 100) - since: ISO timestamp to get logs since that time """ try: - level_filter = request.args.get('level') - limit = int(request.args.get('limit', 100)) - since = request.args.get('since') + level_filter = request.args.get("level") + limit = int(request.args.get("limit", 100)) + since = request.args.get("since") logs = memory_handler.get_logs( - level_filter=level_filter, - limit=limit, - since=since + level_filter=level_filter, limit=limit, since=since ) response_data = { "logs": logs, "total_count": len(logs), "timestamp": datetime.now(time_zone).isoformat(), - "filters_applied": { - "level": level_filter, - "limit": limit, - "since": since - } + "filters_applied": {"level": level_filter, "limit": limit, "since": since}, } return Response( - json.dumps(response_data, indent=2), - content_type="application/json" + json.dumps(response_data, indent=2), content_type="application/json" ) except (ValueError, TypeError, KeyError) as e: @@ -1179,9 +1296,10 @@ def get_logs(): return Response( json.dumps({"error": "Failed to retrieve logs"}), status=500, - content_type="application/json" + content_type="application/json", ) + @app.route("/logs/alerts", methods=["GET"]) def get_alerts(): """ @@ -1192,9 +1310,9 @@ def get_alerts(): # Group alerts by level for easier processing grouped_alerts = { - 'WARNING': [a for a in alerts if a['level'] == 'WARNING'], - 'ERROR': [a for a in alerts if a['level'] == 'ERROR'], - 'CRITICAL': [a for a in alerts if a['level'] == 'CRITICAL'] + "WARNING": [a for a in alerts if a["level"] == "WARNING"], + "ERROR": [a for a in alerts if a["level"] == "ERROR"], + "CRITICAL": [a for a in alerts if a["level"] == "CRITICAL"], } response_data = { @@ -1203,12 +1321,11 @@ def get_alerts(): "alert_counts": { level: len(items) for level, items in grouped_alerts.items() }, - "timestamp": datetime.now(time_zone).isoformat() + "timestamp": datetime.now(time_zone).isoformat(), } return Response( - json.dumps(response_data, indent=2), - content_type="application/json" + json.dumps(response_data, indent=2), content_type="application/json" ) except (ValueError, TypeError, KeyError) as e: @@ -1216,9 +1333,10 @@ def get_alerts(): return Response( json.dumps({"error": "Failed to retrieve alerts"}), status=500, - content_type="application/json" + content_type="application/json", ) + @app.route("/logs/clear", methods=["POST"]) def clear_logs(): """ @@ -1230,7 +1348,7 @@ def clear_logs(): return Response( json.dumps({"status": "success", "message": "Logs cleared"}), - content_type="application/json" + content_type="application/json", ) except (RuntimeError, ValueError, TypeError, KeyError) as e: @@ -1238,9 +1356,10 @@ def clear_logs(): return Response( json.dumps({"error": "Failed to clear logs"}), status=500, - content_type="application/json" + content_type="application/json", ) + @app.route("/logs/alerts/clear", methods=["POST"]) def clear_alerts_only(): """ @@ -1252,7 +1371,7 @@ def clear_alerts_only(): return Response( json.dumps({"status": "success", "message": "Alert logs cleared"}), - content_type="application/json" + content_type="application/json", ) except (RuntimeError, ValueError, TypeError, KeyError) as e: @@ -1260,9 +1379,10 @@ def clear_alerts_only(): return Response( json.dumps({"error": "Failed to clear alert logs"}), status=500, - content_type="application/json" + content_type="application/json", ) + @app.route("/logs/stats", methods=["GET"]) def get_log_stats(): """ @@ -1270,15 +1390,14 @@ def get_log_stats(): """ try: stats = memory_handler.get_buffer_stats() - + response_data = { "buffer_stats": stats, - "timestamp": datetime.now(time_zone).isoformat() + "timestamp": datetime.now(time_zone).isoformat(), } return Response( - json.dumps(response_data, indent=2), - content_type="application/json" + json.dumps(response_data, indent=2), content_type="application/json" ) except (ValueError, TypeError, KeyError) as e: @@ -1286,9 +1405,10 @@ def get_log_stats(): return Response( json.dumps({"error": "Failed to retrieve buffer stats"}), status=500, - content_type="application/json" + content_type="application/json", ) + if __name__ == "__main__": http_server = None try: @@ -1320,7 +1440,7 @@ def get_log_stats(): logger.error("[Main] EOS Connect cannot start without its web interface.") sys.exit(1) - except Exception as e: + except (OSError, ImportError) as e: # Only handle truly unexpected errors (not port-related) logger.error("[Main] Unexpected error: %s", str(e)) logger.error("[Main] EOS Connect cannot start. Please check the logs.") diff --git a/src/json/test/current_controls_multi_evcc.test.json b/src/json/test/current_controls_multi_evcc.test.json new file mode 100644 index 00000000..41ce7a5f --- /dev/null +++ b/src/json/test/current_controls_multi_evcc.test.json @@ -0,0 +1,87 @@ +{ + "current_states": { + "current_ac_charge_demand": 0, + "current_dc_charge_demand": 3200, + "current_discharge_allowed": true, + "inverter_mode": "MODE_AVOID_DISCHARGE_EVCC_FAST", + "inverter_mode_num": 3, + "override_active": false, + "override_end_time": 0 + }, + "evcc": { + "charging_state": true, + "charging_mode": "pv", + "current_sessions": [ + { + "connected": true, + "charging": true, + "vehicleName": "Model Y Performance", + "vehicleSoc": 45.2, + "vehicleRange": 185, + "vehicleOdometer": 28934, + "chargedEnergy": 8200, + "chargeRemainingEnergy": 15800, + "chargeDuration": 2850, + "chargeRemainingDuration": 8750, + "mode": "now", + "targetSoc": 80, + "chargePower": 3200 + }, + { + "connected": true, + "charging": true, + "vehicleName": "Model 3 Standard", + "vehicleSoc": 89.1, + "vehicleRange": 412, + "vehicleOdometer": 67821, + "chargedEnergy": 22100, + "chargeRemainingEnergy": 1900, + "chargeDuration": 0, + "chargeRemainingDuration": 0, + "mode": "pv", + "targetSoc": 90, + "chargePower": 0 + }, + { + "connected": false, + "charging": false, + "vehicleName": "Model S Plaid", + "vehicleSoc": 0, + "vehicleRange": 0, + "vehicleOdometer": 15642, + "chargedEnergy": 0, + "chargeRemainingEnergy": 0, + "chargeDuration": 0, + "chargeRemainingDuration": 0, + "mode": "off", + "targetSoc": 85, + "chargePower": 0 + } + ] + }, + "battery": { + "soc": 45.2, + "usable_capacity": 9500, + "max_charge_power_dyn": 3300, + "max_grid_charge_rate": 5000 + }, + "inverter": { + "inverter_special_data": { + "DEVICE_TEMPERATURE_AMBIENTEMEAN_F32": 22.1, + "MODULE_TEMPERATURE_MEAN_01_F32": 24.5, + "MODULE_TEMPERATURE_MEAN_03_F32": 23.8, + "MODULE_TEMPERATURE_MEAN_04_F32": 21.2, + "FANCONTROL_PERCENT_01_F32": 25.8, + "FANCONTROL_PERCENT_02_F32": 28.4 + } + }, + "state": { + "request_state": "response received", + "last_request_timestamp": "2024-10-03T14:29:45.123Z", + "last_response_timestamp": "2024-10-03T14:30:00.456Z", + "next_run": "2024-10-03T14:35:00.000Z" + }, + "eos_connect_version": "0.2.1", + "timestamp": "2024-10-03T14:30:15.789Z", + "api_version": "0.0.1" +} \ No newline at end of file diff --git a/src/json/test/current_controls_no_evcc.test.json b/src/json/test/current_controls_no_evcc.test.json new file mode 100644 index 00000000..2b52116b --- /dev/null +++ b/src/json/test/current_controls_no_evcc.test.json @@ -0,0 +1,41 @@ +{ + "current_states": { + "current_ac_charge_demand": 0, + "current_dc_charge_demand": 1800, + "current_discharge_allowed": true, + "inverter_mode": "MODE_DISCHARGE_ALLOWED", + "inverter_mode_num": 2, + "override_active": false, + "override_end_time": 0 + }, + "evcc": { + "charging_state": false, + "charging_mode": "off", + "current_sessions": [] + }, + "battery": { + "soc": 85.7, + "usable_capacity": 9500, + "max_charge_power_dyn": 3300, + "max_grid_charge_rate": 5000 + }, + "inverter": { + "inverter_special_data": { + "DEVICE_TEMPERATURE_AMBIENTEMEAN_F32": 19.8, + "MODULE_TEMPERATURE_MEAN_01_F32": 21.2, + "MODULE_TEMPERATURE_MEAN_03_F32": 20.5, + "MODULE_TEMPERATURE_MEAN_04_F32": 18.9, + "FANCONTROL_PERCENT_01_F32": 8.2, + "FANCONTROL_PERCENT_02_F32": 10.1 + } + }, + "state": { + "request_state": "response received", + "last_request_timestamp": "2024-10-03T14:29:45.123Z", + "last_response_timestamp": "2024-10-03T14:30:00.456Z", + "next_run": "2024-10-03T14:35:00.000Z" + }, + "eos_connect_version": "0.2.1", + "timestamp": "2024-10-03T14:30:15.789Z", + "api_version": "0.0.1" +} \ No newline at end of file diff --git a/src/json/test/current_controls_single_evcc.test.json b/src/json/test/current_controls_single_evcc.test.json new file mode 100644 index 00000000..12857ce5 --- /dev/null +++ b/src/json/test/current_controls_single_evcc.test.json @@ -0,0 +1,57 @@ +{ + "current_states": { + "current_ac_charge_demand": 2500, + "current_dc_charge_demand": 1500, + "current_discharge_allowed": false, + "inverter_mode": "MODE_DISCHARGE_ALLOWED_EVCC_PV", + "inverter_mode_num": 4, + "override_active": false, + "override_end_time": 1728049800 + }, + "evcc": { + "charging_state": true, + "charging_mode": "pv", + "current_sessions": [ + { + "connected": true, + "charging": true, + "vehicleName": "Model 3 Long Range", + "vehicleSoc": 68.5, + "vehicleRange": 312, + "vehicleOdometer": 45678, + "chargedEnergy": 12500, + "chargeRemainingEnergy": 8500, + "chargeDuration": 3600, + "chargeRemainingDuration": 5400, + "mode": "pv", + "targetSoc": 85, + "chargePower": 2200 + } + ] + }, + "battery": { + "soc": 75.5, + "usable_capacity": 9500, + "max_charge_power_dyn": 3300, + "max_grid_charge_rate": 5000 + }, + "inverter": { + "inverter_special_data": { + "DEVICE_TEMPERATURE_AMBIENTEMEAN_F32": 23.5, + "MODULE_TEMPERATURE_MEAN_01_F32": 25.2, + "MODULE_TEMPERATURE_MEAN_03_F32": 24.8, + "MODULE_TEMPERATURE_MEAN_04_F32": 22.1, + "FANCONTROL_PERCENT_01_F32": 15.5, + "FANCONTROL_PERCENT_02_F32": 18.2 + } + }, + "state": { + "request_state": "response received", + "last_request_timestamp": "2024-10-03T14:29:45.123Z", + "last_response_timestamp": "2024-10-03T14:30:00.456Z", + "next_run": "2024-10-03T14:35:00.000Z" + }, + "eos_connect_version": "0.2.1", + "timestamp": "2024-10-03T14:30:15.789Z", + "api_version": "0.0.1" +} \ No newline at end of file diff --git a/src/json/test/optimize_request.test.json b/src/json/test/optimize_request.test.json new file mode 100644 index 00000000..461901d0 --- /dev/null +++ b/src/json/test/optimize_request.test.json @@ -0,0 +1,336 @@ +{ + "ems": { + "pv_prognose_wh": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 14.642806901211323, + 48.59127160144763, + 72.02270524067268, + 115.82838852614212, + 217.64653479361104, + 264.3397029186528, + 151.98026891643715, + 84.53886062293004, + 373.6319079602918, + 173.7106321827244, + 58.26617885152783, + 8.983655076545293, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 11.857061499757139, + 62.00636321344536, + 132.66302862000276, + 200.22191439236252, + 238.77424127457877, + 442.18558155297694, + 441.63921994432155, + 465.71867496818913, + 448.6882346462509, + 354.58951316725455, + 312.0142825430191, + 31.862256748155918, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "strompreis_euro_pro_wh": [ + 0.0002185, + 0.0002133, + 0.0002131, + 0.0002109, + 0.0002114, + 0.0002128, + 0.0002142, + 0.0002152, + 0.0002125, + 0.0002105, + 0.0002089, + 0.0002088, + 0.0002075, + 0.0002065, + 0.0002074, + 0.0002089, + 0.0002202, + 0.0003103, + 0.0003446, + 0.0003524, + 0.0003504, + 0.0003335, + 0.0003236, + 0.000318, + 0.0003129, + 0.0003084, + 0.0003032, + 0.0003032, + 0.0003046, + 0.0003203, + 0.0003502, + 0.0003636, + 0.0003636, + 0.0003381, + 0.0003182, + 0.0003016, + 0.000297, + 0.0002936, + 0.0002925, + 0.0003022, + 0.0003098, + 0.0003281, + 0.0003614, + 0.0003766, + 0.0003636, + 0.0003449, + 0.0003252, + 0.000316 + ], + "einspeiseverguetung_euro_pro_wh": [ + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 0, + 0, + 0, + 0, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05 + ], + "preis_euro_pro_wh_akku": 5e-05, + "gesamtlast": [ + 457.01825, + 381.73350000000005, + 328.87925, + 269.65415, + 276.43295, + 275.9443, + 484.8195, + 533.04885, + 615.3107500000001, + 1824.6356999999998, + 2080.1855, + 2666.6145, + 2875.0967500000006, + 2242.4640999999997, + 2185.0618999999997, + 3182.24295, + 2231.1129, + 2318.37935, + 2285.34425, + 2501.2783, + 2075.7241, + 1545.7418, + 569.5178, + 785.47425, + 335.0448, + 336.1595, + 284.03679999999997, + 273.18825000000004, + 264.9767, + 268.6394, + 427.18555, + 461.95804999999996, + 966.0146, + 1323.7368, + 1258.32565, + 1672.6086500000001, + 1889.9933, + 1749.6637, + 1967.11305, + 1850.2103499999998, + 1921.96555, + 1629.21865, + 613.00045, + 1128.8386500000001, + 689.8242, + 446.90115000000003, + 437.75535, + 394.30165 + ] + }, + "pv_akku": { + "device_id": "battery1", + "capacity_wh": 22118, + "charging_efficiency": 0.9, + "discharging_efficiency": 0.9, + "max_charge_power_w": 10000, + "initial_soc_percentage": 99, + "min_soc_percentage": 5, + "max_soc_percentage": 100 + }, + "inverter": { + "device_id": "inverter1", + "max_power_wh": 10000, + "battery_id": "battery1" + }, + "eauto": { + "device_id": "ev1", + "capacity_wh": 27000, + "charging_efficiency": 0.9, + "discharging_efficiency": 0.95, + "max_charge_power_w": 7360, + "initial_soc_percentage": 50, + "min_soc_percentage": 5, + "max_soc_percentage": 100 + }, + "dishwasher": { + "device_id": "additional_load_1", + "consumption_wh": 1, + "duration_h": 1 + }, + "temperature_forecast": [ + 19.6, + 18.8, + 18.5, + 18, + 18.2, + 18.5, + 18.5, + 18, + 19, + 18, + 18.7, + 19.5, + 21.2, + 19.4, + 18.4, + 17, + 15.3, + 14.6, + 14.4, + 13.8, + 12.1, + 12, + 11.8, + 11.3, + 11, + 10.8, + 10.6, + 10.4, + 10.5, + 10.4, + 10.5, + 10.3, + 10, + 9.9, + 9.9, + 10.2, + 10.5, + 10.7, + 10.3, + 10.3, + 10.2, + 10, + 9.8, + 9.5, + 9.3, + 8.9, + 8.7, + 0 + ], + "start_solution": [ + 16.0, + 2.0, + 14.0, + 3.0, + 2.0, + 3.0, + 15.0, + 11.0, + 18.0, + 14.0, + 8.0, + 3.0, + 6.0, + 13.0, + 6.0, + 13.0, + 20.0, + 3.0, + 9.0, + 8.0, + 10.0, + 13.0, + 13.0, + 7.0, + 14.0, + 14.0, + 4.0, + 6.0, + 0.0, + 13.0, + 8.0, + 13.0, + 10.0, + 12.0, + 13.0, + 4.0, + 5.0, + 0.0, + 0.0, + 14.0, + 2.0, + 11.0, + 10.0, + 12.0, + 7.0, + 7.0, + 12.0, + 9.0, + 17.0 + ] +} \ No newline at end of file diff --git a/src/json/test/optimize_response.test.json b/src/json/test/optimize_response.test.json new file mode 100644 index 00000000..2e730792 --- /dev/null +++ b/src/json/test/optimize_response.test.json @@ -0,0 +1,652 @@ +{ + "ac_charge": [ + 0.5, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.375, + 0.0, + 0.75, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.6, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "dc_charge": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ], + "discharge_allowed": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 1, + 0, + 0, + 1, + 0, + 1, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "eautocharge_hours_float": null, + "result": { + "Last_Wh_pro_Stunde": [ + 2319.37935, + 2285.34425, + 2501.2783, + 2075.7241, + 1545.7418, + 569.5178, + 785.47425, + 335.0448, + 336.1595, + 284.03679999999997, + 273.18825000000004, + 264.9767, + 268.6394, + 427.18555, + 461.95804999999996, + 966.0146, + 1323.7368, + 1258.32565, + 1672.6086500000001, + 1889.9933, + 1749.6637, + 1967.11305, + 1850.2103499999998, + 1921.96555, + 1629.21865, + 613.00045, + 1128.8386500000001, + 689.8242, + 446.90115000000003, + 437.75535, + 394.30165 + ], + "EAuto_SoC_pro_Stunde": [ + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0 + ], + "Einnahmen_Euro_pro_Stunde": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "Gesamt_Verluste": 2079.091999999999, + "Gesamtbilanz_Euro": 3.8810030947028977, + "Gesamteinnahmen_Euro": 0.0, + "Gesamtkosten_Euro": 3.8810030947028977, + "Home_appliance_wh_per_hour": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "Kosten_Euro_pro_Stunde": [ + 0.716915784134748, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.10483551792000001, + 0.1036715898, + 0.08611995775999999, + 0.08283067740000001, + 0.08071190281999999, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.3710955974436222, + 0.4301611617765365, + 0.37696625934933964, + 0.4441392584909716, + 0.45197661689085555, + 0.49876290265817264, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.008216546858651494, + 0.12459932139999999 + ], + "Netzbezug_Wh_pro_Stunde": [ + 2310.395694923455, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 335.0448, + 336.1595, + 284.03679999999997, + 273.18825000000004, + 264.9767, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1230.423068447023, + 1448.3540800556784, + 1283.945025031811, + 1518.424815353749, + 1495.6208368327452, + 1609.9512674569808, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 25.26613425169586, + 394.30165 + ], + "Netzeinspeisung_Wh_pro_Stunde": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "Verluste_Pro_Stunde": [ + 0.0, + 253.92713888888875, + 277.9198111111109, + 230.6360111111112, + 171.74908888888876, + 63.27975555555554, + 87.27491666666663, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 29.848822222222225, + 46.14760983336032, + 44.43907630961718, + 92.5946190422219, + 124.83498728973746, + 113.28348985838011, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 177.48404369464924, + 68.11116111111107, + 125.42651666666666, + 76.64713333333327, + 49.65568333333334, + 45.83213508314492, + 0.0 + ], + "akku_soc_pro_stunde": [ + 99.0, + 99.0, + 87.5194348996795, + 74.95411203544624, + 64.52658644040551, + 56.76145899267565, + 53.90045186926686, + 49.95457445419015, + 49.95457445419015, + 49.95457445419015, + 49.95457445419015, + 49.95457445419015, + 49.95457445419015, + 48.605048175945186, + 46.51862038711435, + 44.50943889196935, + 40.32304685642265, + 34.67900185733876, + 29.557223674031952, + 29.557223674031952, + 29.557223674031952, + 29.557223674031952, + 29.557223674031952, + 29.557223674031952, + 29.557223674031952, + 21.53280719448365, + 18.453362348154364, + 12.782573096564409, + 9.317199494370117, + 7.0721645303890455, + 5.0 + ], + "Electricity_price": [ + 0.0003103, + 0.0003446, + 0.0003524, + 0.0003504, + 0.0003335, + 0.0003236, + 0.000318, + 0.0003129, + 0.0003084, + 0.0003032, + 0.0003032, + 0.0003046, + 0.0003203, + 0.0003502, + 0.0003636, + 0.0003636, + 0.0003381, + 0.0003182, + 0.0003016, + 0.000297, + 0.0002936, + 0.0002925, + 0.0003022, + 0.0003098, + 0.0003281, + 0.0003614, + 0.0003766, + 0.0003636, + 0.0003449, + 0.0003252, + 0.000316 + ] + }, + "eauto_obj": { + "device_id": "ev1", + "hours": 48, + "charge_array": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ], + "discharge_array": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "discharging_efficiency": 0.95, + "capacity_wh": 27000, + "charging_efficiency": 0.9, + "max_charge_power_w": 7360, + "soc_wh": 13500.0, + "initial_soc_percentage": 50 + }, + "start_solution": [ + 16.0, + 2.0, + 14.0, + 3.0, + 2.0, + 3.0, + 15.0, + 11.0, + 18.0, + 14.0, + 8.0, + 3.0, + 6.0, + 13.0, + 6.0, + 13.0, + 20.0, + 3.0, + 9.0, + 8.0, + 10.0, + 13.0, + 13.0, + 7.0, + 14.0, + 14.0, + 4.0, + 6.0, + 0.0, + 13.0, + 8.0, + 13.0, + 10.0, + 12.0, + 13.0, + 4.0, + 5.0, + 0.0, + 0.0, + 14.0, + 2.0, + 11.0, + 10.0, + 12.0, + 7.0, + 7.0, + 12.0, + 9.0, + 17.0 + ], + "washingstart": 17, + "timestamp": "2025-09-21T17:22:05.100330+02:00" +} \ No newline at end of file diff --git a/src/version.py b/src/version.py index 9b36e344..e4c9b93e 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = '0.1.24.126-develop' +__version__ = '0.2.01.126-develop' diff --git a/src/web/style.css b/src/web/css/style.css similarity index 100% rename from src/web/style.css rename to src/web/css/style.css diff --git a/src/web/css/style_legacy.css b/src/web/css/style_legacy.css new file mode 100644 index 00000000..74c5cf86 --- /dev/null +++ b/src/web/css/style_legacy.css @@ -0,0 +1,267 @@ +body { + margin: 0; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + display: flex; + flex-direction: column; + height: 100vh; + background-color: rgb(54, 54, 54); + color: lightgray; +} + +.container { + display: flex; + flex-direction: column; + height: 100%; +} + +.top-boxes, +.bottom-boxes { + display: flex; +} + +.top-boxes { + height: 20%; +} + +.top-box { + flex: 1; + margin: 10px; + padding: 10px; + background-color: rgb(78, 78, 78); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + border-radius: 10px; + display: flex; + flex-direction: column; + font-size: clamp(10px, 1.5vh, 18px); /* Dynamically scale font size */ +} + +.bottom-boxes { + height: 80%; +} + +.left-box { + width: 75%; + margin: 10px; + padding: 10px; + border-radius: 10px; + background-color: rgb(78, 78, 78); +} + +.right-box { + width: 24%; + margin: 10px; + padding: 10px; + border-radius: 10px; + background-color: rgb(78, 78, 78); + display: flex; + flex-direction: column; +} + +.header { + background-color: rgb(114, 114, 114); + ; + color: white; + padding: 10px; + text-align: center; + border-radius: 10px; + position: relative; +} + +.header_notification { + background-color: rgb(58, 58, 58); + color: white; + padding: 4px; + text-align: center; + border-radius: 5px; + font-size: 0.65em; + position: absolute; + top: 10px; + right: 10px; +} + +.content { + padding: 10px; + flex: 1; + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 0 5px 0 5px; + text-align: left; +} +th { + text-align:center; +} + +#overlay_menu { + position: fixed; + top: 10%; + left: 20%; + width: 60%; + /* height: 80%; */ + background-color: rgba(0, 0, 0, 0.9); + color: white; + justify-content: center; + align-items: center; + z-index: 1100; + border-radius: 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + overflow: auto; +} + +#overlay_menu_content_wrapper { + width: 100%; + /* height: 100%; */ + border-radius: 10px; + border: solid 1px rgba(255, 255, 255, 0.5); + margin: 10px; +} + +#overlay_menu_content { + padding-bottom: 5%; +} + +#mobileview_rotate { + display: none; +} + +.valueChange { + transition: color 2s ease-out; +} + +/* Media Queries for Smartphones */ +@media (max-width: 768px) { + .top-boxes { + flex-direction: column; + height: auto; + } + + .top-box { + height: auto; + font-size: 0.73em; + } + + .top-box > .header { + font-size: 1.5em; + } + + .top-box > .content { + font-size: 1.2em; + } + + /* .left-box > .header { + font-size: 1.3em; + } */ + + .bottom-boxes { + flex-direction: column; + height: auto; + } + + .left-box, + .right-box { + width: auto; + height: auto; + } + + /* .right-box { + font-size: smaller; + } */ + + .right-box > .content{ + font-size: 1.2em; + } + + #overlay_menu { + top: 10%; + left: 7.5%; + width: 85%; + height: auto; + } + #mobileview_rotate { + display: block; + } +} + +@media (max-height: 768px) and (min-aspect-ratio: 1/1) { + .top-boxes { + display: none; + } + .bottom-boxes { + height: 100%; + } + .left-box { + width: 100%; + } + .content { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 85%; + } + .right-box { + display: none; + } + #mobileview_rotate { + display: none; + } +} + + +.table { + display: table; + width: 100%; + border-collapse: collapse; + font-size: calc(0.35em + 0.9vh); +} + +.table-header, .table-body { + display: table-row-group; +} + +.table-row { + display: table-row; +} + +.table-header .table-cell { + font-style: italic; + text-align: center; + padding-bottom: 0.5em; +} + +.table-body .table-cell:first-child { + font-family: 'Seven Segment', sans-serif; + /* padding-right: 1em; */ +} + +.table-cell { + display: table-cell; + padding: 1px 5px; + text-align: left; + margin: 2px; + /* border-radius: 20px; */ +} + +.table-cell.rounded { + border-radius: 10px; + overflow: hidden; +} + +/* Hide the up/down arrows for number inputs on desktop */ +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* For Firefox */ +input[type="number"] { + -moz-appearance: textfield; +} \ No newline at end of file diff --git a/src/web/index.html b/src/web/index.html index 2e0c6677..d2b54678 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -8,7 +8,7 @@ EOS connect board - + @@ -54,6 +54,21 @@ } } + + + +
@@ -266,926 +281,20 @@
- + + + + + + + + + + - chartInstance.update(); - } - } - window.addEventListener('resize', updateLegendVisibility); - updateLegendVisibility(); - - function writeIfValueChanged(id, value) { - const element = document.getElementById(id); - if (element.innerText !== value) { - element.innerText = value; - element.classList.add('valueChange'); - setTimeout(() => { - element.classList.remove('valueChange'); - }, 1000); // Remove the class after 1 second - } - } - - function overlayMenu(header, content, close = true) { - const overlay = document.getElementById('overlay_menu'); - if (overlay.style.display === 'none') { - overlay.style.display = 'flex'; - document.getElementById('overlay_menu_head').innerHTML = header; - document.getElementById('overlay_menu_content').innerHTML = content; - document.getElementById('overlay_menu_close').style.display = close ? '' : 'none'; - } - } - - function closeOverlayMenu(direct = true) { - const overlay = document.getElementById('overlay_menu'); - if (overlay.style.display === 'flex') { - if (direct) { - overlayMenu('', '', false); - overlay.style.display = 'none'; - } else { - overlay.style.transition = 'opacity 1s'; - overlay.style.opacity = '0'; - setTimeout(() => { - overlayMenu('', '', false); - overlay.style.display = 'none'; - overlay.style.opacity = '1'; - }, 250); - } - } - } - - function getBatteryIcon(soc_value) { - if (soc_value > 90) { - return ''; - } else if (soc_value > 70) { - return ''; - } else if (soc_value > 50) { - return ''; - } else if (soc_value > 30) { - return ''; - } else { - return ''; - } - } - - async function fetch_EOS_Connect_Data(filename) { - const response = await fetch('json/' + filename + '?nocache=' + new Date().getTime()); - const data = await response.json(); - return data; - } - - function updateChart(data_request, data_response) { - // Use server timestamp consistently for data processing - const serverTime = new Date(data_response["timestamp"]); - const currentHour = serverTime.getHours(); - - // Create labels in user's local timezone - showing only hours with :00 - chartInstance.data.labels = Array.from({ length: data_response["result"]["Last_Wh_pro_Stunde"].length }, - (_, i) => { - // Create a new date object for each hour label in user's timezone - const labelTime = new Date(serverTime.getTime() + (i * 60 * 60 * 1000)); - const hour = labelTime.getHours(); - return `${hour.toString().padStart(2, '0')}:00`; - }); - - // Calculate consumption (excluding home appliances) - chartInstance.data.datasets[0].data = data_response["result"]["Last_Wh_pro_Stunde"].map((value, index) => { - const actHomeApplianceValue = data_response["result"]["Home_appliance_wh_per_hour"].map(value => value) - return ((value - actHomeApplianceValue[index]) / 1000).toFixed(3); - }); - - // Home appliances - chartInstance.data.datasets[1].data = data_response["result"]["Home_appliance_wh_per_hour"].map(value => (value / 1000).toFixed(3)); - - // PV forecast - chartInstance.data.datasets[2].data = data_request["ems"]["pv_prognose_wh"].slice(currentHour).concat(data_request["ems"]["pv_prognose_wh"].slice(24, 48)).map(value => (value / 1000).toFixed(3)); - - // Prepare arrays for grid and AC charge with redistribution logic - const gridData = []; - const acChargeData = []; - - data_response["result"]["Netzbezug_Wh_pro_Stunde"].forEach((value, index) => { - const originalAcChargeValue = data_response["ac_charge"].slice(currentHour).concat(data_response["ac_charge"].slice(24, 48))[index] * max_charge_power_w; - let gridValue = (value - originalAcChargeValue) / 1000; - let adjustedAcChargeValue = originalAcChargeValue / 1000; - - // Validation for invalid numbers - if (isNaN(gridValue) || !isFinite(gridValue)) { - console.warn(`Invalid grid calculation at index ${index}: Netzbezug=${value}, AC_charge=${originalAcChargeValue}, using 0 for grid`); - gridValue = 0; - adjustedAcChargeValue = (value / 1000); // Treat all as AC charge - } - // If calculated grid value would be negative, show actual grid data and planned AC charge - else if (gridValue < 0) { - console.info(`Negative calculated grid at index ${index}: ${gridValue.toFixed(3)}kW, showing actual Netzbezug=${(value / 1000).toFixed(3)}kW and planned AC charge=${(originalAcChargeValue / 1000).toFixed(3)}kW`); - // Show actual grid consumption/feed-in from Netzbezug_Wh_pro_Stunde - gridValue = value / 1000; - // Show planned AC charge - adjustedAcChargeValue = originalAcChargeValue / 1000; - } - - gridData.push(gridValue.toFixed(3)); - acChargeData.push(adjustedAcChargeValue.toFixed(3)); - }); - - // Set the calculated data - chartInstance.data.datasets[3].data = gridData; // Grid consumption - chartInstance.data.datasets[4].data = acChargeData; // AC charging (adjusted) - - // Rest of the datasets remain unchanged - chartInstance.data.datasets[5].data = data_response["result"]["akku_soc_pro_stunde"]; - chartInstance.data.datasets[6].data = data_response["result"]["Kosten_Euro_pro_Stunde"]; - chartInstance.data.datasets[7].data = data_response["result"]["Einnahmen_Euro_pro_Stunde"]; - chartInstance.data.datasets[8].data = data_response["result"]["Electricity_price"].map(value => value * 1000); - chartInstance.data.datasets[9].data = data_response["discharge_allowed"].slice(currentHour).concat(data_response["discharge_allowed"].slice(24, 48)); - - chartInstance.update('none'); // Update without animation - } - - function createChart(data_request, data_response) { - const ctx = document.getElementById('energyChart').getContext('2d'); - chartInstance = new Chart(ctx, { - type: 'bar', - data: { - labels: [], - datasets: [ - { label: 'Load', data: [], backgroundColor: 'rgba(75, 192, 192, 0.2)', borderColor: 'rgba(75, 192, 192, 1)', borderWidth: 1, stack: 'load' }, - { label: 'Home Appliance', data: [], backgroundColor: 'rgba(172, 41, 0, 0.4)', borderColor: 'rgba(172, 41, 0, 1)', borderWidth: 1, stack: 'load' }, - { label: 'PV forecast', data: [], backgroundColor: '#FFA500', borderColor: '#FF991C', borderWidth: 1, stack: 'combined' }, - { label: 'Grid', data: [], backgroundColor: 'rgba(128, 128, 128, 0.6)', borderColor: 'rgba(211, 211, 211, 0.7)', borderWidth: 1, stack: 'combined' }, - { label: 'AC Charge', data: [], backgroundColor: 'darkred', borderColor: 'rgba(255, 0, 0, 0.2)', borderWidth: 1, stack: 'combined' }, - { label: 'Akku SOC', data: [], type: 'line', backgroundColor: 'blue', borderColor: 'lightblue', borderWidth: 1, yAxisID: 'y2' }, - { label: 'Expense', data: [], type: 'line', borderColor: 'lightgreen', backgroundColor: 'green', borderWidth: 1, yAxisID: 'y1', stepped: true, hidden: true }, - { label: 'Income', data: [], type: 'line', borderColor: 'lightyellow', backgroundColor: 'yellow', borderWidth: 1, yAxisID: 'y1', stepped: true, hidden: true }, - { label: 'Electricity Price', data: [], type: 'line', borderColor: 'rgba(255, 69, 0, 0.8)', backgroundColor: 'rgba(255, 165, 0, 0.2)', borderWidth: 1, yAxisID: 'y1', stepped: true }, - //{ label: 'Discharge Allowed', data: [], type: 'line', borderColor: 'lightblue', backgroundColor: 'rgba(255, 255, 255, 0.05)', borderWidth: 0, fill: true, yAxisID: 'y3' } - { label: 'Discharge Allowed', data: [], type: 'line', borderColor: 'rgba(144, 238, 144, 0.3)', backgroundColor: 'rgba(144, 238, 144, 0.05)', borderWidth: 1, fill: true, yAxisID: 'y3' } - ] - }, - options: { - scales: { - y: { beginAtZero: true, title: { display: true, text: 'Energy (kWh)', color: 'lightgray' }, grid: { color: 'rgb(54, 54, 54)' }, ticks: { color: 'lightgray' } }, - y1: { beginAtZero: true, position: 'right', title: { display: true, text: 'Price (€)', color: 'lightgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'lightgray', callback: value => value.toFixed(2) } }, - y2: { beginAtZero: true, position: 'right', title: { display: true, text: 'Akku SOC (%)', color: 'darkgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'darkgray', callback: value => value.toFixed(0) } }, - y3: { beginAtZero: true, position: 'right', display: false, title: { display: true, text: 'AC Charge', color: 'darkgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'darkgray', callback: value => value.toFixed(2) } }, - x: { grid: { color: 'rgb(54, 54, 54)' }, ticks: { color: 'lightgray', font: { size: 10 } } } - }, - plugins: { - legend: { display: !isMobile(), labels: { color: 'lightgray' } } - }, - } - }); - updateChart(data_request, data_response); // Feed the content immediately after creation - } - - function setBatteryChargingData(data_response) { - // planned charging - var currentHour = new Date(data_response["timestamp"]).getHours(); // ✅ Use server time - price_data = data_response["result"]["Electricity_price"]; - ac_charge = data_response["ac_charge"]; - //ac_charge = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - - var next_charge_time = ac_charge.slice(currentHour).findIndex((value) => value > 0); - if (next_charge_time !== -1) { - next_charge_time += currentHour; - var next_charge_time_hour = next_charge_time % 24; - document.getElementById('next_charge_time').innerText = next_charge_time_hour + ":00"; - } else { - document.getElementById('next_charge_time').innerText = "--:--"; - } - - // calculate the average price for the next charging hours based on ac_charge - // and calculate the next charge amount - var next_charge_amount = 0; - let total_price = 0; - let total_price_count = 0; - var foundFirst = false; - for (let index = 0; index < ac_charge.slice(currentHour).length; index++) { - const value = ac_charge[currentHour + index]; - - if (value > 0) { - if (!foundFirst) { - foundFirst = true; - } - let current_hour_amount = value * max_charge_power_w; - let current_hour_price = price_data[index] * current_hour_amount; // Convert to ct/kWh - total_price += current_hour_price; - total_price_count += 1; - next_charge_amount += value * max_charge_power_w; - } else if (foundFirst) { - break; // Stop the loop once a 0 is encountered after the first non-zero value - } - } - - let next_charge_avg_price = total_price / next_charge_amount * 100000; - - if (next_charge_amount === 0) { - //document.getElementById('next_charge_header').style.display = "none"; - document.getElementById('next_charge_time').innerText = "not planned"; - document.getElementById('next_charge_summary').style.display = "none"; - document.getElementById('next_charge_summary_2').style.display = "none"; - - } else { - document.getElementById('next_charge_amount').innerText = (next_charge_amount / 1000).toFixed(1) + " kWh"; - // next_charge_sum_price - document.getElementById('next_charge_sum_price').innerText = total_price.toFixed(2) + " €"; - - if (next_charge_time !== -1) { - document.getElementById('next_charge_avg_price').innerText = (next_charge_avg_price).toFixed(1) + " ct/kWh"; - } - - document.getElementById('next_charge_header').style.display = ""; - document.getElementById('next_charge_summary').style.display = ""; - document.getElementById('next_charge_summary_2').style.display = ""; - } - } - - function getChargingColorAndText(evcc_mode, evcc_state) { - let color = "white"; - let text = "N/A"; - - if (evcc_mode === "off") { - text = "Off"; - } else if (evcc_mode === "pv") { - text = "PV"; - if (evcc_state) { - color = COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV; - } - } else if (evcc_mode === "minpv") { - text = "Min+PV"; - if (evcc_state) { - color = COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV; - } - } else if (evcc_mode === "now") { - text = "Fast charge"; - if (evcc_state) { - color = COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST; - } - } else if (evcc_mode === "pv+now" || evcc_mode === "minpv+now") { - text = "Smart Cost Fast"; - if (evcc_state) { - color = COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST; - } - } - - return { color, text }; - - } - - function showSingleLoadpoint(session) { - document.getElementById('ecar_charging_table_single').style.display = ""; - document.getElementById('ecar_charging_table_multiple').style.display = "none"; - document.getElementById('ecar_charging_table_off').style.display = "none"; - - displayName = session["vehicleName"] || "Unknown Vehicle"; - if (displayName.length > 25) - displayName = displayName.substring(0, 25) + "..."; - - writeIfValueChanged('ecar_charging_name', displayName); - writeIfValueChanged('ecar_charging_soc', (session["vehicleSoc"]).toFixed(1) + " %"); - writeIfValueChanged('ecar_charging_odometer', session["vehicleOdometer"] + " km"); - writeIfValueChanged('ecar_charging_range', session["vehicleRange"] + " km"); - writeIfValueChanged('ecar_charging_charged', (session["chargedEnergy"] / 1000).toFixed(1) + " kWh"); - writeIfValueChanged('ecar_charging_charged_remain', (session["chargeRemainingEnergy"] / 1000).toFixed(1) + " kWh"); - let chargeDuration = session["chargeDuration"]; - let hours = Math.floor(chargeDuration / 3600); - let minutes = Math.floor((chargeDuration % 3600) / 60); - writeIfValueChanged('ecar_charging_duration', hours.toString().padStart(2, '0') + ":" + minutes.toString().padStart(2, '0')); - let chargeRemainingDuration = session["chargeRemainingDuration"]; - let remainingHours = Math.floor(chargeRemainingDuration / 3600); - let remainingMinutes = Math.floor((chargeRemainingDuration % 3600) / 60); - writeIfValueChanged('ecar_charging_duration_remain', remainingHours.toString().padStart(2, '0') + ":" + remainingMinutes.toString().padStart(2, '0')); - } - - function showCarChargingData(data_controls) { - // car charging - current states of EVCC - let evcc_mode = data_controls["evcc"]["charging_mode"]; - let evcc_state = data_controls["evcc"]["charging_state"]; - - document.getElementById('evcc_mode').innerText = getChargingColorAndText(evcc_mode, evcc_state).text; - document.getElementById('evcc_mode').style.color = getChargingColorAndText(evcc_mode, evcc_state).color; - document.getElementById('evcc_state').style.color = getChargingColorAndText(evcc_mode, evcc_state).color; - - - if (evcc_state) { - document.getElementById('evcc_state').innerText = "charging"; - } else { - document.getElementById('evcc_state').innerText = "not charging"; - } - - let numOfConnectedVehicles = data_controls["evcc"]["current_sessions"].filter(session => session["connected"]).length; - if (numOfConnectedVehicles > 1) { - data_controls["evcc"]["current_sessions"].forEach((session) => { - if (session["connected"]) { - document.getElementById('ecar_charging_table_single').style.display = "none"; - document.getElementById('ecar_charging_table_multiple').style.display = ""; - document.getElementById('ecar_charging_table_off').style.display = "none"; - - let vehicle_name = session["vehicleName"] || "Unknown Vehicle"; - vehicle_name = vehicle_name.replace(/[^a-zA-Z0-9_]/g, '_'); // sanitize vehicle name for id - - let displayName = session["vehicleName"] || "Unknown Vehicle"; - // reduce vehicle name to 10 characters - if (displayName.length > 10) - displayName = displayName.substring(0, 10) + "..."; - - let row = document.createElement('tr'); - // set id for the row - row.id = 'ecar_charging_row_' + vehicle_name; - row.style.color = getChargingColorAndText(session["mode"], session["charging"]).color; // set color based on charging state - row.innerHTML = ` - - ${displayName} - - - ${(session["vehicleSoc"]).toFixed(1)} % - - - ${(session["chargedEnergy"] / 1000).toFixed(1) + " kWh"} - - - ${session["vehicleRange"] + " km"} - `; - // check if the row already exists - let existingRow = document.getElementById('ecar_charging_row_' + vehicle_name); - if (existingRow) { - // update the existing row - existingRow.style.color = row.style.color; // update color based on charging state - existingRow.innerHTML = row.innerHTML; - } else { - // append the new row to the table - document.getElementById('ecar_charging_table_multiple').appendChild(row); - } - } - }); - // cleanup, remove rows for vehicles that are not connected anymore - let connectedVehicles = data_controls["evcc"]["current_sessions"] - .filter(session => session["connected"]) - .map(session => session["vehicleName"].replace(/[^a-zA-Z0-9_]/g, '_')); - // console.log("Connected Vehicles: ", connectedVehicles); - let rows = document.querySelectorAll('#ecar_charging_table_multiple tr'); - rows.forEach(row => { - // check if the row id starts with 'ecar_charging_row_' and if the vehicle is not connected anymore - // and remove it if not connected - if (row.id.startsWith('ecar_charging_row_') && !connectedVehicles.includes(row.id.replace('ecar_charging_row_', ''))) { - console.log("Removing row: ", row.id); - row.remove(); - } - }); - if (rows.length === 0) { - document.getElementById('ecar_charging_table_single').style.display = "none"; - document.getElementById('ecar_charging_table_multiple').style.display = "none"; - document.getElementById('ecar_charging_table_off').style.display = ""; - } - - } else if (numOfConnectedVehicles == 1) { - let entryOfConnectedVehicle = data_controls["evcc"]["current_sessions"].find(session => session["connected"]); - showSingleLoadpoint(entryOfConnectedVehicle); - } - else { - document.getElementById('ecar_charging_table_single').style.display = "none"; - document.getElementById('ecar_charging_table_multiple').style.display = "none"; - document.getElementById('ecar_charging_table_off').style.display = ""; - } - } - - function adjustGridChargePower(delta) { - const input = document.getElementById("grid_charge_power"); - let newValue = parseFloat(input.value) + delta; - newValue = Math.max(parseFloat(input.min), Math.min(parseFloat(input.max), newValue)); - input.value = newValue.toFixed(1); - } - - function menu_controls_override(icons, ac_charge_max, auto_active) { - const buttons = - icons.map((icon, index) => { - if (index > 2) return; // without special evcc modes - //const isDisabled = index === inverter_mode_num; - const isDisabled = false; - return '' - }).join('') + - (auto_active ? - '
Back To Automatic
' + - '
' : '' - ); - - const duration_entry = - ''; - const ac_charge_power = - '
' + - '' + - '' + - '' + - // ' kW' + - '
'; - const content = - '
' + - buttons + - '
' + - "
" + - 'Duration
' + - '
' + - duration_entry + - '
' + - "
" + - 'Grid Charge Power (kW)
' + - ac_charge_power; - overlayMenu("Override Current Controls", content); - } - - async function showCurrentData() { - //console.log("------- showCurrentControls -------"); - const data_controls = await fetch_EOS_Connect_Data("current_controls.json"); - showCarChargingData(data_controls); - - inverter_mode_text = data_controls["current_states"]["inverter_mode"]; - inverter_mode_num = data_controls["current_states"]["inverter_mode_num"]; - //inverter_mode_num = 5; // for testing - override_active = data_controls["current_states"]["override_active"]; - override_end_time = data_controls["current_states"]["override_end_time"]; - if (override_active) { - inverter_mode_text = ' ' + inverter_mode_text; - document.getElementById('control_ac_charge_desc').innerText = "Override Active"; - document.getElementById('control_ac_charge_desc').style.color = "orange"; - const overrideEndTimeFormatted = new Date(override_end_time * 1000).toLocaleString(navigator.language, { hour: '2-digit', minute: '2-digit' }); - document.getElementById('control_ac_charge').innerText = "until " + overrideEndTimeFormatted; - document.getElementById('control_ac_charge').style.color = "orange"; - if (inverter_mode_num === 0) { - document.getElementById('control_dc_charge_desc').innerText = "AC Charge Power"; - document.getElementById('control_dc_charge').innerText = (data_controls["current_states"]["current_ac_charge_demand"] / 1000).toFixed(1) + " kW"; - } else if (inverter_mode_num === 2) { - document.getElementById('control_dc_charge_desc').innerText = "DC Charge Power"; - document.getElementById('control_dc_charge').innerText = (data_controls["current_states"]["current_dc_charge_demand"] / 1000).toFixed(1) + " kW"; - } else { - document.getElementById('control_dc_charge_desc').innerText = ""; - document.getElementById('control_dc_charge').innerText = ""; - } - document.getElementById('control_discharge_allowed_desc').innerText = ""; - document.getElementById('control_discharge_allowed').innerText = ""; - document.getElementById('current_controls_box').style.border = "1px solid orange"; - } else { - inverter_mode_text = inverter_mode_text.replace("MODE ", ""); - document.getElementById('control_ac_charge_desc').innerText = "AC Charge"; - document.getElementById('control_ac_charge_desc').style.color = ""; - document.getElementById('control_ac_charge').innerText = (data_controls["current_states"]["current_ac_charge_demand"] / 1000).toFixed(1) + " kW"; - document.getElementById('control_ac_charge').style.color = ""; - document.getElementById('control_dc_charge_desc').innerText = "DC Charge"; - document.getElementById('control_dc_charge').innerText = (data_controls["current_states"]["current_dc_charge_demand"] / 1000).toFixed(1) + " kW"; - document.getElementById('control_discharge_allowed_desc').innerText = "Discharge allowed"; - document.getElementById('control_discharge_allowed').innerText = (data_controls["current_states"]["current_discharge_allowed"] === true ? "Yes" : "No"); - document.getElementById('current_controls_box').style.border = ""; - } - - document.getElementById('control_overall').innerHTML = inverter_mode_text.replace("MODE ", ""); - document.getElementById('battery_soc').innerText = data_controls["battery"]["soc"] + " %"; - // set battery icon - document.getElementById('battery_icon_main').innerHTML = getBatteryIcon(data_controls["battery"]["soc"]); - document.getElementById('current_max_charge_dyn').innerHTML = "" + (data_controls["battery"]["max_charge_power_dyn"] / 1000).toFixed(2) + " kW"; - // set battery usable energy - document.getElementById('battery_usable_capacity').innerHTML = ' ' + (data_controls["battery"]["usable_capacity"] / 1000).toFixed(1) + ' kWh'; - document.getElementById('battery_usable_capacity').title = "usable capacity: " + (data_controls["battery"]["usable_capacity"] / 1000).toFixed(1) + " kWh"; - - // dependent on inverter mode show the icon - //0: "MODE_CHARGE_FROM_GRID", - //1: "MODE_AVOID_DISCHARGE", - //2: "MODE_DISCHARGE_ALLOWED", - //3: "MODE_AVOID_DISCHARGE_EVCC_FAST", - //4: "MODE_DISCHARGE_ALLOWED_EVCC_PV", - //5: "MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV", - - const iconElement = document.getElementById('current_header_right'); - iconElement.innerHTML = ""; // Clear previous content - - const icons = [ - { icon: "fa-plug-circle-bolt", color: COLOR_MODE_CHARGE_FROM_GRID, title: "Charge from grid" }, - { icon: "fa-lock", color: COLOR_MODE_AVOID_DISCHARGE, title: "Avoid discharge" }, - { icon: "fa-battery-half", color: COLOR_MODE_DISCHARGE_ALLOWED, title: "Discharge allowed" }, - { icon: "fa-charging-station", color: COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST, title: "Avoid discharge due to e-car fast charge" }, - { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV, title: "Discharge allowed during e-car charging in pv mode" }, - { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, title: "Discharge allowed during e-car charging in min+pv mode" } - ]; - const { icon, color, title } = icons[inverter_mode_num] || {}; - iconElement.innerHTML = ``; - iconElement.style.color = color || ""; - iconElement.title = title || ""; - - if (override_active) { - iconElement.innerHTML = ' ' + iconElement.innerHTML; - } - - const updatedMaxChargePower = data_controls["battery"]["max_charge_power_dyn"] / 1000; - newListener = function () { - console.log("Override active: " + override_active + " - Max charge power: " + updatedMaxChargePower); - menu_controls_override(icons, updatedMaxChargePower, override_active); - }; - - // Remove the old listener if it exists - if (menuControlEventListener) { - iconElement.removeEventListener('click', menuControlEventListener); - } - - // Add the new listener - menuControlEventListener = newListener; - iconElement.addEventListener('click', menuControlEventListener); - - - // energy optimization - last result received - const timestamp_last_run = new Date(data_controls.state.last_response_timestamp); - const timestamp_next_run = new Date(data_controls.state.next_run); - const timestamp_last_run_formatted = timestamp_last_run.toLocaleString(navigator.language, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - document.getElementById('timestamp_last_run').innerText = timestamp_last_run_formatted; - document.getElementById('timestamp_last_run').title = "last run"; - let time_to_next_run = Math.floor((timestamp_next_run - new Date()) / 1000); - let minutes = Math.floor(Math.abs(time_to_next_run) / 60); // Correct calculation for minutes - let seconds = Math.abs(time_to_next_run % 60); - if (time_to_next_run < 0) { - document.getElementById('timestamp_next_run').style.color = "lightgreen"; - // add alt text to the time field - document.getElementById('timestamp_next_run').title = "current optimization running for " + minutes.toString().padStart(2, '0') + " min and " + seconds.toString().padStart(2, '0') + " sec"; - } else { - document.getElementById('timestamp_next_run').style.color = "orange"; - document.getElementById('timestamp_next_run').title = "next optimization run in " + minutes.toString().padStart(2, '0') + " min and " + seconds.toString().padStart(2, '0') + " sec"; - } - document.getElementById('timestamp_next_run').innerText = minutes.toString().padStart(2, '0') + ":" + seconds.toString().padStart(2, '0') + " min"; - - // display current eos connect version - document.getElementById('version_overlay').innerText = "EOS connect version: " + data_controls["eos_connect_version"]; - - document.getElementById('current_header_left').innerHTML = ''; // + " " + data_controls["eos_connect_version"]; - document.getElementById('current_header_left').title = "EOS connect version: " + data_controls["eos_connect_version"]; - document.getElementById('current_header_left').addEventListener('click', function () { - overlayMenu( - "version", - 'currently installed:

' + data_controls["eos_connect_version"] + "

" + - "
" + - '
' + - '' + - '' + - '' + - "
" - ); - }); - } - - function handleModeChange(mode) { - const duration = document.getElementById('duration_time').value; - const gridChargePower = document.getElementById('grid_charge_power').value; - const data_controls = { "mode": mode, "duration": duration, "grid_charge_power": parseFloat(gridChargePower) }; - //console.log("handleModeChange", data_controls); - setOverrideControl(data_controls) - .then((result) => { - //console.log("Override control set successfully:", result); - closeOverlayMenu(); - overlayMenu('Success', "Mode changed successfully.", false); - setTimeout(() => { - closeOverlayMenu(false); - }, 2000); // Close the overlay after 2 seconds - }) - .catch((error) => { - overlayMenu("Error", `Failed to change mode: ${error.message}`); - setTimeout(() => { - overlayMenu("", ""); - }, 2000); // Close the overlay after 2 seconds - }); - } - - function showStatistics(data_request, data_response) { - // set the values for solar yield today and tomorrow - let yield_today = data_request["ems"]["pv_prognose_wh"].slice(0, 24).reduce((acc, value) => acc + value, 0) / 1000; - let yield_tomorrow = data_request["ems"]["pv_prognose_wh"].slice(24, 48).reduce((acc, value) => acc + value, 0) / 1000; - document.getElementById('statistics_header_left').innerHTML = ' ' + yield_today.toFixed(1) + ' kWh'; - document.getElementById('statistics_header_left').title = "Solar yield for today"; - document.getElementById('statistics_header_right').innerHTML = + yield_tomorrow.toFixed(1) + ' kWh' + ' '; - document.getElementById('statistics_header_right').title = "Solar yield for tomorrow"; - - // set expense and income for today and tomorrow - expense_data = data_response["result"]["Kosten_Euro_pro_Stunde"]; - income_data = data_response["result"]["Einnahmen_Euro_pro_Stunde"]; - feed_in_data = data_response["result"]["Netzeinspeisung_Wh_pro_Stunde"]; - - let currentHour = new Date(data_response["timestamp"]).getHours(); // ✅ Use server time - let expense_today = expense_data.slice(0, 24 - currentHour).reduce((acc, value) => acc + value, 0).toFixed(2); - document.getElementById('expense_summary').innerText = expense_today + " €"; - document.getElementById('expense_summary').title = "Expense for the rest of the day"; - - // set income for rest of the day - let income_today = income_data.slice(0, 24 - currentHour).reduce((acc, value) => acc + value, 0).toFixed(2); - document.getElementById('income_summary').innerText = income_today + " €"; - document.getElementById('income_summary').title = "Income for the rest of the day"; - - // set feed in for rest of the day - let feed_in_today = feed_in_data.slice(0, 24 - currentHour).reduce((acc, value) => acc + value, 0) / 1000; - document.getElementById('feed_in_summary').innerText = feed_in_today.toFixed(1) + " kWh"; - document.getElementById('feed_in_summary').title = "Feed in for the rest of the day"; - - } - - function showSchedule(data_request, data_response) { - //console.log("------- showSchedule -------"); - var serverTime = new Date(data_response["timestamp"]); - var currentHour = serverTime.getHours(); - var discharge_allowed = data_response["discharge_allowed"]; - var ac_charge = data_response["ac_charge"]; - - // Add timezone indicator to schedule header - document.getElementById('load_schedule_header').innerHTML = - ` Schedule next 24 hours (Local Time)`; - - ac_charge = ac_charge.map((value, index) => value * max_charge_power_w); - var priceData = data_response["result"]["Electricity_price"]; - var expenseData = data_response["result"]["Kosten_Euro_pro_Stunde"]; - var incomeData = data_response["result"]["Einnahmen_Euro_pro_Stunde"]; - - // clear all entries in div discharge_scheduler - var tableBody = document.querySelector("#discharge_scheduler .table-body"); - tableBody.innerHTML = ''; - - priceData.forEach((value, index) => { - if (index > 23) return; - - if ((index + 1) % 4 === 0 && (index + 1) !== 0) { - var row = document.createElement('div'); - row.className = 'table-row'; - row.style.borderBottom = "1px solid #707070"; - row.style.height = "5px"; - tableBody.appendChild(row); // Append the row to the table body - var row = document.createElement('div'); - row.className = 'table-row'; - row.style.height = "5px"; - tableBody.appendChild(row); // Append the row to the table body - } - - var row = document.createElement('div'); - row.className = 'table-row'; - - var cell1 = document.createElement('div'); - cell1.className = 'table-cell'; - // cell1.innerHTML = ((index + currentHour) % 24) + ":00"; - const labelTime = new Date(serverTime.getTime() + (index * 60 * 60 * 1000)); - cell1.innerHTML = labelTime.getHours().toString().padStart(2, '0') + ":00"; - - cell1.style.textAlign = "right"; - row.appendChild(cell1); - - var cell2 = document.createElement('div'); - cell2.className = 'table-cell'; - const buttonDiv = document.createElement('div'); - buttonDiv.style.border = "1px solid #ccc"; - buttonDiv.style.borderRadius = "5px"; - buttonDiv.style.borderColor = "darkgray"; - buttonDiv.style.width = "50px"; - buttonDiv.style.display = "inline-block"; - buttonDiv.style.textAlign = "center"; - - if (index === 0 && inverter_mode_num > 2) { - // override first hour - if eos connect overriding eos - if (inverter_mode_num === 3) { // MODE_AVOID_DISCHARGE_EVCC_FAST - //buttonDiv.style.backgroundColor = "#3399FF"; - buttonDiv.style.color = COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST; - buttonDiv.innerHTML = " "; - } else if (inverter_mode_num === 4) { // MODE_DISCHARGE_ALLOWED_EVCC_PV - //buttonDiv.style.backgroundColor = "#3399FF"; - buttonDiv.style.color = COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV; - buttonDiv.innerHTML = " "; - } else if (inverter_mode_num === 5) { //MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV - //buttonDiv.style.backgroundColor = "rgb(255, 144, 144)"; - buttonDiv.style.color = COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV; - buttonDiv.innerHTML = " "; - } - } else if (discharge_allowed[(index + currentHour)] === 1) { - //buttonDiv.style.backgroundColor = "grey"; - buttonDiv.style.color = COLOR_MODE_DISCHARGE_ALLOWED; - buttonDiv.innerHTML = ""; - } else if (ac_charge[(index + currentHour)]) { - //buttonDiv.style.backgroundColor = color_bat_grid_charging; - buttonDiv.style.color = COLOR_MODE_CHARGE_FROM_GRID; - let acChargeValue = ac_charge[(index + currentHour)] === 0 ? "" : (ac_charge[(index + currentHour)] / 1000).toFixed(1) + ' kWh'; - buttonDiv.innerHTML = " " + acChargeValue; - buttonDiv.style.padding = "0 10px"; - buttonDiv.style.width = ""; - } else { - //buttonDiv.style.backgroundColor = ""; - buttonDiv.style.color = COLOR_MODE_AVOID_DISCHARGE; - buttonDiv.innerHTML = ""; - } - - cell2.appendChild(buttonDiv); - cell2.style.textAlign = "center"; - row.appendChild(cell2); - - var cell3 = document.createElement('div'); - cell3.className = 'table-cell'; - cell3.innerHTML = (priceData[index] * 100000).toFixed(1); - cell3.style.textAlign = "center"; - row.appendChild(cell3); - - var cell4 = document.createElement('div'); - cell4.className = 'table-cell'; - cell4.innerHTML = (expenseData[index]).toFixed(2); - cell4.style.textAlign = "center"; - row.appendChild(cell4); - - var cell5 = document.createElement('div'); - cell5.className = 'table-cell'; - cell5.innerHTML = (incomeData[index]).toFixed(2); - cell5.style.textAlign = "center"; - row.appendChild(cell5); - - tableBody.appendChild(row); - }); - } - - function handlingErrorInResponse(data_response) { - - if (!data_response || !data_response["result"] || !data_response["result"]["Last_Wh_pro_Stunde"] || data_response["result"]["Last_Wh_pro_Stunde"].length === 0) { - document.getElementById('overlay').style.display = 'flex'; - if (data_response["error"]) { - if (data_response["error"].includes("Request timed out")) { - document.getElementById('waiting_text').innerText = "No processing possible - connection to EOS server timed out"; - document.getElementById('waiting_error_text').innerText = "Error: " + data_response["error"]; - } - else if (data_response["error"].includes("422 Client Error: Unprocessable Entity")) { - document.getElementById('waiting_text').innerText = "Check your configuration! - EOS cannot process the request..."; - document.getElementById('waiting_error_text').innerText = "Error: " + data_response["error"]; - } - else { - document.getElementById('waiting_text').innerText = "No data available..."; - document.getElementById('waiting_error_text').innerText = "no detailed error information available - error message: " + data_response["error"]; - } - } else if (data_response["status"]) { - document.getElementById('waiting_text').innerText = data_response["status"]; - document.getElementById('waiting_error_text').innerText = data_response["message"]; - } - else { - document.getElementById('waiting_text').innerText = "Waiting for first data..."; - document.getElementById('waiting_error_text').innerText = ""; - } - return true; - } - } - - async function setOverrideControl(data_controls) { - try { - const response = await fetch('controls/mode_override', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data_controls) - }); - - if (!response.ok) { - const errorMessage = await response.text(); - throw new Error(`Error ${response.status}: ${errorMessage}`); - } - - return await response.json(); // Return the parsed JSON response - } catch (error) { - console.error("Failed to set override control:", error); - throw error; // Re-throw the error to handle it in the calling function - } - } - // function to observe changed values of doc elements from class "valueChange" and animate the change - Array.from(document.getElementsByClassName("valueChange")).forEach(function (element) { - const observer = new MutationObserver(function (mutationsList, observer) { - const elem = mutationsList[0].target; - // elem.classList.add("animateValue"); - elem.style.color = "black"; //"#2196f3"; //lightgreen - //elem.style.fontSize = "95%"; - setTimeout(function () { - elem.style.color = "inherit";// "#eee"; - //elem.style.fontSize = "100%"; - }, 1000); - - }); - observer.observe(element, { characterData: false, childList: true, attributes: false }); - }); - - async function init() { - // base chart - if (TEST_MODE) { - var data_request = await fetch_EOS_Connect_Data("optimize_request.test.json"); - var data_response = await fetch_EOS_Connect_Data("optimize_response.test.json"); - } else { - var data_request = await fetch_EOS_Connect_Data("optimize_request.json"); - var data_response = await fetch_EOS_Connect_Data("optimize_response.json"); - } - // const data_request = await fetch_EOS_Connect_Data("optimize_request.json"); - // max_charge_power_w according to optimize_request api version - max_charge_power_w = data_request["pv_akku"] && data_request["pv_akku"].hasOwnProperty("max_ladeleistung_w") ? data_request["pv_akku"]["max_ladeleistung_w"] : data_request["pv_akku"] ? data_request["pv_akku"]["max_charge_power_w"] : 0; - - // const data_response = await fetch_EOS_Connect_Data("optimize_response.json"); - - await showCurrentData(); - - // error handling - if (handlingErrorInResponse(data_response)) { - return; - } - - if (chartInstance) { - updateChart(data_request, data_response); - document.getElementById('overlay').style.display = 'none'; - } else { - createChart(data_request, data_response); - document.getElementById('overlay').style.display = 'none'; - } - - showStatistics(data_request, data_response); - showSchedule(data_request, data_response); - setBatteryChargingData(data_response); - updateLegendVisibility(); - } - - init(); - setInterval(init, 1000); - - \ No newline at end of file diff --git a/src/web/index_legacy.html b/src/web/index_legacy.html new file mode 100644 index 00000000..e7677ea7 --- /dev/null +++ b/src/web/index_legacy.html @@ -0,0 +1,1191 @@ + + + + + + + + EOS connect board + + + + + + + + +
+
+

EOS connect

+

loading ... +

+

+ ...

+
+
+
+
+ EOS connect version: v00.00.00
+
+ + +
+
+
+
Current Controls + ... + ... +
+
+ + + + + + + + + + + + + + + + + + + + +
Overall State
+
+
AC Charge--
DC Charge--
Discharge Allowed--
+
+
+
+
Battery State + ... + ... +
+
+ + + + + + + + + + + + + + + + + + + + +
Next AC Charge... next charge time
+
+
Needed Energy... kWh / + ... € +
Avg AC Charging Price... kWh
Dynamic Max AC+DC Charge Power--
+
+
+
+
eCar Charging + ... + ... +
+
+ no car connected + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
...--.- %
+
+
--- km --- km
0.00 kWh 0.00 + kWh
00:00 00:00 +
+ + +
+
+
+
+
Statistics + ... + ... +
+
+ + + + + + + + + + + + + + + + +
Expense (rest of the day) 0.00 €
+
+
Income0.00 €
Feed In0.0 kWh
+
+
+
+
+
+
Optimization + 00:00:00 + + + + 00:00:00 +
+
+ +
+
+
+
Schedule + next 24 hours
+
+
+
+
+
time
+
Control
target state +
+
Price
ct/kWh
+
Expense
+
Income
+
+
+
+   +
+
+
+
Data 1
+
Data 2
+
Data 3
+
Data 4
+
Data 5
+
+
+
Data 5
+
Data 6
+
Data 7
+
Data 8
+
Data 9
+
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/web/js/battery.js b/src/web/js/battery.js new file mode 100644 index 00000000..a30ac340 --- /dev/null +++ b/src/web/js/battery.js @@ -0,0 +1,99 @@ +/** + * Battery Manager for EOS Connect + * Handles battery status, charging data and display + * Extracted from legacy index.html + */ + +class BatteryManager { + constructor() { + console.log('[BatteryManager] Initialized'); + } + + /** + * Initialize battery manager + */ + init() { + console.log('[BatteryManager] Manager initialized'); + } + + /** + * Set battery charging data and update UI elements + */ + setBatteryChargingData(data_response) { + // planned charging + var currentHour = new Date(data_response["timestamp"]).getHours(); // ✅ Use server time + let price_data = data_response["result"]["Electricity_price"]; + let ac_charge = data_response["ac_charge"]; + + var next_charge_time = ac_charge.slice(currentHour).findIndex((value) => value > 0); + if (next_charge_time !== -1) { + next_charge_time += currentHour; + var next_charge_time_hour = next_charge_time % 24; + document.getElementById('next_charge_time').innerText = next_charge_time_hour + ":00"; + } else { + document.getElementById('next_charge_time').innerText = "--:--"; + } + + // calculate the average price for the next charging hours based on ac_charge + // and calculate the next charge amount + var next_charge_amount = 0; + let total_price = 0; + let total_price_count = 0; + var foundFirst = false; + for (let index = 0; index < ac_charge.slice(currentHour).length; index++) { + const value = ac_charge[currentHour + index]; + + if (value > 0) { + if (!foundFirst) { + foundFirst = true; + } + let current_hour_amount = value * max_charge_power_w; + let current_hour_price = price_data[index] * current_hour_amount; // Convert to ct/kWh + total_price += current_hour_price; + total_price_count += 1; + next_charge_amount += value * max_charge_power_w; + } else if (foundFirst) { + break; // Stop the loop once a 0 is encountered after the first non-zero value + } + } + + let next_charge_avg_price = total_price / next_charge_amount * 100000; + + if (next_charge_amount === 0) { + document.getElementById('next_charge_time').innerText = "not planned"; + const nextChargeSummary = document.getElementById('next_charge_summary'); + const nextChargeSummary2 = document.getElementById('next_charge_summary_2'); + if (nextChargeSummary) nextChargeSummary.style.display = "none"; + if (nextChargeSummary2) nextChargeSummary2.style.display = "none"; + } else { + document.getElementById('next_charge_amount').innerText = (next_charge_amount / 1000).toFixed(1) + " kWh"; + + // Set total price + const sumPriceElement = document.getElementById('next_charge_sum_price'); + if (sumPriceElement) { + sumPriceElement.innerText = total_price.toFixed(2) + " €"; + } + + // Set average price if element exists + const avgPriceElement = document.getElementById('next_charge_avg_price'); + if (avgPriceElement && !isNaN(next_charge_avg_price) && isFinite(next_charge_avg_price)) { + avgPriceElement.innerText = next_charge_avg_price.toFixed(1) + " ct/kWh"; + } + + // Display charge summary elements + const nextChargeHeader = document.getElementById('next_charge_header'); + const nextChargeSummary = document.getElementById('next_charge_summary'); + const nextChargeSummary2 = document.getElementById('next_charge_summary_2'); + if (nextChargeHeader) nextChargeHeader.style.display = ""; + if (nextChargeSummary) nextChargeSummary.style.display = ""; + if (nextChargeSummary2) nextChargeSummary2.style.display = ""; + } + } +} + +// Legacy compatibility function +function setBatteryChargingData(data_response) { + if (batteryManager) { + batteryManager.setBatteryChargingData(data_response); + } +} \ No newline at end of file diff --git a/src/web/js/chart.js b/src/web/js/chart.js new file mode 100644 index 00000000..696dc215 --- /dev/null +++ b/src/web/js/chart.js @@ -0,0 +1,186 @@ +/** + * Chart Manager for EOS Connect + * Handles chart creation, updates and display functionality + * Extracted from legacy index.html + */ + +class ChartManager { + constructor() { + this.chartInstance = null; + console.log('[ChartManager] Initialized'); + } + + /** + * Initialize chart manager + */ + init() { + console.log('[ChartManager] Manager initialized'); + } + + /** + * Update existing chart with new data + */ + updateChart(data_request, data_response) { + if (!this.chartInstance) { + console.warn('[ChartManager] No chart instance to update'); + return; + } + + // Use server timestamp consistently for data processing + const serverTime = new Date(data_response["timestamp"]); + const currentHour = serverTime.getHours(); + + // Create labels in user's local timezone - showing only hours with :00 + this.chartInstance.data.labels = Array.from({ length: data_response["result"]["Last_Wh_pro_Stunde"].length }, + (_, i) => { + // Create a new date object for each hour label in user's timezone + const labelTime = new Date(serverTime.getTime() + (i * 60 * 60 * 1000)); + const hour = labelTime.getHours(); + return `${hour.toString().padStart(2, '0')}:00`; + }); + + // Calculate consumption (excluding home appliances) + this.chartInstance.data.datasets[0].data = data_response["result"]["Last_Wh_pro_Stunde"].map((value, index) => { + const actHomeApplianceValue = data_response["result"]["Home_appliance_wh_per_hour"].map(value => value) + return ((value - actHomeApplianceValue[index]) / 1000).toFixed(3); + }); + + // Home appliances + this.chartInstance.data.datasets[1].data = data_response["result"]["Home_appliance_wh_per_hour"].map(value => (value / 1000).toFixed(3)); + + // PV forecast + this.chartInstance.data.datasets[2].data = data_request["ems"]["pv_prognose_wh"].slice(currentHour).concat(data_request["ems"]["pv_prognose_wh"].slice(24, 48)).map(value => (value / 1000).toFixed(3)); + + // Prepare arrays for grid and AC charge with redistribution logic + const gridData = []; + const acChargeData = []; + + data_response["result"]["Netzbezug_Wh_pro_Stunde"].forEach((value, index) => { + const originalAcChargeValue = data_response["ac_charge"].slice(currentHour).concat(data_response["ac_charge"].slice(24, 48))[index] * max_charge_power_w; + let gridValue = (value - originalAcChargeValue) / 1000; + let adjustedAcChargeValue = originalAcChargeValue / 1000; + + // Validation for invalid numbers + if (isNaN(gridValue) || !isFinite(gridValue)) { + console.warn(`Invalid grid calculation at index ${index}: Netzbezug=${value}, AC_charge=${originalAcChargeValue}, using 0 for grid`); + gridValue = 0; + adjustedAcChargeValue = (value / 1000); // Treat all as AC charge + } + // If calculated grid value would be negative, show actual grid data and planned AC charge + else if (gridValue < 0) { + console.info(`Negative calculated grid at index ${index}: ${gridValue.toFixed(3)}kW, showing actual Netzbezug=${(value / 1000).toFixed(3)}kW and planned AC charge=${(originalAcChargeValue / 1000).toFixed(3)}kW`); + // Show actual grid consumption/feed-in from Netzbezug_Wh_pro_Stunde + gridValue = value / 1000; + // Show planned AC charge + adjustedAcChargeValue = originalAcChargeValue / 1000; + } + + gridData.push(gridValue.toFixed(3)); + acChargeData.push(adjustedAcChargeValue.toFixed(3)); + }); + + // Set the calculated data + this.chartInstance.data.datasets[3].data = gridData; // Grid consumption + this.chartInstance.data.datasets[4].data = acChargeData; // AC charging (adjusted) + + // Rest of the datasets remain unchanged + this.chartInstance.data.datasets[5].data = data_response["result"]["akku_soc_pro_stunde"]; + this.chartInstance.data.datasets[6].data = data_response["result"]["Kosten_Euro_pro_Stunde"]; + this.chartInstance.data.datasets[7].data = data_response["result"]["Einnahmen_Euro_pro_Stunde"]; + this.chartInstance.data.datasets[8].data = data_response["result"]["Electricity_price"].map(value => value * 1000); + this.chartInstance.data.datasets[9].data = data_response["discharge_allowed"].slice(currentHour).concat(data_response["discharge_allowed"].slice(24, 48)); + + this.chartInstance.update('none'); // Update without animation + } + + /** + * Create new chart instance + */ + createChart(data_request, data_response) { + const ctx = document.getElementById('energyChart').getContext('2d'); + this.chartInstance = new Chart(ctx, { + type: 'bar', + data: { + labels: [], + datasets: [ + { label: 'Load', data: [], backgroundColor: 'rgba(75, 192, 192, 0.2)', borderColor: 'rgba(75, 192, 192, 1)', borderWidth: 1, stack: 'load' }, + { label: 'Home Appliance', data: [], backgroundColor: 'rgba(172, 41, 0, 0.4)', borderColor: 'rgba(172, 41, 0, 1)', borderWidth: 1, stack: 'load' }, + { label: 'PV forecast', data: [], backgroundColor: '#FFA500', borderColor: '#FF991C', borderWidth: 1, stack: 'combined' }, + { label: 'Grid', data: [], backgroundColor: 'rgba(128, 128, 128, 0.6)', borderColor: 'rgba(211, 211, 211, 0.7)', borderWidth: 1, stack: 'combined' }, + { label: 'AC Charge', data: [], backgroundColor: 'darkred', borderColor: 'rgba(255, 0, 0, 0.2)', borderWidth: 1, stack: 'combined' }, + { label: 'Akku SOC', data: [], type: 'line', backgroundColor: 'blue', borderColor: 'lightblue', borderWidth: 1, yAxisID: 'y2' }, + { label: 'Expense', data: [], type: 'line', borderColor: 'lightgreen', backgroundColor: 'green', borderWidth: 1, yAxisID: 'y1', stepped: true, hidden: true }, + { label: 'Income', data: [], type: 'line', borderColor: 'lightyellow', backgroundColor: 'yellow', borderWidth: 1, yAxisID: 'y1', stepped: true, hidden: true }, + { label: 'Electricity Price', data: [], type: 'line', borderColor: 'rgba(255, 69, 0, 0.8)', backgroundColor: 'rgba(255, 165, 0, 0.2)', borderWidth: 1, yAxisID: 'y1', stepped: true }, + { label: 'Discharge Allowed', data: [], type: 'line', borderColor: 'rgba(144, 238, 144, 0.3)', backgroundColor: 'rgba(144, 238, 144, 0.05)', borderWidth: 1, fill: true, yAxisID: 'y3' } + ] + }, + options: { + scales: { + y: { beginAtZero: true, title: { display: true, text: 'Energy (kWh)', color: 'lightgray' }, grid: { color: 'rgb(54, 54, 54)' }, ticks: { color: 'lightgray' } }, + y1: { beginAtZero: true, position: 'right', title: { display: true, text: 'Price (€)', color: 'lightgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'lightgray', callback: value => value.toFixed(2) } }, + y2: { beginAtZero: true, position: 'right', title: { display: true, text: 'Akku SOC (%)', color: 'darkgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'darkgray', callback: value => value.toFixed(0) } }, + y3: { beginAtZero: true, position: 'right', display: false, title: { display: true, text: 'AC Charge', color: 'darkgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'darkgray', callback: value => value.toFixed(2) } }, + x: { grid: { color: 'rgb(54, 54, 54)' }, ticks: { color: 'lightgray', font: { size: 10 } } } + }, + plugins: { + legend: { display: !isMobile(), labels: { color: 'lightgray' } } + }, + } + }); + + // Set global reference for legacy compatibility + chartInstance = this.chartInstance; + + this.updateChart(data_request, data_response); // Feed the content immediately after creation + } + + /** + * Update legend visibility based on screen size + */ + updateLegendVisibility() { + if (this.chartInstance) { + this.chartInstance.options.plugins.legend.display = !isMobile(); + if (!this.chartInstance.options.scales.y.ticks.font) + this.chartInstance.options.scales.y.ticks.font = {}; + this.chartInstance.options.scales.y.ticks.font.size = isMobile() ? 8 : 12; + + if (!this.chartInstance.options.scales.y1.ticks.font) + this.chartInstance.options.scales.y1.ticks.font = {}; + this.chartInstance.options.scales.y1.ticks.font.size = isMobile() ? 8 : 12; + + if (!this.chartInstance.options.scales.y2.ticks.font) + this.chartInstance.options.scales.y2.ticks.font = {}; + this.chartInstance.options.scales.y2.ticks.font.size = isMobile() ? 8 : 12; + + if (!this.chartInstance.options.scales.x.ticks.font) + this.chartInstance.options.scales.x.ticks.font = {}; + this.chartInstance.options.scales.x.ticks.font.size = isMobile() ? 8 : 12; + + this.chartInstance.options.scales.y.title.display = !isMobile(); + this.chartInstance.options.scales.y1.title.display = !isMobile(); + this.chartInstance.options.scales.y2.title.display = !isMobile(); + + this.chartInstance.update(); + } + } +} + +// Legacy compatibility functions +function createChart(data_request, data_response) { + if (chartManager) { + chartManager.createChart(data_request, data_response); + } +} + +function updateChart(data_request, data_response) { + if (chartManager) { + chartManager.updateChart(data_request, data_response); + } +} + +function updateLegendVisibility() { + if (chartManager) { + chartManager.updateLegendVisibility(); + } +} diff --git a/src/web/js/constants.js b/src/web/js/constants.js new file mode 100644 index 00000000..a8d1c060 --- /dev/null +++ b/src/web/js/constants.js @@ -0,0 +1,90 @@ +/** + * Constants for EOS Connect + * Color modes and other application constants + * Extracted from legacy index.html + */ + +// Battery mode color constants +const COLOR_MODE_CHARGE_FROM_GRID = "rgb(255, 144, 144)"; +const COLOR_MODE_AVOID_DISCHARGE = "lightgray"; +const COLOR_MODE_DISCHARGE_ALLOWED = "lightgreen"; +const COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST = "#3399FF"; +const COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV = "lightgreen"; +const COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV = "darkorange"; + +// Global managers - will be initialized in main.js +let controlsManager; +let scheduleManager; +let chartManager; +let statisticsManager; +let evccManager; +let batteryManager; +let loggingManager; + +// Global variables for application state - matching legacy code +let myChart; +let nightPeriodStart = 22; +let nightPeriodEnd = 6; +let wattPeakPower = null; +let dataChangedSinceLastVisualization = false; +let stepwidth_min = 15; +let last_data_response; +let last_data_request; +let date_german_format = new Date().toLocaleDateString('de-DE'); +let date_us_format = new Date().toISOString().split('T')[0]; + +// Test mode configuration - only activated with ?test=1 parameter +const urlParams = new URLSearchParams(window.location.search); +let isTestMode = urlParams.get('test') === '1'; + +const TEST_SCENARIOS = { + LIVE: null, + SINGLE_EVCC: 'single_evcc', + MULTI_EVCC: 'multi_evcc', + NO_EVCC: 'no_evcc' +}; + +let currentTestScenario = TEST_SCENARIOS.LIVE; + +// Chart configuration constants +const CHART_CONFIG = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top' + }, + title: { + display: true, + text: 'Energy Management' + } + }, + scales: { + x: { + type: 'time', + time: { + unit: 'hour', + displayFormats: { + hour: 'HH:mm' + } + } + }, + y: { + beginAtZero: false + } + } +}; + +// Export for module usage +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + COLOR_MODE_CHARGE_FROM_GRID, + COLOR_MODE_AVOID_DISCHARGE, + COLOR_MODE_DISCHARGE_ALLOWED, + COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST, + COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV, + COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, + CHART_CONFIG + }; +} \ No newline at end of file diff --git a/src/web/js/controls.js b/src/web/js/controls.js new file mode 100644 index 00000000..af4ebb54 --- /dev/null +++ b/src/web/js/controls.js @@ -0,0 +1,340 @@ +/** + * Controls Manager for EOS Connect + * Handles all control-related functionality including override controls, mode changes, and UI interactions + */ + +class ControlsManager { + constructor() { + this.menuControlEventListener = null; + this.icons = [ + { icon: "fa-plug-circle-bolt", color: COLOR_MODE_CHARGE_FROM_GRID, title: "Charge from grid" }, + { icon: "fa-lock", color: COLOR_MODE_AVOID_DISCHARGE, title: "Avoid discharge" }, + { icon: "fa-battery-half", color: COLOR_MODE_DISCHARGE_ALLOWED, title: "Discharge allowed" }, + { icon: "fa-charging-station", color: COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST, title: "Avoid discharge due to e-car fast charge" }, + { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV, title: "Discharge allowed during e-car charging in pv mode" }, + { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, title: "Discharge allowed during e-car charging in min+pv mode" } + ]; + } + + /** + * Initialize controls manager + */ + init() { + console.log('[ControlsManager] Initialized'); + } + + /** + * Adjust grid charge power by delta amount + */ + adjustGridChargePower(delta) { + const input = document.getElementById("grid_charge_power"); + if (!input) return; + + let newValue = parseFloat(input.value) + delta; + newValue = Math.max(parseFloat(input.min), Math.min(parseFloat(input.max), newValue)); + input.value = newValue.toFixed(1); + } + + /** + * Create and show the override controls menu + */ + showOverrideMenu(maxChargePower, overrideActive) { + const buttons = this.icons.map((icon, index) => { + if (index > 2) return; // without special evcc modes + const isDisabled = false; // Could be: index === inverter_mode_num; + + return ``; + }).join('') + (overrideActive ? + `
Back To Automatic
+ ` : '' + ); + + const durationEntry = ``; + + const acChargePower = `
+ + + +
`; + + // Add passive touch event listeners after overlay is created + setTimeout(() => { + const decreaseBtn = document.getElementById('charge-power-decrease'); + const increaseBtn = document.getElementById('charge-power-increase'); + + if (decreaseBtn) { + decreaseBtn.addEventListener('touchstart', function() { + this.style.backgroundColor = 'lightblue'; + }, { passive: true }); + decreaseBtn.addEventListener('touchend', function() { + this.style.color = 'white'; + this.style.backgroundColor = '#444'; + }, { passive: true }); + } + + if (increaseBtn) { + increaseBtn.addEventListener('touchstart', function() { + this.style.backgroundColor = 'lightblue'; + }, { passive: true }); + increaseBtn.addEventListener('touchend', function() { + this.style.color = 'white'; + this.style.backgroundColor = '#444'; + }, { passive: true }); + } + }, 0); + + const content = `
${buttons}
+
+ Duration
+
${durationEntry}
+
+ Grid Charge Power (kW)
${acChargePower}`; + + overlayMenu("Override Current Controls", content); + } + + /** + * Handle mode change button clicks + */ + async handleModeChange(mode) { + const durationElement = document.getElementById('duration_time'); + const gridChargePowerElement = document.getElementById('grid_charge_power'); + + if (!durationElement || !gridChargePowerElement) { + console.error('[ControlsManager] Duration or grid charge power elements not found'); + return; + } + + const duration = durationElement.value; + const gridChargePower = gridChargePowerElement.value; + const controlData = { + "mode": mode, + "duration": duration, + "grid_charge_power": parseFloat(gridChargePower) + }; + + try { + console.log('[ControlsManager] Sending mode change:', controlData); + const result = await dataManager.setOverrideControl(controlData); + + console.log('[ControlsManager] Override control set successfully:', result); + closeOverlayMenu(); + overlayMenu('Success', "Mode changed successfully.", false); + + setTimeout(() => { + closeOverlayMenu(false); + }, 2000); + + } catch (error) { + console.error('[ControlsManager] Failed to change mode:', error); + overlayMenu("Error", `Failed to change mode: ${error.message}`); + setTimeout(() => { + closeOverlayMenu(false); + }, 2000); + } + } + + /** + * Update current controls display + */ + updateCurrentControls(controlsData) { + if (!controlsData || !controlsData.current_states) { + console.warn('[ControlsManager] Invalid controls data provided'); + return; + } + + const states = controlsData.current_states; + const overrideActive = states.override_active; + const overrideEndTime = states.override_end_time; + const inverterModeText = states.inverter_mode; + const inverterModeNum = states.inverter_mode_num; + + // Update overall state + const cleanModeText = inverterModeText.replace("MODE ", ""); + document.getElementById('control_overall').innerHTML = overrideActive ? + ` ${cleanModeText}` : cleanModeText; + + // Update controls based on override state + if (overrideActive) { + this.updateOverrideControls(states, overrideEndTime, inverterModeNum); + } else { + this.updateNormalControls(states); + } + + // Update mode icon and click handler + this.updateModeIcon(inverterModeNum, overrideActive, controlsData.battery.max_charge_power_dyn); + } + + /** + * Update controls when override is active + */ + updateOverrideControls(states, overrideEndTime, inverterModeNum) { + const overrideEndFormatted = new Date(overrideEndTime * 1000).toLocaleString(navigator.language, { + hour: '2-digit', + minute: '2-digit' + }); + + document.getElementById('control_ac_charge_desc').innerText = "Override Active"; + document.getElementById('control_ac_charge_desc').style.color = "orange"; + document.getElementById('control_ac_charge').innerText = "until " + overrideEndFormatted; + document.getElementById('control_ac_charge').style.color = "orange"; + + if (inverterModeNum === 0) { + document.getElementById('control_dc_charge_desc').innerText = "AC Charge Power"; + document.getElementById('control_dc_charge').innerText = (states.current_ac_charge_demand / 1000).toFixed(1) + " kW"; + } else if (inverterModeNum === 2) { + document.getElementById('control_dc_charge_desc').innerText = "DC Charge Power"; + document.getElementById('control_dc_charge').innerText = (states.current_dc_charge_demand / 1000).toFixed(1) + " kW"; + } else { + document.getElementById('control_dc_charge_desc').innerText = ""; + document.getElementById('control_dc_charge').innerText = ""; + } + + document.getElementById('control_discharge_allowed_desc').innerText = ""; + document.getElementById('control_discharge_allowed').innerText = ""; + document.getElementById('current_controls_box').style.border = "1px solid orange"; + } + + /** + * Update controls in normal mode + */ + updateNormalControls(states) { + document.getElementById('control_ac_charge_desc').innerText = "AC Charge"; + document.getElementById('control_ac_charge_desc').style.color = ""; + document.getElementById('control_ac_charge').innerText = (states.current_ac_charge_demand / 1000).toFixed(1) + " kW"; + document.getElementById('control_ac_charge').style.color = ""; + + document.getElementById('control_dc_charge_desc').innerText = "DC Charge"; + document.getElementById('control_dc_charge').innerText = (states.current_dc_charge_demand / 1000).toFixed(1) + " kW"; + + document.getElementById('control_discharge_allowed_desc').innerText = "Discharge allowed"; + document.getElementById('control_discharge_allowed').innerText = states.current_discharge_allowed ? "Yes" : "No"; + + document.getElementById('current_controls_box').style.border = ""; + } + + /** + * Update the mode icon and setup click handler + */ + updateModeIcon(inverterModeNum, overrideActive, maxChargePowerDyn) { + const iconElement = document.getElementById('current_header_right'); + if (!iconElement) return; + + iconElement.innerHTML = ""; // Clear previous content + + const iconData = this.icons[inverterModeNum] || {}; + const { icon, color, title } = iconData; + + iconElement.innerHTML = ``; + iconElement.style.color = color || ""; + iconElement.title = title || ""; + + if (overrideActive) { + iconElement.innerHTML = ' ' + iconElement.innerHTML; + } + + // Setup click handler for override controls + this.setupOverrideClickHandler(iconElement, maxChargePowerDyn / 1000, overrideActive); + } + + /** + * Setup click handler for override controls + */ + setupOverrideClickHandler(iconElement, maxChargePower, overrideActive) { + const newListener = () => { + console.log('[ControlsManager] Override active:', overrideActive, '- Max charge power:', maxChargePower); + this.showOverrideMenu(maxChargePower, overrideActive); + }; + + // Remove old listener if it exists + if (this.menuControlEventListener) { + iconElement.removeEventListener('click', this.menuControlEventListener); + } + + // Add new listener + this.menuControlEventListener = newListener; + iconElement.addEventListener('click', this.menuControlEventListener); + } + + /** + * Show control details (left header click) + */ + showControlDetails() { + // This could show detailed control information + console.log('[ControlsManager] Show control details clicked'); + // Implementation depends on what you want to show + } + + /** + * Cleanup when shutting down + */ + cleanup() { + const iconElement = document.getElementById('current_header_right'); + if (iconElement && this.menuControlEventListener) { + iconElement.removeEventListener('click', this.menuControlEventListener); + this.menuControlEventListener = null; + } + } +} + +// ControlsManager instance is created in main.js during initialization + +// Legacy compatibility functions - keep for backward compatibility +function adjustGridChargePower(delta) { + if (controlsManager) { + return controlsManager.adjustGridChargePower(delta); + } +} + +function menu_controls_override(icons, ac_charge_max, auto_active) { + if (controlsManager) { + return controlsManager.showOverrideMenu(ac_charge_max, auto_active); + } +} + +function handleModeChange(mode) { + if (controlsManager) { + return controlsManager.handleModeChange(mode); + } +} \ No newline at end of file diff --git a/src/web/js/data.js b/src/web/js/data.js new file mode 100644 index 00000000..8c69b7d7 --- /dev/null +++ b/src/web/js/data.js @@ -0,0 +1,208 @@ +/** + * Data Manager for EOS Connect + * Handles all API communication and data fetching + */ + +class DataManager { + constructor() { + this.baseUrl = window.location.origin; + this.cache = new Map(); + this.cacheTimeout = 5000; // 5 second cache to avoid excessive requests + } + + /** + * Fetch data with basic caching to reduce server load + */ + async fetchWithCache(url, cacheKey) { + const now = Date.now(); + const cached = this.cache.get(cacheKey); + + // Return cached data if still valid + if (cached && (now - cached.timestamp) < this.cacheTimeout) { + return cached.data; + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const data = await response.json(); + + // Cache the successful response + this.cache.set(cacheKey, { + data: data, + timestamp: now + }); + + return data; + } catch (error) { + console.error(`[DataManager] Error fetching ${url}:`, error); + + // Return cached data if available, even if expired + if (cached) { + console.warn(`[DataManager] Using expired cache for ${cacheKey}`); + return cached.data; + } + + throw error; + } + } + + /** + * Fetch EOS Connect data files + * This replaces the original fetch_EOS_Connect_Data function + * Routes test files to the dynamic test endpoint + */ + async fetchEOSConnectData(filename) { + // Check if this is a test file and route to dynamic test endpoint + // All test files now follow the .test.json naming convention + const isTestFile = filename.endsWith('.test.json'); + + const basePath = isTestFile ? 'json/test/' : 'json/'; + const url = `${basePath}${filename}?nocache=${new Date().getTime()}`; + return this.fetchWithCache(url, filename); + } + + /** + * Fetch current controls data (battery, inverter, EVCC status) + */ + async fetchCurrentControls(testScenario = null) { + if (testScenario) { + return this.fetchEOSConnectData(`current_controls_${testScenario}.test.json`); + } + return this.fetchEOSConnectData("current_controls.json"); + } + + /** + * Fetch optimization request data + */ + async fetchOptimizationRequest(testMode = false) { + const filename = testMode ? "optimize_request.test.json" : "optimize_request.json"; + return this.fetchEOSConnectData(filename); + } + + /** + * Fetch optimization response data + */ + async fetchOptimizationResponse(testMode = false) { + const filename = testMode ? "optimize_response.test.json" : "optimize_response.json"; + return this.fetchEOSConnectData(filename); + } + + /** + * Send override control commands to server + */ + async setOverrideControl(controlData) { + try { + const response = await fetch('controls/mode_override', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(controlData) + }); + + if (!response.ok) { + const errorMessage = await response.text(); + throw new Error(`Error ${response.status}: ${errorMessage}`); + } + + return await response.json(); + } catch (error) { + console.error("[DataManager] Failed to set override control:", error); + throw error; + } + } + + /** + * Fetch all data needed for initialization + * Returns both request and response data + */ + async fetchAllData(testMode = false, testScenario = null) { + try { + const [requestData, responseData, controlsData] = await Promise.all([ + this.fetchOptimizationRequest(testMode), + this.fetchOptimizationResponse(testMode), + this.fetchCurrentControls(testScenario) + ]); + + return { + request: requestData, + response: responseData, + controls: controlsData + }; + } catch (error) { + console.error("[DataManager] Error fetching all data:", error); + throw error; + } + } + + /** + * Check if response data contains errors + */ + hasErrorInResponse(responseData) { + if (!responseData || + !responseData["result"] || + !responseData["result"]["Last_Wh_pro_Stunde"] || + responseData["result"]["Last_Wh_pro_Stunde"].length === 0) { + return true; + } + return false; + } + + /** + * Get error information from response data + */ + getErrorInfo(responseData) { + if (responseData && responseData["error"]) { + if (responseData["error"].includes("Request timed out")) { + return { + title: "No processing possible - connection to EOS server timed out", + message: "Error: " + responseData["error"] + }; + } else if (responseData["error"].includes("422 Client Error: Unprocessable Entity")) { + return { + title: "Check your configuration! - EOS cannot process the request...", + message: "Error: " + responseData["error"] + }; + } else { + return { + title: "No data available...", + message: "no detailed error information available - error message: " + responseData["error"] + }; + } + } else if (responseData && responseData["status"]) { + return { + title: responseData["status"], + message: responseData["message"] + }; + } else { + return { + title: "Waiting for first data...", + message: "" + }; + } + } + + /** + * Clear cache (useful for forced refresh) + */ + clearCache() { + this.cache.clear(); + console.log("[DataManager] Cache cleared"); + } +} + +// Create global data manager instance +const dataManager = new DataManager(); + +// Legacy compatibility function - keep for now to avoid breaking changes +async function fetch_EOS_Connect_Data(filename) { + return dataManager.fetchEOSConnectData(filename); +} + +// Legacy compatibility function for override controls +async function setOverrideControl(data_controls) { + return dataManager.setOverrideControl(data_controls); +} \ No newline at end of file diff --git a/src/web/js/evcc.js b/src/web/js/evcc.js new file mode 100644 index 00000000..f44ba524 --- /dev/null +++ b/src/web/js/evcc.js @@ -0,0 +1,258 @@ +/** + * EVCC Manager for EOS Connect + * Handles Electric Vehicle Charging Control functionality + * Extracted from legacy index.html + */ + +class EVCCManager { + constructor() { + console.log('[EVCCManager] Initialized'); + } + + /** + * Initialize EVCC manager + */ + init() { + console.log('[EVCCManager] Manager initialized'); + } + + /** + * Get charging color and text based on EVCC mode and state + */ + getChargingColorAndText(evcc_mode, evcc_state) { + let color = "white"; + let text = "N/A"; + + if (evcc_mode === "off") { + text = "Off"; + } else if (evcc_mode === "pv") { + text = "PV"; + if (evcc_state) { + color = COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV; + } + } else if (evcc_mode === "minpv") { + text = "Min+PV"; + if (evcc_state) { + color = COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV; + } + } else if (evcc_mode === "now") { + text = "Fast charge"; + if (evcc_state) { + color = COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST; + } + } else if (evcc_mode === "pv+now" || evcc_mode === "minpv+now") { + text = "Smart Cost Fast"; + if (evcc_state) { + color = COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST; + } + } + + return { color, text }; + } + + /** + * Show single loadpoint UI + */ + showSingleLoadpoint(session) { + const singleTable = document.getElementById('ecar_charging_table_single'); + const multipleTable = document.getElementById('ecar_charging_table_multiple'); + const offTable = document.getElementById('ecar_charging_table_off'); + + if (singleTable) singleTable.style.display = ""; + if (multipleTable) multipleTable.style.display = "none"; + if (offTable) offTable.style.display = "none"; + + const displayName = session["vehicleName"] || "Unknown Vehicle"; + + // Update UI elements if they exist - based on legacy implementation + const vehicleNameElement = document.getElementById('vehicle_name'); + if (vehicleNameElement) { + vehicleNameElement.innerText = displayName; + } + + // Update all single vehicle elements - based on legacy implementation + if (session["vehicleName"]) { + let displayName = session["vehicleName"] || "Unknown Vehicle"; + if (displayName.length > 25) + displayName = displayName.substring(0, 25) + "..."; + writeIfValueChanged('ecar_charging_name', displayName); + } + + if (session["vehicleSoc"] !== undefined) { + writeIfValueChanged('ecar_charging_soc', (session["vehicleSoc"]).toFixed(1) + " %"); + } + + if (session["vehicleOdometer"] !== undefined) { + writeIfValueChanged('ecar_charging_odometer', session["vehicleOdometer"] + " km"); + } + + if (session["vehicleRange"] !== undefined) { + writeIfValueChanged('ecar_charging_range', session["vehicleRange"] + " km"); + } + + if (session["chargedEnergy"] !== undefined) { + writeIfValueChanged('ecar_charging_charged', (session["chargedEnergy"] / 1000).toFixed(1) + " kWh"); + } + + if (session["chargeRemainingEnergy"] !== undefined) { + writeIfValueChanged('ecar_charging_charged_remain', (session["chargeRemainingEnergy"] / 1000).toFixed(1) + " kWh"); + } + + if (session["chargeDuration"] !== undefined) { + let chargeDuration = session["chargeDuration"]; + let hours = Math.floor(chargeDuration / 3600); + let minutes = Math.floor((chargeDuration % 3600) / 60); + writeIfValueChanged('ecar_charging_duration', hours.toString().padStart(2, '0') + ":" + minutes.toString().padStart(2, '0')); + } + + if (session["chargeRemainingDuration"] !== undefined) { + let chargeRemainingDuration = session["chargeRemainingDuration"]; + let remainingHours = Math.floor(chargeRemainingDuration / 3600); + let remainingMinutes = Math.floor((chargeRemainingDuration % 3600) / 60); + writeIfValueChanged('ecar_charging_duration_remain', remainingHours.toString().padStart(2, '0') + ":" + remainingMinutes.toString().padStart(2, '0')); + } + } + + /** + * Show car charging data - handles EVCC display logic + */ + showCarChargingData(data_controls) { + // car charging - current states of EVCC + let evcc_mode = data_controls["evcc"]["charging_mode"]; + let evcc_state = data_controls["evcc"]["charging_state"]; + + this.updateEVCCStatus(evcc_mode, evcc_state); + + let numOfConnectedVehicles = data_controls["evcc"]["current_sessions"].filter(session => session["connected"]).length; + + if (numOfConnectedVehicles > 1) { + this.showMultipleLoadpoints(data_controls["evcc"]["current_sessions"]); + } else if (numOfConnectedVehicles == 1) { + let entryOfConnectedVehicle = data_controls["evcc"]["current_sessions"].find(session => session["connected"]); + this.showSingleLoadpoint(entryOfConnectedVehicle); + } else { + this.showNoConnectedVehicles(); + } + } + + /** + * Show multiple loadpoints (multiple connected vehicles) + */ + showMultipleLoadpoints(sessions) { + document.getElementById('ecar_charging_table_single').style.display = "none"; + document.getElementById('ecar_charging_table_multiple').style.display = ""; + document.getElementById('ecar_charging_table_off').style.display = "none"; + + sessions.forEach((session) => { + if (session["connected"]) { + let vehicle_name = session["vehicleName"] || "Unknown Vehicle"; + vehicle_name = vehicle_name.replace(/[^a-zA-Z0-9_]/g, '_'); // sanitize vehicle name for id + + let displayName = session["vehicleName"] || "Unknown Vehicle"; + // reduce vehicle name to 10 characters + if (displayName.length > 10) + displayName = displayName.substring(0, 10) + "..."; + + let row = document.createElement('tr'); + // set id for the row + row.id = 'ecar_charging_row_' + vehicle_name; + row.style.color = this.getChargingColorAndText(session["mode"], session["charging"]).color; // set color based on charging state + row.innerHTML = ` + + ${displayName} + + + ${(session["vehicleSoc"]).toFixed(1)} % + + + ${(session["chargedEnergy"] / 1000).toFixed(1) + " kWh"} + + + ${session["vehicleRange"] + " km"} + `; + // check if the row already exists + let existingRow = document.getElementById('ecar_charging_row_' + vehicle_name); + if (existingRow) { + // update the existing row + existingRow.style.color = row.style.color; // update color based on charging state + existingRow.innerHTML = row.innerHTML; + } else { + // append the new row to the table + document.getElementById('ecar_charging_table_multiple').appendChild(row); + } + } + }); + + // cleanup, remove rows for vehicles that are not connected anymore + let connectedVehicles = sessions + .filter(session => session["connected"]) + .map(session => (session["vehicleName"] || "Unknown Vehicle").replace(/[^a-zA-Z0-9_]/g, '_')); + + let rows = document.querySelectorAll('#ecar_charging_table_multiple tr'); + rows.forEach(row => { + // check if the row id starts with 'ecar_charging_row_' and if the vehicle is not connected anymore + // and remove it if not connected + if (row.id.startsWith('ecar_charging_row_') && !connectedVehicles.includes(row.id.replace('ecar_charging_row_', ''))) { + console.log("Removing row: ", row.id); + row.remove(); + } + }); + + if (rows.length === 0) { + this.showNoConnectedVehicles(); + } + } + + /** + * Show no connected vehicles state + */ + showNoConnectedVehicles() { + document.getElementById('ecar_charging_table_single').style.display = "none"; + document.getElementById('ecar_charging_table_multiple').style.display = "none"; + document.getElementById('ecar_charging_table_off').style.display = ""; + } + + /** + * Update EVCC status display + */ + updateEVCCStatus(evcc_mode, evcc_state) { + const { color, text } = this.getChargingColorAndText(evcc_mode, evcc_state); + + const modeElement = document.getElementById('evcc_mode'); + const stateElement = document.getElementById('evcc_state'); + + if (modeElement) { + modeElement.innerText = text; + modeElement.style.color = color; + } + + if (stateElement) { + stateElement.innerText = evcc_state ? "Charging" : "Idle"; + stateElement.style.color = color; + } + } +} + +// Legacy compatibility functions +function getChargingColorAndText(evcc_mode, evcc_state) { + if (evccManager) { + return evccManager.getChargingColorAndText(evcc_mode, evcc_state); + } + return { color: "white", text: "N/A" }; +} + +function showSingleLoadpoint(session) { + if (evccManager) { + evccManager.showSingleLoadpoint(session); + } +} + +function showCarChargingData(data_controls) { + if (evccManager) { + evccManager.showCarChargingData(data_controls); + } +} \ No newline at end of file diff --git a/src/web/js/logging.js b/src/web/js/logging.js new file mode 100644 index 0000000000000000000000000000000000000000..3b13cfc912f85fd72cf1b614ce16375536dd1dfa GIT binary patch literal 85590 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 J&wA=G{(t)-30eRE literal 0 HcmV?d00001 diff --git a/src/web/js/main.js b/src/web/js/main.js new file mode 100644 index 00000000..be87f4cd --- /dev/null +++ b/src/web/js/main.js @@ -0,0 +1,222 @@ +/** + * Main application initialization and coordination + * This should contain ALL the initialization logic from the legacy file + */ +/* + * TIME HANDLING STRATEGY + * ====================== + * + * SERVER TIME: Used for all data processing and calculations (from data_response["timestamp"]) + * USER TIME: Used for display - chart labels and schedule show in user's local timezone + * + * This allows users in different timezones to see when events happen in their local time + * while maintaining consistent server-based data processing. + */ +const TEST_MODE = false; +if (TEST_MODE) { + document.body.style.backgroundColor = 'lightgreen'; +} + +let max_charge_power_w = 0; +let inverter_mode_num = -1; +let chartInstance = null; +let menuControlEventListener = null; + +// Constants are now loaded from constants.js + +// Functions now properly modularized - check respective manager files for implementations + +// Set up chart resize handler - this needs to be in main.js +window.addEventListener('resize', () => { + if (chartManager) { + chartManager.updateLegendVisibility(); + } +}); + +// Legacy wrapper functions now in data.js + +// Use handlingErrorInResponse from data.js +function handlingErrorInResponse(data_response) { + if (dataManager.hasErrorInResponse(data_response)) { + const errorInfo = dataManager.getErrorInfo(data_response); + + const overlay = document.getElementById('overlay'); + const waitingText = document.getElementById('waiting_text'); + const errorText = document.getElementById('waiting_error_text'); + + if (overlay) overlay.style.display = 'flex'; + if (waitingText) waitingText.innerText = errorInfo.title; + if (errorText) errorText.innerText = errorInfo.message; + + return true; + } + return false; +} + +// Chart functions now handled by ChartManager in chart.js + +// setBatteryChargingData moved to battery.js manager + +// EVCC functions moved to evcc.js - using manager pattern + +// showCarChargingData moved to evcc.js manager + +// ADD ASYNC KEYWORD HERE - THIS WAS MISSING! +async function showCurrentData() { + //console.log("------- showCurrentControls -------"); + const data_controls = await dataManager.fetchCurrentControls(currentTestScenario); + showCarChargingData(data_controls); + + // Use controlsManager to update controls (check if it exists first) + if (typeof controlsManager !== 'undefined' && controlsManager.updateCurrentControls) { + controlsManager.updateCurrentControls(data_controls); + } + + // Keep the battery and version display logic + document.getElementById('battery_soc').innerText = data_controls["battery"]["soc"] + " %"; + document.getElementById('battery_icon_main').innerHTML = getBatteryIcon(data_controls["battery"]["soc"]); + document.getElementById('current_max_charge_dyn').innerHTML = "" + (data_controls["battery"]["max_charge_power_dyn"] / 1000).toFixed(2) + " kW"; + document.getElementById('battery_usable_capacity').innerHTML = ' ' + (data_controls["battery"]["usable_capacity"] / 1000).toFixed(1) + ' kWh'; + document.getElementById('battery_usable_capacity').title = "usable capacity: " + (data_controls["battery"]["usable_capacity"] / 1000).toFixed(1) + " kWh"; + + // Keep timestamp and version logic + const timestamp_last_run = new Date(data_controls.state.last_response_timestamp); + const timestamp_next_run = new Date(data_controls.state.next_run); + const timestamp_last_run_formatted = timestamp_last_run.toLocaleString(navigator.language, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + document.getElementById('timestamp_last_run').innerText = timestamp_last_run_formatted; + document.getElementById('timestamp_last_run').title = "last run"; + let time_to_next_run = Math.floor((timestamp_next_run - new Date()) / 1000); + let minutes = Math.floor(Math.abs(time_to_next_run) / 60); + let seconds = Math.abs(time_to_next_run % 60); + if (time_to_next_run < 0) { + document.getElementById('timestamp_next_run').style.color = "lightgreen"; + document.getElementById('timestamp_next_run').title = "current optimization running for " + minutes.toString().padStart(2, '0') + " min and " + seconds.toString().padStart(2, '0') + " sec"; + } else { + document.getElementById('timestamp_next_run').style.color = "orange"; + document.getElementById('timestamp_next_run').title = "next optimization run in " + minutes.toString().padStart(2, '0') + " min and " + seconds.toString().padStart(2, '0') + " sec"; + } + document.getElementById('timestamp_next_run').innerText = minutes.toString().padStart(2, '0') + ":" + seconds.toString().padStart(2, '0') + " min"; + + // display current eos connect version + document.getElementById('version_overlay').innerText = "EOS connect version: " + data_controls["eos_connect_version"]; + + const menuElement = document.getElementById('current_header_left'); + menuElement.innerHTML = ''; + menuElement.title = "Menu"; + + // Remove any existing event listeners by cloning the element + const newMenuElement = menuElement.cloneNode(true); + menuElement.parentNode.replaceChild(newMenuElement, menuElement); + + // Add single event listener + newMenuElement.addEventListener('click', function () { + showMainMenu(data_controls["eos_connect_version"]); + }); +} + +// Use manager functions for statistics and schedule +// showStatistics moved to statistics.js manager + +// showSchedule moved to schedule.js manager + +// function to observe changed values of doc elements from class "valueChange" and animate the change +Array.from(document.getElementsByClassName("valueChange")).forEach(function (element) { + const observer = new MutationObserver(function (mutationsList, observer) { + const elem = mutationsList[0].target; + // elem.classList.add("animateValue"); + elem.style.color = "black"; //"#2196f3"; //lightgreen + //elem.style.fontSize = "95%"; + setTimeout(function () { + elem.style.color = "inherit";// "#eee"; + //elem.style.fontSize = "100%"; + }, 1000); + + }); + observer.observe(element, { characterData: false, childList: true, attributes: false }); +}); + +// Updated init function to use dataManager +async function init() { + try { + // Initialize managers if not already done + if (!controlsManager) { + controlsManager = new ControlsManager(); + } + if (!scheduleManager) { + scheduleManager = new ScheduleManager(); + } + if (!statisticsManager) { + statisticsManager = new StatisticsManager(); + } + if (!chartManager) { + chartManager = new ChartManager(); + } + if (!evccManager) { + evccManager = new EVCCManager(); + } + if (!batteryManager) { + batteryManager = new BatteryManager(); + } + if (!loggingManager) { + loggingManager = new LoggingManager(); + } + + // Fetch all data using the new dataManager + const allData = await dataManager.fetchAllData(TEST_MODE, currentTestScenario); + const { request: data_request, response: data_response, controls: data_controls } = allData; + + // Extract max_charge_power_w from request data + max_charge_power_w = data_request["pv_akku"] && data_request["pv_akku"].hasOwnProperty("max_ladeleistung_w") + ? data_request["pv_akku"]["max_ladeleistung_w"] + : data_request["pv_akku"] ? data_request["pv_akku"]["max_charge_power_w"] : 0; + + // Initialize controls manager if not done yet (check if it exists first) + if (typeof controlsManager !== 'undefined' && !controlsManager.initialized) { + controlsManager.init(); + controlsManager.initialized = true; + } + + // Show current data - THIS NEEDS TO BE CALLED EVERY TIME TO UPDATE TIMESTAMPS + await showCurrentData(); + + // Handle errors in response + if (handlingErrorInResponse(data_response)) { + return; + } + + // Update or create chart using chartManager + if (chartManager.chartInstance) { + chartManager.updateChart(data_request, data_response); + document.getElementById('overlay').style.display = 'none'; + } else { + chartManager.createChart(data_request, data_response); + document.getElementById('overlay').style.display = 'none'; + } + + // Update all displays + showStatistics(data_request, data_response); + showSchedule(data_request, data_response); + setBatteryChargingData(data_response); + chartManager.updateLegendVisibility(); + + } catch (error) { + console.error('[EOS Connect] Error during initialization:', error); + + // Show error in overlay + const overlay = document.getElementById('overlay'); + const waitingText = document.getElementById('waiting_text'); + const errorText = document.getElementById('waiting_error_text'); + + if (overlay) overlay.style.display = 'flex'; + if (waitingText) waitingText.innerText = "Connection Error"; + if (errorText) errorText.innerText = error.message; + } +} + +// Initialize and start polling +init(); +setInterval(init, 1000); // Keep 1000ms for now to match legacy behavior \ No newline at end of file diff --git a/src/web/js/schedule.js b/src/web/js/schedule.js new file mode 100644 index 00000000..f6bc4ff7 --- /dev/null +++ b/src/web/js/schedule.js @@ -0,0 +1,143 @@ +/** + * Schedule Manager for EOS Connect + * Handles schedule display and management functionality + * Extracted from legacy index.html + */ + +class ScheduleManager { + constructor() { + console.log('[ScheduleManager] Initialized'); + } + + /** + * Initialize schedule manager + */ + init() { + console.log('[ScheduleManager] Manager initialized'); + } + + /** + * Show schedule for next 24 hours + */ + showSchedule(data_request, data_response) { + //console.log("------- showSchedule -------"); + var serverTime = new Date(data_response["timestamp"]); + var currentHour = serverTime.getHours(); + var discharge_allowed = data_response["discharge_allowed"]; + var ac_charge = data_response["ac_charge"]; + + // Add timezone indicator to schedule header + document.getElementById('load_schedule_header').innerHTML = + ` Schedule next 24 hours (Local Time)`; + + ac_charge = ac_charge.map((value, index) => value * max_charge_power_w); + var priceData = data_response["result"]["Electricity_price"]; + var expenseData = data_response["result"]["Kosten_Euro_pro_Stunde"]; + var incomeData = data_response["result"]["Einnahmen_Euro_pro_Stunde"]; + + // clear all entries in div discharge_scheduler + var tableBody = document.querySelector("#discharge_scheduler .table-body"); + tableBody.innerHTML = ''; + + priceData.forEach((value, index) => { + if (index > 23) return; + + if ((index + 1) % 4 === 0 && (index + 1) !== 0) { + var row = document.createElement('div'); + row.className = 'table-row'; + row.style.borderBottom = "1px solid #707070"; + row.style.height = "5px"; + tableBody.appendChild(row); // Append the row to the table body + var row = document.createElement('div'); + row.className = 'table-row'; + row.style.height = "5px"; + tableBody.appendChild(row); // Append the row to the table body + } + + var row = document.createElement('div'); + row.className = 'table-row'; + + var cell1 = document.createElement('div'); + cell1.className = 'table-cell'; + // cell1.innerHTML = ((index + currentHour) % 24) + ":00"; + const labelTime = new Date(serverTime.getTime() + (index * 60 * 60 * 1000)); + cell1.innerHTML = labelTime.getHours().toString().padStart(2, '0') + ":00"; + + cell1.style.textAlign = "right"; + row.appendChild(cell1); + + var cell2 = document.createElement('div'); + cell2.className = 'table-cell'; + const buttonDiv = document.createElement('div'); + buttonDiv.style.border = "1px solid #ccc"; + buttonDiv.style.borderRadius = "5px"; + buttonDiv.style.borderColor = "darkgray"; + buttonDiv.style.width = "50px"; + buttonDiv.style.display = "inline-block"; + buttonDiv.style.textAlign = "center"; + + if (index === 0 && inverter_mode_num > 2) { + // override first hour - if eos connect overriding eos + if (inverter_mode_num === 3) { // MODE_AVOID_DISCHARGE_EVCC_FAST + //buttonDiv.style.backgroundColor = "#3399FF"; + buttonDiv.style.color = COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST; + buttonDiv.innerHTML = " "; + } else if (inverter_mode_num === 4) { // MODE_DISCHARGE_ALLOWED_EVCC_PV + //buttonDiv.style.backgroundColor = "#3399FF"; + buttonDiv.style.color = COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV; + buttonDiv.innerHTML = " "; + } else if (inverter_mode_num === 5) { //MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV + //buttonDiv.style.backgroundColor = "rgb(255, 144, 144)"; + buttonDiv.style.color = COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV; + buttonDiv.innerHTML = " "; + } + } else if (discharge_allowed[(index + currentHour)] === 1) { + //buttonDiv.style.backgroundColor = "grey"; + buttonDiv.style.color = COLOR_MODE_DISCHARGE_ALLOWED; + buttonDiv.innerHTML = ""; + } else if (ac_charge[(index + currentHour)]) { + //buttonDiv.style.backgroundColor = color_bat_grid_charging; + buttonDiv.style.color = COLOR_MODE_CHARGE_FROM_GRID; + let acChargeValue = ac_charge[(index + currentHour)] === 0 ? "" : (ac_charge[(index + currentHour)] / 1000).toFixed(1) + ' kWh'; + buttonDiv.innerHTML = " " + acChargeValue; + buttonDiv.style.padding = "0 10px"; + buttonDiv.style.width = ""; + } else { + //buttonDiv.style.backgroundColor = ""; + buttonDiv.style.color = COLOR_MODE_AVOID_DISCHARGE; + buttonDiv.innerHTML = ""; + } + + cell2.appendChild(buttonDiv); + cell2.style.textAlign = "center"; + row.appendChild(cell2); + + var cell3 = document.createElement('div'); + cell3.className = 'table-cell'; + cell3.innerHTML = (priceData[index] * 100000).toFixed(1); + cell3.style.textAlign = "center"; + row.appendChild(cell3); + + var cell4 = document.createElement('div'); + cell4.className = 'table-cell'; + cell4.innerHTML = (expenseData[index]).toFixed(2); + cell4.style.textAlign = "center"; + row.appendChild(cell4); + + var cell5 = document.createElement('div'); + cell5.className = 'table-cell'; + cell5.innerHTML = (incomeData[index]).toFixed(2); + cell5.style.textAlign = "center"; + row.appendChild(cell5); + + tableBody.appendChild(row); + }); + } +} + +// Legacy compatibility function +function showSchedule(data_request, data_response) { + if (scheduleManager) { + scheduleManager.showSchedule(data_request, data_response); + } +} diff --git a/src/web/js/statistics.js b/src/web/js/statistics.js new file mode 100644 index 00000000..1e56c158 --- /dev/null +++ b/src/web/js/statistics.js @@ -0,0 +1,58 @@ +/** + * Statistics Manager for EOS Connect + * Handles statistics display and calculations + * Extracted from legacy index.html + */ + +class StatisticsManager { + constructor() { + console.log('[StatisticsManager] Initialized'); + } + + /** + * Initialize statistics manager + */ + init() { + console.log('[StatisticsManager] Manager initialized'); + } + + /** + * Show statistics including solar yield, expenses, income and feed-in data + */ + showStatistics(data_request, data_response) { + // set the values for solar yield today and tomorrow + let yield_today = data_request["ems"]["pv_prognose_wh"].slice(0, 24).reduce((acc, value) => acc + value, 0) / 1000; + let yield_tomorrow = data_request["ems"]["pv_prognose_wh"].slice(24, 48).reduce((acc, value) => acc + value, 0) / 1000; + document.getElementById('statistics_header_left').innerHTML = ' ' + yield_today.toFixed(1) + ' kWh'; + document.getElementById('statistics_header_left').title = "Solar yield for today"; + document.getElementById('statistics_header_right').innerHTML = + yield_tomorrow.toFixed(1) + ' kWh' + ' '; + document.getElementById('statistics_header_right').title = "Solar yield for tomorrow"; + + // set expense and income for today and tomorrow + let expense_data = data_response["result"]["Kosten_Euro_pro_Stunde"]; + let income_data = data_response["result"]["Einnahmen_Euro_pro_Stunde"]; + let feed_in_data = data_response["result"]["Netzeinspeisung_Wh_pro_Stunde"]; + + let currentHour = new Date(data_response["timestamp"]).getHours(); // ✅ Use server time + let expense_today = expense_data.slice(0, 24 - currentHour).reduce((acc, value) => acc + value, 0).toFixed(2); + document.getElementById('expense_summary').innerText = expense_today + " €"; + document.getElementById('expense_summary').title = "Expense for the rest of the day"; + + // set income for rest of the day + let income_today = income_data.slice(0, 24 - currentHour).reduce((acc, value) => acc + value, 0).toFixed(2); + document.getElementById('income_summary').innerText = income_today + " €"; + document.getElementById('income_summary').title = "Income for the rest of the day"; + + // set feed in for rest of the day + let feed_in_today = feed_in_data.slice(0, 24 - currentHour).reduce((acc, value) => acc + value, 0) / 1000; + document.getElementById('feed_in_summary').innerText = feed_in_today.toFixed(1) + " kWh"; + document.getElementById('feed_in_summary').title = "Feed in for the rest of the day"; + } +} + +// Legacy compatibility function +function showStatistics(data_request, data_response) { + if (statisticsManager) { + statisticsManager.showStatistics(data_request, data_response); + } +} diff --git a/src/web/js/ui.js b/src/web/js/ui.js new file mode 100644 index 00000000..0111061b --- /dev/null +++ b/src/web/js/ui.js @@ -0,0 +1,387 @@ +/** + * UI helper functions, overlays, animations + * Extracted from legacy index.html + */ + +function isMobile() { + return window.innerWidth <= 768; +} + +function writeIfValueChanged(id, value) { + const element = document.getElementById(id); + if (element.innerText !== value) { + element.innerText = value; + element.classList.add('valueChange'); + setTimeout(() => { + element.classList.remove('valueChange'); + }, 1000); // Remove the class after 1 second + } +} + +function overlayMenu(header, content, close = true) { + const overlay = document.getElementById('overlay_menu'); + // Always update content, whether overlay is open or closed + overlay.style.display = 'flex'; + document.getElementById('overlay_menu_head').innerHTML = header; + document.getElementById('overlay_menu_content').innerHTML = content; + document.getElementById('overlay_menu_close').style.display = close ? '' : 'none'; +} + +function closeOverlayMenu(direct = true) { + const overlay = document.getElementById('overlay_menu'); + if (overlay.style.display === 'flex') { + if (direct) { + overlayMenu('', '', false); + overlay.style.display = 'none'; + } else { + overlay.style.transition = 'opacity 1s'; + overlay.style.opacity = '0'; + setTimeout(() => { + overlayMenu('', '', false); + overlay.style.display = 'none'; + overlay.style.opacity = '1'; + }, 250); + } + } +} + +function getBatteryIcon(soc_value) { + if (soc_value > 90) { + return ''; + } else if (soc_value > 70) { + return ''; + } else if (soc_value > 50) { + return ''; + } else if (soc_value > 30) { + return ''; + } else { + return ''; + } +} + +// Initialize value change observers +function initializeValueChangeObservers() { + Array.from(document.getElementsByClassName("valueChange")).forEach(function (element) { + const observer = new MutationObserver(function (mutationsList, observer) { + const elem = mutationsList[0].target; + elem.style.color = "black"; + setTimeout(function () { + elem.style.color = "inherit"; + }, 1000); + }); + observer.observe(element, { characterData: false, childList: true, attributes: false }); + }); +} + +/** + * Test Control Functions for Development and Testing + */ +function toggleTestPanel() { + const testControls = document.getElementById('test_controls'); + + if (testControls.style.display === 'none' || testControls.style.display === '') { + testControls.style.display = 'block'; + } else { + testControls.style.display = 'none'; + } +} + +function switchTestScenario() { + const select = document.getElementById('test_scenario_select'); + const scenario = select.value; + + if (scenario === 'live') { + currentTestScenario = TEST_SCENARIOS.LIVE; + } else { + currentTestScenario = scenario; + } + + console.log('[TestMode] Switched to scenario:', currentTestScenario); + + // Automatically refresh data when switching scenarios + refreshTestData(); +} + +async function refreshTestData() { + console.log('[TestMode] Refreshing data with scenario:', currentTestScenario); + + // Force refresh by calling init() which will use the current test scenario + if (typeof init === 'function') { + await init(); + } +} + +function showTestPanel() { + const panel = document.getElementById('test_control_panel'); + if (panel) { + panel.style.display = 'block'; + } +} + +function hideTestPanel() { + const panel = document.getElementById('test_control_panel'); + if (panel) { + panel.style.display = 'none'; + } +} + +// Initialize test panel on page load +document.addEventListener('DOMContentLoaded', function() { + // Show test panel ONLY if URL contains test=1 parameter + const urlParams = new URLSearchParams(window.location.search); + const isTestParam = urlParams.get('test') === '1'; + + if (isTestParam) { + console.log('[TestMode] Test mode activated via ?test=1 parameter'); + setTimeout(() => showTestPanel(), 1000); // Show after page loads + } else { + console.log('[TestMode] Test mode not activated (no ?test=1 parameter)'); + } +}); + +/** + * Show main dropdown menu near the menu icon + */ +function showMainMenu(version) { + // Remove existing dropdown if present + const existingDropdown = document.getElementById('main-dropdown-menu'); + if (existingDropdown) { + existingDropdown.remove(); + return; // Toggle behavior - close if already open + } + + // Create dropdown menu + const dropdown = document.createElement('div'); + dropdown.id = 'main-dropdown-menu'; + dropdown.style.cssText = ` + position: absolute; + top: 45px; + left: 10px; + background-color: rgb(58, 58, 58); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + min-width: 180px; + padding: 8px 0; + font-size: 0.9em; + `; + + dropdown.innerHTML = ` +
+ + Alarms +
+ +
+ + Logs +
+ +
+ + Info +
+ +
+ +
+
+ + Changelog +
+ +
+ +
+
+ + Bug Report +
+ +
+ `; + + // Find the menu icon parent container to position relative to it + const menuIcon = document.getElementById('current_header_left'); + const parentBox = menuIcon.closest('.top-box'); + + // Add relative positioning to parent if not already present + if (getComputedStyle(parentBox).position === 'static') { + parentBox.style.position = 'relative'; + } + + // Append dropdown to parent container + parentBox.appendChild(dropdown); + + // Add click outside listener to close dropdown + setTimeout(() => { + document.addEventListener('click', handleClickOutside, true); + }, 0); +} + +/** + * Close dropdown menu + */ +function closeDropdownMenu() { + const dropdown = document.getElementById('main-dropdown-menu'); + if (dropdown) { + dropdown.remove(); + document.removeEventListener('click', handleClickOutside, true); + } +} + +/** + * Handle clicks outside dropdown to close it + */ +function handleClickOutside(event) { + const dropdown = document.getElementById('main-dropdown-menu'); + const menuIcon = document.getElementById('current_header_left'); + + if (dropdown && !dropdown.contains(event.target) && !menuIcon.contains(event.target)) { + closeDropdownMenu(); + } +} + +/** + * Show alarms menu using LoggingManager + */ +function showAlarmsMenu() { + if (loggingManager) { + loggingManager.showAlertsPanel(); + } else { + overlayMenu("Alarms", "Logging system not initialized", false); + setTimeout(() => closeOverlayMenu(false), 2000); + } +} + +/** + * Show logs menu using LoggingManager + */ +function showLogsMenu() { + if (loggingManager) { + loggingManager.showLogsPanel(); + } else { + overlayMenu("Logs", "Logging system not initialized", false); + setTimeout(() => closeOverlayMenu(false), 2000); + } +} + +/** + * Show info menu (original version info) + */ +function showInfoMenu(version) { + const infoContent = + 'currently installed:

' + version + "

" + + "
" + + '
' + + '' + + '' + + '' + + "
"; + + overlayMenu("Version Info", infoContent); +} + +/** + * Create full-screen overlay for logs with small margins + */ +function showFullScreenOverlay(header, content, close = true) { + // Create overlay if it doesn't exist + let overlay = document.getElementById('full_screen_overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'full_screen_overlay'; + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + display: none; + z-index: 1000; + padding: 60px; + box-sizing: border-box; + `; + document.body.appendChild(overlay); + } + + // Create content container + overlay.innerHTML = ` +
+ +
+ ${header} + ${close ? '' : ''} +
+ + +
+ ${content} +
+
+ `; + + overlay.style.display = 'flex'; + + // Add escape key listener + const escapeHandler = (e) => { + if (e.key === 'Escape') { + closeFullScreenOverlay(); + } + }; + document.addEventListener('keydown', escapeHandler); + overlay.escapeHandler = escapeHandler; // Store for cleanup +} + +/** + * Close full-screen overlay + */ +function closeFullScreenOverlay() { + const overlay = document.getElementById('full_screen_overlay'); + if (overlay) { + overlay.style.display = 'none'; + // Remove escape key listener + if (overlay.escapeHandler) { + document.removeEventListener('keydown', overlay.escapeHandler); + } + // Stop auto-refresh when closing overlay + if (typeof loggingManager !== 'undefined' && loggingManager.stopAutoRefresh) { + loggingManager.stopAutoRefresh(); + } + } +} From c23bba2782152154088d857398734023432c2abb Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 4 Oct 2025 08:39:34 +0200 Subject: [PATCH 026/132] fix: update logging prefixes for consistency and clarity across API and control modules --- src/eos_connect.py | 14 ++++----- src/interfaces/base_control.py | 54 ++++++++++++++++---------------- src/interfaces/evcc_interface.py | 2 ++ 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/eos_connect.py b/src/eos_connect.py index c5915735..0b7c84d2 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -1292,7 +1292,7 @@ def get_logs(): ) except (ValueError, TypeError, KeyError) as e: - logger.error("[API] Error retrieving logs: %s", e) + logger.error("[Web] Error retrieving logs: %s", e) return Response( json.dumps({"error": "Failed to retrieve logs"}), status=500, @@ -1329,7 +1329,7 @@ def get_alerts(): ) except (ValueError, TypeError, KeyError) as e: - logger.error("[API] Error retrieving alerts: %s", e) + logger.error("[Web] Error retrieving alerts: %s", e) return Response( json.dumps({"error": "Failed to retrieve alerts"}), status=500, @@ -1344,7 +1344,7 @@ def clear_logs(): """ try: memory_handler.clear_logs() - logger.info("[API] Memory logs cleared via web API") + logger.info("[Web] Memory logs cleared via web API") return Response( json.dumps({"status": "success", "message": "Logs cleared"}), @@ -1352,7 +1352,7 @@ def clear_logs(): ) except (RuntimeError, ValueError, TypeError, KeyError) as e: - logger.error("[API] Error clearing logs: %s", e) + logger.error("[Web] Error clearing logs: %s", e) return Response( json.dumps({"error": "Failed to clear logs"}), status=500, @@ -1367,7 +1367,7 @@ def clear_alerts_only(): """ try: memory_handler.clear_alerts_only() - logger.info("[API] Alert logs cleared via web API") + logger.info("[Web] Alert logs cleared via web API") return Response( json.dumps({"status": "success", "message": "Alert logs cleared"}), @@ -1375,7 +1375,7 @@ def clear_alerts_only(): ) except (RuntimeError, ValueError, TypeError, KeyError) as e: - logger.error("[API] Error clearing alert logs: %s", e) + logger.error("[Web] Error clearing alert logs: %s", e) return Response( json.dumps({"error": "Failed to clear alert logs"}), status=500, @@ -1401,7 +1401,7 @@ def get_log_stats(): ) except (ValueError, TypeError, KeyError) as e: - logger.error("[API] Error retrieving buffer stats: %s", e) + logger.error("[Web] Error retrieving buffer stats: %s", e) return Response( json.dumps({"error": "Failed to retrieve buffer stats"}), status=500, diff --git a/src/interfaces/base_control.py b/src/interfaces/base_control.py index 100393e7..de492c23 100644 --- a/src/interfaces/base_control.py +++ b/src/interfaces/base_control.py @@ -10,7 +10,7 @@ from datetime import datetime logger = logging.getLogger("__main__") -logger.info("[BASE_CTRL] loading module ") +logger.info("[BASE-CTRL] loading module ") MODE_CHARGE_FROM_GRID = 0 MODE_AVOID_DISCHARGE = 1 @@ -100,7 +100,7 @@ def get_current_bat_charge_max(self): Returns the current maximum battery charge power. """ logger.debug( - "[BASE_CTRL] get current battery charge max %s", self.current_bat_charge_max + "[BASE-CTRL] get current battery charge max %s", self.current_bat_charge_max ) return self.current_bat_charge_max @@ -160,7 +160,7 @@ def set_current_ac_charge_demand(self, value_relative): if not self.override_active: self.current_ac_charge_demand = current_charge_demand logger.debug( - "[BASE_CTRL] set AC charge demand for current hour %s:00 -> %s Wh -" + "[BASE-CTRL] set AC charge demand for current hour %s:00 -> %s Wh -" + " based on max charge power %s W", current_hour, self.current_ac_charge_demand, @@ -168,7 +168,7 @@ def set_current_ac_charge_demand(self, value_relative): ) else: logger.debug( - "[BASE_CTRL] OVERRIDE AC charge demand for current hour %s:00 -> %s Wh -" + "[BASE-CTRL] OVERRIDE AC charge demand for current hour %s:00 -> %s Wh -" + " based on max charge power %s W", current_hour, self.current_ac_charge_demand, @@ -189,7 +189,7 @@ def set_current_dc_charge_demand(self, value_relative): if not self.override_active: self.current_dc_charge_demand = current_charge_demand logger.debug( - "[BASE_CTRL] set DC charge demand for current hour %s:00 -> %s Wh -" + "[BASE-CTRL] set DC charge demand for current hour %s:00 -> %s Wh -" + " based on max charge power %s W", current_hour, self.current_dc_charge_demand, @@ -197,7 +197,7 @@ def set_current_dc_charge_demand(self, value_relative): ) else: logger.debug( - "[BASE_CTRL] OVERRIDE DC charge demand for current hour %s:00 -> %s Wh -" + "[BASE-CTRL] OVERRIDE DC charge demand for current hour %s:00 -> %s Wh -" + " based on max charge power %s W", current_hour, self.current_dc_charge_demand, @@ -212,7 +212,7 @@ def set_current_bat_charge_max(self, value_max): # store the current charge demand without override self.current_bat_charge_max = value_max logger.debug( - "[BASE_CTRL] set current battery charge max to %s", self.current_bat_charge_max + "[BASE-CTRL] set current battery charge max to %s", self.current_bat_charge_max ) self.__set_current_overall_state() @@ -223,7 +223,7 @@ def set_current_discharge_allowed(self, value): current_hour = datetime.now(self.time_zone).hour self.current_discharge_allowed = value logger.debug( - "[BASE_CTRL] set Discharge allowed for current hour %s:00 %s", + "[BASE-CTRL] set Discharge allowed for current hour %s:00 %s", current_hour, self.current_discharge_allowed, ) @@ -234,7 +234,7 @@ def set_current_evcc_charging_state(self, value): Sets the current EVCC charging state. """ self.current_evcc_charging_state = value - # logger.debug("[BASE_CTRL] set current EVCC charging state to %s", value) + # logger.debug("[BASE-CTRL] set current EVCC charging state to %s", value) self.__set_current_overall_state() def set_current_evcc_charging_mode(self, value): @@ -242,7 +242,7 @@ def set_current_evcc_charging_mode(self, value): Sets the current EVCC charging mode. """ self.current_evcc_charging_mode = value - # logger.debug("[BASE_CTRL] set current EVCC charging mode to %s", value) + # logger.debug("[BASE-CTRL] set current EVCC charging mode to %s", value) self.__set_current_overall_state() def __set_current_overall_state(self): @@ -252,7 +252,7 @@ def __set_current_overall_state(self): if self.override_active: # check if the override end time is reached if time.time() > self.override_end_time: - logger.info("[BASE_CTRL] OVERRIDE end time reached, clearing override") + logger.info("[BASE-CTRL] OVERRIDE end time reached, clearing override") self.clear_mode_override() return return @@ -284,7 +284,7 @@ def __set_current_overall_state(self): ): new_state = MODE_AVOID_DISCHARGE_EVCC_FAST logger.info( - "[BASE_CTRL] EVCC charging state is active," + "[BASE-CTRL] EVCC charging state is active," + " setting overall state to MODE_AVOID_DISCHARGE_EVCC_FAST" ) @@ -297,7 +297,7 @@ def __set_current_overall_state(self): ): new_state = MODE_DISCHARGE_ALLOWED_EVCC_PV logger.info( - "[BASE_CTRL] EVCC charging state is active," + "[BASE-CTRL] EVCC charging state is active," + " setting overall state to MODE_DISCHARGE_ALLOWED_EVCC_PV" ) @@ -310,7 +310,7 @@ def __set_current_overall_state(self): ): new_state = MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV logger.info( - "[BASE_CTRL] EVCC charging state is active," + "[BASE-CTRL] EVCC charging state is active," + " setting overall state to MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV" ) @@ -327,22 +327,22 @@ def __set_current_overall_state(self): self._state_change_timestamps.pop(0) if grid_charge_value_changed: logger.info( - "[BASE_CTRL] AC charge demand changed to %s W", + "[BASE-CTRL] AC charge demand changed to %s W", self.current_ac_charge_demand, ) elif dc_charge_value_changed: logger.info( - "[BASE_CTRL] DC charge demand changed to %s W", + "[BASE-CTRL] DC charge demand changed to %s W", self.current_dc_charge_demand, ) elif bat_charge_max_value_changed: logger.info( - "[BASE_CTRL] Battery charge max changed to %s W", + "[BASE-CTRL] Battery charge max changed to %s W", self.current_bat_charge_max, ) else: logger.debug( - "[BASE_CTRL] overall state changed to %s", + "[BASE-CTRL] overall state changed to %s", state_mapping.get(new_state, "unknown state"), ) # store the last AC charge demand for comparison @@ -359,7 +359,7 @@ def set_current_battery_soc(self, value): Sets the current battery state of charge (SOC). """ self.current_battery_soc = value - # logger.debug("[BASE_CTRL] set current battery SOC to %s", value) + # logger.debug("[BASE-CTRL] set current battery SOC to %s", value) def set_mode_override(self, mode, duration, charge_rate): """ @@ -375,7 +375,7 @@ def set_mode_override(self, mode, duration, charge_rate): duration_seconds = duration * 60 # duration_seconds = duration * 60 / 10 else: - logger.error("[BASE_CTRL] OVERRIDE invalid duration %s", duration) + logger.error("[BASE-CTRL] OVERRIDE invalid duration %s", duration) return if mode >= 0 or mode <= 2: @@ -384,7 +384,7 @@ def set_mode_override(self, mode, duration, charge_rate): self.override_end_time = (time.time() + duration_seconds) // 60 * 60 self._state_change_timestamps.append(time.time()) logger.info( - "[BASE_CTRL] OVERRIDE set overall state to %s with endtime %s", + "[BASE-CTRL] OVERRIDE set overall state to %s with endtime %s", state_mapping[mode], datetime.fromtimestamp( self.override_end_time, self.time_zone @@ -393,17 +393,17 @@ def set_mode_override(self, mode, duration, charge_rate): if charge_rate > 0 and mode == MODE_CHARGE_FROM_GRID: self.current_ac_charge_demand = charge_rate * 1000 logger.info( - "[BASE_CTRL] OVERRIDE set AC charge demand to %s", + "[BASE-CTRL] OVERRIDE set AC charge demand to %s", self.current_ac_charge_demand, ) if charge_rate > 0 and mode == MODE_DISCHARGE_ALLOWED: self.current_dc_charge_demand = charge_rate * 1000 logger.info( - "[BASE_CTRL] OVERRIDE set DC charge demand to %s", + "[BASE-CTRL] OVERRIDE set DC charge demand to %s", self.current_dc_charge_demand, ) else: - logger.error("[BASE_CTRL] OVERRIDE invalid mode %s", mode) + logger.error("[BASE-CTRL] OVERRIDE invalid mode %s", mode) def clear_mode_override(self): """ @@ -415,7 +415,7 @@ def clear_mode_override(self): self.current_dc_charge_demand = self.current_dc_charge_demand_no_override self.__set_current_overall_state() # reset the override end time to 0 - logger.info("[BASE_CTRL] cleared mode override") + logger.info("[BASE-CTRL] cleared mode override") def __start_update_service(self): """ @@ -427,7 +427,7 @@ def __start_update_service(self): target=self.__update_base_control_loop, daemon=True ) self._update_thread.start() - logger.info("[BASE_CTRL] Update service started.") + logger.info("[BASE-CTRL] Update service started.") def shutdown(self): """ @@ -436,7 +436,7 @@ def shutdown(self): if self._update_thread and self._update_thread.is_alive(): self._stop_event.set() self._update_thread.join() - logger.info("[BASE_CTRL] Update service stopped.") + logger.info("[BASE-CTRL] Update service stopped.") def __update_base_control_loop(self): """ diff --git a/src/interfaces/evcc_interface.py b/src/interfaces/evcc_interface.py index f40a9e88..fd7cc1d7 100644 --- a/src/interfaces/evcc_interface.py +++ b/src/interfaces/evcc_interface.py @@ -353,6 +353,8 @@ def __get_summerized_charging_state_n_mode(self, collected_states_modes): if sum_mode_priority < CHARGING_MODE_PRIORITY[mode]: sum_mode_priority = CHARGING_MODE_PRIORITY[mode] sum_charging_mode = mode + if entry.get("smartCostActive", False): + sum_smart_cost_active = True # if no loadpoints are charging, set charging mode to the first one if sum_charging_state is False: sum_charging_mode = ( From d3ba49fecdcb0603730d058a37042cfe461d69dd Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 4 Oct 2025 14:24:49 +0200 Subject: [PATCH 027/132] feat: introduce web ui system log view with several filters and alert dashboard incl. notification via menu and menu entries --- src/eos_connect.py | 2 +- src/web/index.html | 28 ++++- src/web/js/logging.js | Bin 85590 -> 161720 bytes src/web/js/main.js | 40 +++++-- src/web/js/ui.js | 246 +++++++++++++++++++++++++++++++++++++++--- 5 files changed, 287 insertions(+), 29 deletions(-) diff --git a/src/eos_connect.py b/src/eos_connect.py index 0b7c84d2..a3fe2895 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -92,7 +92,7 @@ def formatTime(self, record, datefmt=None): streamhandler.setFormatter(timezone_formatter) memory_handler = MemoryLogHandler( - max_records=5000, # All log entries (mixed levels) + max_records=10000, # All log entries (mixed levels) max_alerts=2000, # Dedicated alert buffer (WARNING/ERROR/CRITICAL only) ) memory_handler.setFormatter(timezone_formatter) # Use timezone formatter for web logs diff --git a/src/web/index.html b/src/web/index.html index d2b54678..eded49c6 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -16,7 +16,7 @@
-
+

EOS connect

loading ...

@@ -24,8 +24,32 @@ style="font-size: 1.0em; font-weight: bold; margin-bottom: 20px; text-align: center; color:rgb(241, 177, 0)"> ...

+ style="border: 4px solid white; border-top: 4px solid gray; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto;">
+ + +
diff --git a/src/web/js/logging.js b/src/web/js/logging.js index 3b13cfc912f85fd72cf1b614ce16375536dd1dfa..b1d160e314e2aab8905326098217b251bdcb72c4 100644 GIT binary patch literal 161720 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%0A*N65%Dx&IWFzr|VZomt7E}+pa+2`O( z5gA49=HxumK-?NTy>A+|IML|Lu^fvZdqo;LY#KtFy12yz)8CJyQ+zqT6a*pk5s?S zQ2MI({;a7HRlh>KSMf7t(Aa?-eAQkZt5rmcU6jziysh-^rSillryf0H#q?Fq;etW& zmhG>n<~g8CtZ3RsnqP6mI!a6u%}8aPM>w1&9j8zQG?DZC)X1Wu%eG3Or7D!uM<>|z z?51+aiNZCyQ+r4l(Z=|873~h%H;N@=s#$+zn(@5pFz#3Bmtphg+xbl0S|87{UGwE_ z%jdX{-HZz_8$|s?jk$L}we@?g{xl_(vzWqf`L1~DoWYk&Yffjx&%hvfAkXJi7QB{9 z8+e0v#Rp#Zzho}KXQDzlR8mzk>_Rc0(@*7SP}UsR`TD1$(s9c}`{q3FX=;Un?xjKI zs3P9&yK(&Ty3skd8g?6Ram&$E-zl_^Y4qQV3&z4zPKE~^I!p)MM{pn6nS-PJXu81zA_ckJb!JzA1)d^tb z#p>7(4kh?5-`v(~pQM#@r2b0jvULZfTjYvZF%O*$%{g1Z5j_caFl+2~=ptxV5{^89 zXqUZ-j=7tr*RhbaQ;*NF9DIayZqw(!IX2HFt-r9oWNU4H{@YwtZ948eb?58Kc~i3B zPX+^N1aW-|mvtfQ+E6-wY7Tl+mPO!0GFF%;q88kbO_9zvpy6#&8kmPqI=(o(q>~r{ zn#yNWYmvS`KYlN4QlCS!ILSKPXI1TrS2+cNoLJVFU+1 zo5V=G+H|_c^O9*$=pP%IlkY<&sTx3j+)_9#i?KVL*Ml!JjC#7AVFi9#RvNRJiUF~D zA`!i~T1q*w!raZ0;2wcr zE}`R8i{Rm{r{@Hv_5+`$dr*k|_nvLx_;1P79v%ci{M zI6XacIs5}M-`dFmr7lnNB+T>7(LdM7d;%qiG9uTIS^N_j5ZybMzPI(`UMJNO>s zhpNWW;6m2AU3@rmhMKqb=hE1`x0{l4{*FI`6U%za zHxutSw~AizWSu9+KK~L-1V)aNC-XTSA5W4cRjnVK=g~>3M&Ui}QNh;OoKs7>r~^4>MS@F278cLz&I5hQl3Fk8QGk9!+7lK`P}JYN_Qot;EBP>CqpgP0>RU*4-j z?I(M;v?MsOCvviVS;Tdy(y0^4wi#@1{FX)ZxjF7KbX!IE|3T5I%nIY-- z4Z6_hmu`#sg*d7Q^4Nb*8>%a$o zg1=-lk|`#05Y&`Li?ruP9y;eJ<}pCW;oNgwy|Zq+CG;5jD&NadN`Y5aBXt^Bc3AJo zpe;KSJU#L7MX?(5Ii0+Qx?#}ZRLkShPSaG_uZtd;Y>1sn&q5#NBmN`kq6)yImIPQ>N~vJE^tS7P<{cDiJXphExs|C;R>$-3*YNLB2Mny z(pv-aEGrelw_&aO#>?_&Pr6zmg*m>>^Q~C;#4I|hd+>>(l7g5j6+mE8_a5c-3f#(a z-&#?BjlLyHXLbm3A>&wAwu@d}L&zuvJ5 z=b%^JZ@PSan@O3Hq9U3=Rp>lbg`~;hNc`&7fMqC@Y9`(675%p;Dc3Lk)k?WkPPv~L zB-EW&tCf2<&==V3svHz5#_#uyBU_Z2uc}|h zoHvNby6Bm&*Ki|yWPxaB0d_{t#V(;*c+6Km#f@_-Z&?_1Zpa}hncn7Wr-MzXKOa$& z0eVz*vnU2RiFJF+{#QNhZw;b{!~PretLC|D_R+3!TuaaRZ0X~;sJ+jM%Oy^}V$EHZ zCiKS1S~<1)y#49WMvkVI3~_Jl7@c5<#kKyfkg$ii(=cq7dFZCI%XMjL`)Ivs&-v`h z51r0NPQOpjn47mHTcf1CK;ziJZiP-`Am>iUi=N@fqP>xPITUi7g_Z|yIy0E84;KHu zNsSXUmG?h}a{K#DSNVy}62B{ju>6W2Z4K;%lON7IzP_-i^LB?rl|2-T4Dk1_54^2D zfo9kpQ#f$h)?T^amfC{i-VrupwkceRANx*Pj~&lTNv9L%5MpmjeY$)WKuTxy;^iU} zfuBnhLA(vY_Wl8%sXyyFcT?ZSd!Lub{$||@@o%RnLR{(e%1v}_nsqoz_YftG9dxo; za=!u^!M~gu-*Jx2*6(S~U(#@WX)O;Gxv_rS(s|~!%KSE`?H)VWv);fnhYp?)I6D|< zywv-pPc*NioMN6Gjjr%>MXPBGsxWj%AXO04Iw|ENy*QIOiJ_EN-uyFmBMB+*j^Nv>dA~h-J zQ3bTbShh{Aa_T#W0KMY<$dXmmt%L;>#ff>y!b5|TVinvUt-0NtiL|9OpuRl@A)JR! zcj)J5789Svm*8z`hkl-v+%7B9QPrNE)Ee<>ayrAVtxG$;l~cf`dt&#_5hqu>^xpRJ zn|!|?JV|_ro{;WD*iIXd{MOFKW;g8U1m%GHb%R;nZkF zvVjzz&UsbT%}vi`ihf-C_t&cus1a~VzN1%mnQ}ETU%3ba(s&4 zG{^DC=k2UwEN{hJg2(u6dMC8^U#?Z0xo=c~d|Qu$yziHrlYA9(9Hd=Y4yhgv@}B28 z$=_T;Da1*Tw8$){)ZA|D=`F>bdfB($B%QE5KegZQnw*ndEUAbX*z-SKE43|m2M&iX zwCDd|^|yh>1({DRZvUA{M&h`sei9s&-Om8(WLoKpQX5r zo<25+uy;~-=LnC&dJZ>gSkWqso}V(ZyZSeq6%c(q?BMa36!tHWWTrn_rU|X1s@mU} zCVt%{3l<%35s5ivw~q4jy4_3PZ_&FRX|1Qh!11KnDQr7 z2#v))fx5qjw>S50zt7{N`!i~NdL`R5Jx+f+g>kokQr{oy=tp3-b^p=7PUMG)((3bjNV47!HBM z`5&Ief-Dx0QTM5g9f`wlBxqpWihjN{Yeu2GrL zm#5vgm8iz5j5zuFAn-3r2fXpyc_Zwk&tLfX~OWQ~Lb* z#AaQetrwH^bveC@I{Rq7Cl(84;qDy6k_j%(b|j=m2`(+C@waYF(Q#Bt)1FG)RxGhaWBDAh zdbFmRq?Om{xj*zPvC0$!Kyg0$Sc7OT1w7iQs3I{R^t_*eJJo&;BgYY4bo?NvUmhNE z2!}jLF&Ea1^Um_9Vw;D2>?zE-pS4-^FvX2%VSn%hoN8-P!!~a`uPCBck^A6io#z!j zoX1-$S~%4*#TCCRzL@)onCAr_{ zpZar(GTg3)|Hcu)DR025!IY7@U$wJ+29C3qt$lJayyK4DiIZ}9kw!Hy8+V=D3D*6w zx;jPFKdsS+=pvUj?o-t(9|RfRi8vGSCH$y4k0Ih>?Z{ea(@i64>&Hc?6jN47k#Kn@ z>3qtZ4IGfe_m{nMgvu3%5V&krVu-#zHg5530kFl!d$oH+afjGFlT%$Dg>ia&wU@+o zO`}R5IbE|V_ z%&er!oG0_XuspQ`bJx}wH56$ddxM7YJbv4{ARhA~XQ7~W*Q-l*;VQcH@?@gYQ;rq! z!P1I*2}Gg9QyvWU1@(&RsXUWTio)Ab1oLp!K5KeTCg!st3v+IeNn2I}gO`mZ%nnR; z4EZPcKx9n4k}u{XnMR$JubdrImyc+iZ?!K3n@QAq&wf+QEtO2F4koKbJVv|b=4CQ5 zKlai8T-d24uj%hP|Hc{|rfp6u`>prr=j(WXUkp%VFPwVzO6gK7kh6|Tr`+<+N8XI% zy|3U)uwT0qBw!PGrP78o5;&Cru2u%RycuM!5}@wQ<)8LjF?)lAfiFadE^SVV`%)?W z;&EW$^>Fp_abXJENrZ|2PIr7WIrm1C#a%(N+)5D=w2hFhgX*j86RmSg2 z>nhE2{AIAvsTswNWUn{YBT|KZjrkrQkN>bsq@{^u&8Asv*OKYV(;3vHe_h*z{ptE9 zuQQ3Yv}CeyadOeG+FAq=ZX3dyaB7HZLaH=l{|(lG+sI@%ZdLVtx{g|pMDt$N&0t@t zMn<_5Y7}a9Gbzk(n#ONYk6RP)$njRp>tI-zTMoLwBv+j+JBLvQw zdCr?yaQ}VoDJ7;{ec5If_qeCu1eb0NI{RvZ71raYhUrYKddp?^xI2dkI*N5)ZQiMb z7y5GF-;T|U4UQad$!VwM6n~ji5{57nDd| z&(9^_p2_glDXqr24u5}ccH+72_QN_lV;<#uwnx$Ll~Tq~C)II}2i68vBzK3M9&z?L zer}!afrQ_|9=F!u`Kh}2<+xcq_JHQ}o%>;q4S%*Wy7g8yJWKoMHX@$C>W$$mom0YR zX}+?Q{#a)oMetUVXT(_O1CHPf8=?=2X4K}B@_V|Eh$@30trsi@r{b_tcFiWi=hTS# zo};?9NyovCLGPwxr2XmhXSsK&1{q93Bz(Dqh?`#vS zx6R(DL$F_8>t=_xP43j3;^bwL^(kLy*I*5jwH4JN>+q=I&cCbitb3_k%j?K8DUUSGooNu_s7 z`uwu3;2L+wVMOCg;`7V>8aZ z{#@GgZgUsLzpwt?;PP5`{D@4TqD;rN`_tvMdL`D>-Uq9lGwi54FidI}Y+4A0dlWAwV`YHcIcMRnpKdFda^ZXKAZ65rZFJvB;CoD=5j>Sa7D@^OX|{nK1-yp zxACWNe%bhy%ovGK@wZwo&Iy4d@O&aT3r;8?&)pY&ncwR7Sk4rkLpk7 z*xJawR-pG)KGT&U0aKWLQ9w-V?3GCM{nBq%u+@q;=RK?sKVS0_Ui@sJ9ZvAVm;T8z zo16mV`$E#WY^|sooP*t5bkXNnK9_NBKW?Vwz%u%*TUg~vrr%meB1jTw=j3?hx%MvQ z`ciqmgm&iMO{u33(#xnImETjkY8oR`EyH#~!s4lEe|ReIh^CS6$7Yh}roG5SuHh-9 zH`-XznQRE}DGyJpa({A8PXQ5WCGsbi61{Yx7`t|+-W*R(LBQo^SuNMIJM9yjTil`H zr+RWy-@y={D}9~t3H%h*ZG)65Nw{|qxs*d5X%trN!NhJfwbtooby}=J!R;>3S)pG` zJ0P+yZbPMhPOaC#DYmvwWF!x)IOhdh@hz*k$#=oEGw8JuYV!t8I%%TTBIXnsx|>F6 z-Uc5!YFehwiV(M@W3+bspRcUm>r-lQjuZQduL12lh7a~Sz)|InfU^E-8LV5Gg@6+l z1TYo8d>>s@+O#nieWa8!-jmnUR|;pB9Kycus?5(ScKA}!CY!RP=UR_3W5uC3$#0Xr zl`d6~k?Qef*q59_p@#eAdnn!Fjh=ICr4qD1eigk<)7SF()-`E+C#d6j9qmz*4&=B? zdJXxDK8dy0`O{C=mL2xfAl2}l@;heG;3@3LJ>|W(@l@AcwVl%al5#G?60+U*V#GOX zUbm_{5*m*v$g-&l-^2;E-| zYMMV-`|FT#^O4%M9uD*Nyym5iv{8 z2Tx^A@dF#By`pt9aNIQW{ZT&o9sBL4;p9M_8H+{4y8kfD<=f$ANBD~Kqff7XXSRJC z2TmuBi>bBEW5au!>Sfnp#=T8YVVJ20KQ>$;Exm6 zIY)SZ@GDvI*c05&#@4B#Sf$9E`SYp8-)Tj*Vh`-OhK^)Grt7KA+-W7KJYuRjH2P^g z?Bcu!ypneBb!hwi>a^_NPOVIE67?eGQiw>~qLZNozd`aVhJz;qRMS;RM^BBP zzMaEEx<#~|e{*`+!O%ws4uez<;|rd%6@6rs;1xF-E#iqU`;7p;?kU*&J5Jp-xb)Mt z_a=(^(qOC#&lJPT-ds2C=Y$-muQbZ1dlD2G`C*VY?4|Jg`qIAUY0GEi&#;-Ca_bo` z(L!&0&U^Q#Q(EaY^z6_KsHOI!peC&`hE*LpvOw88g~UGS(BwY-(CIQs%z_Z zQjRC_YdUu7cWK^TdyegNQY5eyco4G1OV>hbDXvR=r@7%WO zOY{lSoT>$iQF{(jO!jlg6vdo8ZId5!pA%URWlN9m4Bu!kQhpEX z>qd{zKJj59Th#y2&Sm}BCcj{B_^zh3m16bxO@=A*5IXV4LE2#*l9~1lkalt08qPcB ztO!OX&XLC9od-@2dJBn@-?hr@(Z#{Z#OLC^r8yn()<9ERrUxaVTuZ!t^tsD2X$$wl zxIGx_2Q9lZKnf`PZ6j~5_+g#WK>iI;07-hjLk2vs_Nt;xZB7fx`>Ix;vU3*HLR6Gq%Jih|3a z5vUK1ch3GM-y8IHXm-n+_I|WmCJ+0@{@7w{AFaMz{mEt_Xu(yY_|oFk&cbcm3*v2n zj_eP-dFMy@lTKa0?L%vG)2QGFqx)yEs`INq+es0hFIbbe=wH!!!z5boS*+(PqmPTW zCT6(=Q+d1owc)36)|UL6XR*S4Ti->)*u@1ni#Gi$+qZS;ERbihsy$oJd8>9gZ+_i5 zwEGxwgKBv7+ASg%LW<#+Ob zJ<9BP)Ub#gHYb0IOUtXnd%HSlD12zJ<=&s~YmHA*3+;*Fhjw`L?S1fWg(<#*R}OSM zg5HXf^WLjdT>O>~L90eH?7w&(_X2yg3{0l+tIJ1t(~RqM6Xyo$6qov2$vNlb^4a3y z+;i|-i8UWAfHM1#N8L);q!j>)A1;GXz~E&&`^MoNaCXmCx?{)a0BE|jP=uCGqMB}O z6*)WvORzFeujpg^d5ycDjrwHpVtRCKtfx;myYBUN{;YfdHOlpxlXRDpuT~tR*flT{ zENi`_GB3>rzXD=_YujFKBmygx11$Mh<3U0ceChswyeY=vuX`d=mDd-?S*RD%CHUUcf(wf<8Zz{i3dVl&6+mfg4*oHYgwf@V@x|erZOnw9MhShcPcMbVZq$qI?{f~5@71{ zDZiW6Rrs&8oU0SqTFqA^ugK zYz_e*C)V{0x2k(wK0-~R`*+e07i}HzGd68nVTT2l z?^UImMSDW@zH0iRTd)Z zrbe&WprUv2;NPvc3r2mdy}Dk}2i$6H>u`%Yzkmo76w#_F7zp0STkGvq6xNa;xO~r& z?4llftgR`XCjIG}b3Ds=ZRmaYbKFOMGRI}Z_Q~C==CcTj3+_p2_(H))>+JaVvFbLW zf9ubheVFTi_*(nth~=kz{jur#&+IdJKj-CxuIyAmBdrDXoCLfTobgd&leBkV`>N(o z1jwPIzf$gN?Q8$isN~=X9;)m^-aQ;3;4~(k@a(E*&kfQ~v)D5_4qIM3f_|=IL$d|D z`OZ2?fRm{3oVQw3`?hHvCB9mUOQ(wR`MzNdEq>45zhqXUG}v8(M+#Bpfc?F`e&q|6 zxy@n`0_%f?0@72A>u}`)x zU7R(VxMtLGeW;C>efnb2#awRlga7p({`c`>d+hJawrf>-M20fO+-6zIb@6#%afr8! z$6s7NujL3+K0ym2hP3MiagfzA5s7-BDa6)KB?REVf&@4-#ioO zu|HWcM6>F;nD?GVmMvW!_9C^f?BMbHR8u^(A_YtK9Hv%XjJ-eDgdQaqY+4xbyjry8 zefE?`3#<~qAMb+we#>fNu{t@=^0ckZ^}-t#i`lhkmFkkY!EE09%c-cx8V#DRM`WPxe1R(QsFM%l#vwWX`fv6}qUZ&!=$622{hocCP4& zl3*d?kHl_=O|n+8KIc=d?p5YW*p`%89`*7#-g+nXq_R4fdU@{WxM!D-)t1PvtJ5q| zIkQ)pE1h@Q@)Ges)9-Sr`YLmk(<0D%mAQJAxpFO9^EUju=MlL+t{hBQ(kB!7CW6>H z8F<~u_a(DN$av!EwPJ;HX`{9sjWrUfLJaUB3o3qn@r6Q?ktN#qAEUsHjziDt%Y6+lcIR^gC&$;5N_qjtPG{M#y~J z9DHEnHpFDE9MwA1BfMYRjLr>hJkOc0Q1&g9nA5SJ%Yz*K-l$vW(Z-sPnk_xjkFi)C zt3m^erNJh0rlm!F-151}SdwXec2QlrmgD)~=%48mb^T3$*5$N+>(3sJkR;6W^`+z+ z>-nCZ>$hp=x;CZFkBtIyYhu1upl7ATh_GcI+W(xXbb8=>$)A2sEIBY_*g9O;;vH-i zmyLctM|?6z&vmMZdJ1a(RpVdBy7tfLUZZymQfO$c`@nvuHcLBgQaMnggxY5%{FIkE zUf6{lsd_T%JmKjr%PKK{{QqCzeE$JBJ^KVmIXesBkY@s?| z#x3_LPqsNQpSSohs7klf0clkqDIUu2sx`#BkPcBjqNv03KefJ{eⓈG!|Q*Ug-sIw)z=d_Ew>e?)-ta@ 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 delta 1469 zcmah|e@t6d6n^Km(9+enu9S|HT3<-V3e;LFS&$6YCBy+@(-C6KWV1tND#6mo=1j~8 z@W(XMxWXYj{;@1MV@`xi^s!J^lV6h00IQacjghp!*UL%Qoh8QBM61>I98XzBuJ(KqoeCZ_G=JqZ=- zh>uc^P{XtiHbSR|p&%5WiEXAZKuf6WwjQ$l+(FLA9pYLDCZHJezn}P@)*KigQBvgq zxHdWujH{CUJtExK4FWj_Vc}mX3KSiJmUPO24bu^HJLDnTk5xPd+Lbi!0&{Y5B;Eua z&8ztrFkUTC$7Nu{{5g}1Bk*NvvYzG=^e^biJPJd9FR`u@3-;+pzftoPQ}YILUWAv^ zVI}olgh+B{!EliVJ==2E@5_cVS_816`8yBQ{>ik`vUZurs#}L8Hn0^Q{a@u$Y%i!~ z<(x<-jr>+xkpn9&hAKuthta)qa-N5tWFP3StH*GxlI(g|PU46B^%kC$X(+6Q5^&>h z`-^2|+;)N&;Tf8HlNU&n4ep*j0#((3nxYF}pk5c`Q}hYY_NbT|6!P{>}+JgB92-T+Ncj!GeN z7S7DQhn-93l%BPwaI69hXiCoJXG7iJ17@two>@a*Jd3zzU1?|sglE0Cc zOQz4DG9wKqNKDpp4cW)xfaHq7n2I`_>@S&>pi>;?yv$-Eo}JwKoXEY!*2zdKo0G`Q z*UBAbUvJ8YOxh2~Y++c3H2l+UgZKC#OUQNDxP=nU$O49xgPPFGKaje=# zY}|VC+-DG`mkQ{o`_RZ10yV5MZFPJOrc+F9w5H%~>9C1(55SSyMOL8%0~2Pt_5kYB h=L_iFKcFmK{TQS)9Z6m!5IcH?G&N8v8Jl^f>@R6~IGX?f diff --git a/src/web/js/main.js b/src/web/js/main.js index be87f4cd..712b6658 100644 --- a/src/web/js/main.js +++ b/src/web/js/main.js @@ -105,17 +105,35 @@ async function showCurrentData() { document.getElementById('version_overlay').innerText = "EOS connect version: " + data_controls["eos_connect_version"]; const menuElement = document.getElementById('current_header_left'); - menuElement.innerHTML = ''; - menuElement.title = "Menu"; - // Remove any existing event listeners by cloning the element - const newMenuElement = menuElement.cloneNode(true); - menuElement.parentNode.replaceChild(newMenuElement, menuElement); + // Only update menu element if it doesn't have the correct icon already + const expectedIcon = ''; + const currentIcon = menuElement.querySelector('i.fa-solid.fa-bars'); - // Add single event listener - newMenuElement.addEventListener('click', function () { - showMainMenu(data_controls["eos_connect_version"]); - }); + if (!currentIcon || currentIcon.outerHTML !== expectedIcon) { + // Preserve any existing notification dot + const existingDot = menuElement.querySelector('.notification-dot'); + + // Update the icon + menuElement.innerHTML = expectedIcon; + menuElement.title = "Menu"; + + // Restore notification dot if it existed + if (existingDot) { + menuElement.appendChild(existingDot); + } + + // Remove any existing event listeners by cloning the element + const newMenuElement = menuElement.cloneNode(true); + menuElement.parentNode.replaceChild(newMenuElement, menuElement); + + // Add single event listener + newMenuElement.addEventListener('click', function () { + showMainMenu(data_controls["eos_connect_version"]); + }); + + console.log('[Main] Updated menu element and preserved notification dot'); + } } // Use manager functions for statistics and schedule @@ -163,6 +181,10 @@ async function init() { } if (!loggingManager) { loggingManager = new LoggingManager(); + // Initialize logging manager with slight delay to ensure DOM is ready + setTimeout(() => { + loggingManager.init(); + }, 1000); } // Fetch all data using the new dataManager diff --git a/src/web/js/ui.js b/src/web/js/ui.js index 0111061b..a7404944 100644 --- a/src/web/js/ui.js +++ b/src/web/js/ui.js @@ -25,6 +25,9 @@ function overlayMenu(header, content, close = true) { document.getElementById('overlay_menu_head').innerHTML = header; document.getElementById('overlay_menu_content').innerHTML = content; document.getElementById('overlay_menu_close').style.display = close ? '' : 'none'; + + // Block background scrolling + document.body.style.overflow = 'hidden'; } function closeOverlayMenu(direct = true) { @@ -33,6 +36,8 @@ function closeOverlayMenu(direct = true) { if (direct) { overlayMenu('', '', false); overlay.style.display = 'none'; + // Restore background scrolling + document.body.style.overflow = ''; } else { overlay.style.transition = 'opacity 1s'; overlay.style.opacity = '0'; @@ -40,6 +45,8 @@ function closeOverlayMenu(direct = true) { overlayMenu('', '', false); overlay.style.display = 'none'; overlay.style.opacity = '1'; + // Restore background scrolling + document.body.style.overflow = ''; }, 250); } } @@ -171,44 +178,44 @@ function showMainMenu(version) {
- + Alarms
- + Logs
+
+
- + Info
-
-
- + Changelog
- +
- + Bug Report
- +
`; @@ -224,6 +231,11 @@ function showMainMenu(version) { // Append dropdown to parent container parentBox.appendChild(dropdown); + // Update dropdown notifications using centralized system + if (typeof MenuNotifications !== 'undefined') { + MenuNotifications.updateDropdown(); + } + // Add click outside listener to close dropdown setTimeout(() => { document.addEventListener('click', handleClickOutside, true); @@ -241,6 +253,185 @@ function closeDropdownMenu() { } } +/** + * Hamburger Menu Dot Controller - Simple State-Aware System + * Knows exactly what's displayed and only changes when needed + */ +const MenuNotifications = { + displayedColor: null, // What's actually displayed: null, 'red', 'orange', 'white', 'gray' + + /** + * Initialize the notification system + */ + init() { + console.log('[MenuNotifications] Simple state-aware system initialized'); + }, + + /** + * Show a dot with specific color (external interface) + * Priority order: red > orange > white > gray > none + * @param {string|null} requestedColor - 'red', 'orange', 'white', 'gray', or null + */ + showDot(requestedColor) { + console.log(`[MenuNotifications] Request: show ${requestedColor}, currently displaying: ${this.displayedColor}`); + + // Determine what should be displayed based on priority + let targetColor = this.getTargetColor(requestedColor); + + // Only update if the target is different from what's displayed + if (targetColor !== this.displayedColor) { + console.log(`[MenuNotifications] State change needed: '${this.displayedColor}' → '${targetColor}'`); + this.displayedColor = targetColor; + this.renderDot(); + } else { + console.log(`[MenuNotifications] No change needed - already displaying '${this.displayedColor}'`); + } + }, + + /** + * Determine target color based on priority rules + */ + getTargetColor(requestedColor) { + // For now, just return the requested color + // Later can add priority logic for multiple sources + return requestedColor; + }, + + /** + * Render the dot based on displayedColor (only called when state changes) + */ + renderDot() { + const menuElement = document.getElementById('current_header_left'); + if (!menuElement) { + console.log(`[MenuNotifications] Menu element not found`); + return; + } + + // Always remove existing dot first (clean slate) + const existingDot = menuElement.querySelector('.notification-dot'); + if (existingDot) { + existingDot.remove(); + } + + // Add new dot if needed + if (this.displayedColor) { + const colors = { + 'red': 'rgb(220, 53, 69)', + 'orange': 'rgb(255, 193, 7)', + 'white': 'rgb(255, 255, 255)', + 'gray': 'rgb(136, 136, 136)' + }; + + const dotColor = colors[this.displayedColor]; + if (dotColor) { + const dot = document.createElement('div'); + dot.className = 'notification-dot'; + dot.style.cssText = ` + position: absolute; + top: 2px; + right: 2px; + width: 6px; + height: 6px; + background-color: ${dotColor}; + border-radius: 50%; + /* border: 1px solid darkgray; optional border */ + z-index: 999; + pointer-events: none; + `; + menuElement.appendChild(dot); + console.log(`[MenuNotifications] Rendered ${this.displayedColor} dot`); + } + } else { + console.log(`[MenuNotifications] Removed dot (no color)`); + } + }, + + /** + * Update dropdown menu notifications + */ + updateDropdown() { + const dropdown = document.getElementById('main-dropdown-menu'); + if (!dropdown) return; + + // Only update Alarms menu item (not Logs) + const alarmsItem = dropdown.querySelector('div[onclick*="showAlarmsMenu"]'); + if (alarmsItem) { + // Convert our color system to old status system for dropdown + let status = null; + if (this.displayedColor === 'red') status = 'error'; + else if (this.displayedColor === 'orange') status = 'warning'; + + this.addDropdownNotification(alarmsItem, status); + } + + // Ensure Logs menu item has no notification dot + const logsItem = dropdown.querySelector('div[onclick*="showLogsMenu"]'); + if (logsItem) { + const existingDot = logsItem.querySelector('.dropdown-notification-dot'); + if (existingDot) { + existingDot.remove(); + } + } + }, + + /** + * Add notification dot to dropdown menu item + * @param {Element} menuItem - The menu item element + * @param {string|null} status - The notification status + */ + addDropdownNotification(menuItem, status) { + // Remove existing notification dot + const existingDot = menuItem.querySelector('.dropdown-notification-dot'); + if (existingDot) { + existingDot.remove(); + } + + // Add new notification dot if needed + if (status) { + const dotColor = status === 'error' ? '#dc3545' : '#ffc107'; + const dot = document.createElement('div'); + dot.className = 'dropdown-notification-dot'; + dot.style.cssText = ` + width: 8px; + height: 8px; + background-color: ${dotColor}; + border-radius: 50%; + margin-left: auto; + margin-right: 8px; + flex-shrink: 0; + border: 1px solid rgba(255,255,255,0.2); + `; + + menuItem.appendChild(dot); + } + }, + + /** + * Restore notification after menu element changes (only if actually missing) + */ + restoreAfterMenuChange() { + // Small delay to ensure DOM is updated + setTimeout(() => { + if (this.displayedColor) { + // Check if dot actually exists before restoring + const menuElement = document.getElementById('current_header_left'); + const existingDot = menuElement ? menuElement.querySelector('.notification-dot') : null; + + if (!existingDot) { + console.log(`[MenuNotifications] Dot missing, restoring ${this.displayedColor} dot`); + this.renderDot(); + } else { + console.log(`[MenuNotifications] Dot already exists, no restore needed`); + } + } + }, 50); + } +}; + +// Initialize and make globally available +MenuNotifications.init(); +window.MenuNotifications = MenuNotifications; + /** * Handle clicks outside dropdown to close it */ @@ -294,7 +485,7 @@ function showInfoMenu(version) { } /** - * Create full-screen overlay for logs with small margins + * Create full-screen overlay for logs with responsive margins */ function showFullScreenOverlay(header, content, close = true) { // Create overlay if it doesn't exist @@ -302,6 +493,10 @@ function showFullScreenOverlay(header, content, close = true) { if (!overlay) { overlay = document.createElement('div'); overlay.id = 'full_screen_overlay'; + + // Responsive padding: very small on mobile, larger on desktop + const paddingValue = isMobile() ? '8px' : '60px'; + overlay.style.cssText = ` position: fixed; top: 0; @@ -311,17 +506,25 @@ function showFullScreenOverlay(header, content, close = true) { background-color: rgba(0, 0, 0, 0.6); display: none; z-index: 1000; - padding: 60px; + padding: ${paddingValue}; box-sizing: border-box; `; document.body.appendChild(overlay); + } else { + // Update padding if overlay already exists (responsive on resize) + const paddingValue = isMobile() ? '8px' : '60px'; + overlay.style.padding = paddingValue; } - // Create content container + // Create content container with responsive padding + const headerPadding = isMobile() ? '12px 15px' : '15px 20px'; + const contentPadding = isMobile() ? '15px' : '20px'; + const borderRadius = isMobile() ? '6px' : '10px'; + overlay.innerHTML = `
${header} - ${close ? '' : ''} + ${close ? `` : ''}
${content}
@@ -358,6 +563,9 @@ function showFullScreenOverlay(header, content, close = true) { overlay.style.display = 'flex'; + // Block background scrolling + document.body.style.overflow = 'hidden'; + // Add escape key listener const escapeHandler = (e) => { if (e.key === 'Escape') { @@ -375,6 +583,10 @@ function closeFullScreenOverlay() { const overlay = document.getElementById('full_screen_overlay'); if (overlay) { overlay.style.display = 'none'; + + // Restore background scrolling + document.body.style.overflow = ''; + // Remove escape key listener if (overlay.escapeHandler) { document.removeEventListener('keydown', overlay.escapeHandler); From 36a060108312c669a33f0d93d192f7ed3e2ee539 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:05:50 +0200 Subject: [PATCH 028/132] fix: update showSchedule function to include data_controls parameter for enhanced schedule management --- src/web/js/main.js | 2 +- src/web/js/schedule.js | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/web/js/main.js b/src/web/js/main.js index 712b6658..47a729d5 100644 --- a/src/web/js/main.js +++ b/src/web/js/main.js @@ -221,7 +221,7 @@ async function init() { // Update all displays showStatistics(data_request, data_response); - showSchedule(data_request, data_response); + showSchedule(data_request, data_response, data_controls); setBatteryChargingData(data_response); chartManager.updateLegendVisibility(); diff --git a/src/web/js/schedule.js b/src/web/js/schedule.js index f6bc4ff7..6a99b3b0 100644 --- a/src/web/js/schedule.js +++ b/src/web/js/schedule.js @@ -19,12 +19,14 @@ class ScheduleManager { /** * Show schedule for next 24 hours */ - showSchedule(data_request, data_response) { + showSchedule(data_request, data_response, data_controls) { //console.log("------- showSchedule -------"); var serverTime = new Date(data_response["timestamp"]); var currentHour = serverTime.getHours(); var discharge_allowed = data_response["discharge_allowed"]; var ac_charge = data_response["ac_charge"]; + var inverter_mode_num = data_controls["current_states"]["inverter_mode_num"]; + var max_charge_power_w = data_controls["max_charge_power_w"]; // Add timezone indicator to schedule header document.getElementById('load_schedule_header').innerHTML = @@ -136,8 +138,8 @@ class ScheduleManager { } // Legacy compatibility function -function showSchedule(data_request, data_response) { +function showSchedule(data_request, data_response, data_controls) { if (scheduleManager) { - scheduleManager.showSchedule(data_request, data_response); + scheduleManager.showSchedule(data_request, data_response, data_controls); } } From cb76764ff3b40cfb24423c85410c5bbfa37e658f Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:06:45 +0200 Subject: [PATCH 029/132] fix: conditions in evcc state overrides --- src/interfaces/base_control.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/interfaces/base_control.py b/src/interfaces/base_control.py index de492c23..ca6e862e 100644 --- a/src/interfaces/base_control.py +++ b/src/interfaces/base_control.py @@ -212,7 +212,8 @@ def set_current_bat_charge_max(self, value_max): # store the current charge demand without override self.current_bat_charge_max = value_max logger.debug( - "[BASE-CTRL] set current battery charge max to %s", self.current_bat_charge_max + "[BASE-CTRL] set current battery charge max to %s", + self.current_bat_charge_max, ) self.__set_current_overall_state() @@ -278,8 +279,9 @@ def __set_current_overall_state(self): # override overall state if EVCC charging state is active and # in mode fast charge and discharge is allowed if ( - new_state == MODE_DISCHARGE_ALLOWED - and self.current_evcc_charging_state + # new_state == MODE_DISCHARGE_ALLOWED + # and + self.current_evcc_charging_state and self.current_evcc_charging_mode in ("now", "pv+now", "minpv+now") ): new_state = MODE_AVOID_DISCHARGE_EVCC_FAST @@ -291,8 +293,9 @@ def __set_current_overall_state(self): # override overall state if EVCC charging state is active and # in mode pv charge and discharge is allowed if ( - new_state == MODE_DISCHARGE_ALLOWED - and self.current_evcc_charging_state + # new_state == MODE_DISCHARGE_ALLOWED + # and + self.current_evcc_charging_state and self.current_evcc_charging_mode == "pv" ): new_state = MODE_DISCHARGE_ALLOWED_EVCC_PV @@ -304,8 +307,9 @@ def __set_current_overall_state(self): # override overall state if EVCC charging state is active and # in mode pv charge and discharge is allowed if ( - new_state == MODE_DISCHARGE_ALLOWED - and self.current_evcc_charging_state + # new_state == MODE_DISCHARGE_ALLOWED + # and + self.current_evcc_charging_state and self.current_evcc_charging_mode == "minpv" ): new_state = MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV From 6d2f232a6d86c32acda730302c5ed50a5046b336 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:06:55 +0200 Subject: [PATCH 030/132] fix: adjust font sizes for mobile responsiveness in main and full-screen overlays --- src/web/js/ui.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/web/js/ui.js b/src/web/js/ui.js index a7404944..631f31cf 100644 --- a/src/web/js/ui.js +++ b/src/web/js/ui.js @@ -171,7 +171,8 @@ function showMainMenu(version) { z-index: 1000; min-width: 180px; padding: 8px 0; - font-size: 0.9em; + // font-size: 0.9em; + font-size: ${isMobile() ? '1.1em' : '0.9em'}; `; dropdown.innerHTML = ` @@ -542,7 +543,7 @@ function showFullScreenOverlay(header, content, close = true) { display: flex; justify-content: space-between; align-items: center; - font-size: ${isMobile() ? '0.9em' : '1em'}; + font-size: ${isMobile() ? '1.1em' : '1em'}; "> ${header} ${close ? `` : ''} From a61a032f3ad4a709a3c8c40ce105ec2a48c40ef0 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 4 Oct 2025 20:04:04 +0200 Subject: [PATCH 031/132] feat: implement full-screen override controls menu and enhance UI interactions + new info popup --- src/web/js/controls.js | 494 ++++++++++++++++++++++++++++------------- src/web/js/main.js | 3 +- src/web/js/ui.js | 112 ++++++++-- 3 files changed, 440 insertions(+), 169 deletions(-) diff --git a/src/web/js/controls.js b/src/web/js/controls.js index af4ebb54..db3ccfbd 100644 --- a/src/web/js/controls.js +++ b/src/web/js/controls.js @@ -7,12 +7,12 @@ class ControlsManager { constructor() { this.menuControlEventListener = null; this.icons = [ - { icon: "fa-plug-circle-bolt", color: COLOR_MODE_CHARGE_FROM_GRID, title: "Charge from grid" }, - { icon: "fa-lock", color: COLOR_MODE_AVOID_DISCHARGE, title: "Avoid discharge" }, - { icon: "fa-battery-half", color: COLOR_MODE_DISCHARGE_ALLOWED, title: "Discharge allowed" }, - { icon: "fa-charging-station", color: COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST, title: "Avoid discharge due to e-car fast charge" }, - { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV, title: "Discharge allowed during e-car charging in pv mode" }, - { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, title: "Discharge allowed during e-car charging in min+pv mode" } + { icon: "fa-plug-circle-bolt", color: COLOR_MODE_CHARGE_FROM_GRID, title: "Charge From Grid" }, + { icon: "fa-lock", color: COLOR_MODE_AVOID_DISCHARGE, title: "Avoid Discharge" }, + { icon: "fa-battery-half", color: COLOR_MODE_DISCHARGE_ALLOWED, title: "Discharge Allowed" }, + { icon: "fa-charging-station", color: COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST, title: "Avoid Discharge Due to E-Car Fast Charge" }, + { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV, title: "Discharge Allowed During E-Car Charging in PV Mode" }, + { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, title: "Discharge Allowed During E-Car Charging in Min+PV Mode" } ]; } @@ -24,120 +24,321 @@ class ControlsManager { } /** - * Adjust grid charge power by delta amount + * Create and show the override controls menu using modern full-screen overlay */ - adjustGridChargePower(delta) { - const input = document.getElementById("grid_charge_power"); - if (!input) return; - - let newValue = parseFloat(input.value) + delta; - newValue = Math.max(parseFloat(input.min), Math.min(parseFloat(input.max), newValue)); - input.value = newValue.toFixed(1); - } - - /** - * Create and show the override controls menu - */ - showOverrideMenu(maxChargePower, overrideActive) { - const buttons = this.icons.map((icon, index) => { - if (index > 2) return; // without special evcc modes - const isDisabled = false; // Could be: index === inverter_mode_num; + showOverrideMenuFullScreen(maxChargePower = null, overrideActive = false) { + let currentModeNum = -1; + + // Use current data if available + if (typeof data_controls !== 'undefined' && data_controls) { + if (!maxChargePower) { + maxChargePower = data_controls.battery?.max_charge_power_dyn ? data_controls.battery.max_charge_power_dyn / 1000 : 5.0; + } - return ``; - }).join('') + (overrideActive ? - `
Back To Automatic
- ` : '' - ); - - const durationEntry = ``; - - const acChargePower = `
- - - -
`; - - // Add passive touch event listeners after overlay is created + // Check multiple ways override could be indicated + if (data_controls.current_states) { + overrideActive = data_controls.current_states.override_active === true; + currentModeNum = data_controls.current_states.inverter_mode_num; + } + } + + if (!maxChargePower) { + maxChargePower = 5.0; // Default fallback + } + + // Also check global variable as fallback for mode number + if ((currentModeNum === -1 || currentModeNum === null || currentModeNum === undefined) && typeof inverter_mode_num !== 'undefined') { + currentModeNum = inverter_mode_num; + } + + console.log('[ControlsManager] Override menu - maxChargePower:', maxChargePower, 'overrideActive:', overrideActive, 'currentModeNum:', currentModeNum); + + // Safely log data_controls only if it exists + if (typeof data_controls !== 'undefined' && data_controls) { + console.log('[ControlsManager] Full data_controls object:', data_controls); + if (data_controls.current_states) { + console.log('[ControlsManager] current_states details:', { + override_active: data_controls.current_states.override_active, + inverter_mode_num: data_controls.current_states.inverter_mode_num, + inverter_mode: data_controls.current_states.inverter_mode + }); + } + } else { + console.log('[ControlsManager] data_controls is not available globally'); + } + + const header = ` +
+ + Override Current Controls +
+ `; + + const content = ` +
+ +
+
+ Battery Mode Selection +
+ +
+ ${this.icons.slice(0, 3).map((icon, index) => { + // Mode numbers in data are 1-based (1,2,3) but our array is 0-based (0,1,2) + // So we need to compare (currentModeNum - 1) with index, OR currentModeNum with (index + 1) + // const isCurrentMode = overrideActive && (currentModeNum === (index + 1)); + const isCurrentMode = (currentModeNum === (index)); + const isDisabled = isCurrentMode; + const buttonColor = isDisabled ? '#666' : icon.color; + const bgColor = isDisabled ? 'rgba(51, 51, 51, 0.8)' : 'rgba(58, 58, 58, 0.8)'; + console.log(`[ControlsManager] Mode ${index} - isCurrentMode: ${isCurrentMode}, isDisabled: ${isDisabled}, currentModeNum: ${currentModeNum}, bgcolor: ${bgColor}`); + // const borderColor = isDisabled ? '#444' : icon.color; + const borderColor = icon.color; // show border color if Disabled it will shown with 0.5 opacity + const cursor = isDisabled ? 'not-allowed' : 'pointer'; + + return ` + + `; + }).join('')} +
+ +
+ + ${overrideActive ? ` + +
+
+ Return to Automatic Mode +
+
+ Override is currently active (Mode ${this.icons[currentModeNum].title}). Click to cancel the override and return to automatic optimization mode. +
+ +
+ ` : ''} + + + + +
+
+ + +
+
+ Override Duration +
+ + +
+ + +
+
+ Grid Charge Power (kW)
Mode '${this.icons[0].title}' Only +
+ +
+ + + + + +
+ +
+ Range: 0.5 - ${maxChargePower.toFixed(1)} kW +
+
+
+ `; + + showFullScreenOverlay(header, content); + + // Add mode-specific control logic and touch event listeners setTimeout(() => { + // Add click handlers for mode buttons to show/hide relevant controls + this.icons.slice(0, 3).forEach((icon, index) => { + const button = document.getElementById(`mode_${index}`); + if (button && !button.disabled) { + const originalOnClick = button.getAttribute('onclick'); + button.setAttribute('onclick', `controlsManager.selectModeForOverride(${index}); ${originalOnClick}`); + } + }); + + // Initialize with mode 0 (grid charge) selected by default + // this.selectModeForOverride(0); + + // Add touch event listeners for power adjustment buttons const decreaseBtn = document.getElementById('charge-power-decrease'); const increaseBtn = document.getElementById('charge-power-increase'); - if (decreaseBtn) { - decreaseBtn.addEventListener('touchstart', function() { - this.style.backgroundColor = 'lightblue'; - }, { passive: true }); - decreaseBtn.addEventListener('touchend', function() { - this.style.color = 'white'; - this.style.backgroundColor = '#444'; - }, { passive: true }); + [decreaseBtn, increaseBtn].forEach(btn => { + if (btn) { + btn.addEventListener('touchstart', function() { + this.style.backgroundColor = 'rgba(220, 53, 69, 0.3)'; + }, { passive: true }); + btn.addEventListener('touchend', function() { + this.style.backgroundColor = 'rgba(58, 58, 58, 0.8)'; + }, { passive: true }); + } + }); + }, 100); + } + + /** + * Select mode for override and show/hide relevant controls + */ + selectModeForOverride(mode) { + // Highlight selected mode button + this.icons.slice(0, 3).forEach((icon, index) => { + const button = document.getElementById(`mode_${index}`); + if (button) { + if (index === mode) { + // Highlight selected mode + button.style.backgroundColor = 'rgba(255, 193, 7, 0.2)'; + button.style.borderColor = '#ffc107'; + button.style.boxShadow = '0 0 10px rgba(255, 193, 7, 0.3)'; + } else if (!button.disabled) { + // Reset non-selected modes + button.style.backgroundColor = 'rgba(58, 58, 58, 0.8)'; + button.style.borderColor = icon.color; + button.style.boxShadow = 'none'; + } } - - if (increaseBtn) { - increaseBtn.addEventListener('touchstart', function() { - this.style.backgroundColor = 'lightblue'; - }, { passive: true }); - increaseBtn.addEventListener('touchend', function() { - this.style.color = 'white'; - this.style.backgroundColor = '#444'; - }, { passive: true }); + }); + + // Show/hide grid charge power section based on mode + const gridPowerSection = document.getElementById('grid-power-section'); + if (gridPowerSection) { + if (mode === 0) { + // Mode 0 (Grid Charge) - show power controls + gridPowerSection.style.display = 'block'; + } else { + // Mode 1 & 2 (Avoid Discharge, Allow Discharge) - hide power controls + gridPowerSection.style.display = 'none'; } - }, 0); - - const content = `
${buttons}
-
- Duration
-
${durationEntry}
-
- Grid Charge Power (kW)
${acChargePower}`; - - overlayMenu("Override Current Controls", content); + } + + // Store selected mode for later use + this.selectedOverrideMode = mode; } /** - * Handle mode change button clicks + * Handle mode change for full-screen overlay */ - async handleModeChange(mode) { + async handleModeChangeFullScreen(mode) { const durationElement = document.getElementById('duration_time'); const gridChargePowerElement = document.getElementById('grid_charge_power'); @@ -148,33 +349,47 @@ class ControlsManager { const duration = durationElement.value; const gridChargePower = gridChargePowerElement.value; - const controlData = { - "mode": mode, - "duration": duration, - "grid_charge_power": parseFloat(gridChargePower) + + const controlData = { + mode: mode, + duration: duration, + grid_charge_power: parseFloat(gridChargePower) }; + console.log('[ControlsManager] Sending override control data:', controlData); + try { - console.log('[ControlsManager] Sending mode change:', controlData); const result = await dataManager.setOverrideControl(controlData); - console.log('[ControlsManager] Override control set successfully:', result); - closeOverlayMenu(); - overlayMenu('Success', "Mode changed successfully.", false); - setTimeout(() => { - closeOverlayMenu(false); - }, 2000); + // Close the overlay after successful operation + closeFullScreenOverlay(2500); + // Refresh data to show updated state + if (typeof init === 'function') { + setTimeout(init, 500); // Small delay to allow server to process + } } catch (error) { - console.error('[ControlsManager] Failed to change mode:', error); - overlayMenu("Error", `Failed to change mode: ${error.message}`); - setTimeout(() => { - closeOverlayMenu(false); - }, 2000); + console.error('[ControlsManager] Error setting override control:', error); + alert('Failed to set override control: ' + error.message); } } + /** + * Adjust grid charge power for full-screen overlay + */ + adjustGridChargePowerFullScreen(delta) { + const input = document.getElementById('grid_charge_power'); + if (!input) return; + + const currentValue = parseFloat(input.value) || 0; + const maxValue = parseFloat(input.max) || 10; + const minValue = parseFloat(input.min) || 0.5; + + const newValue = Math.max(minValue, Math.min(maxValue, currentValue + delta)); + input.value = newValue.toFixed(1); + } + /** * Update current controls display */ @@ -284,7 +499,8 @@ class ControlsManager { setupOverrideClickHandler(iconElement, maxChargePower, overrideActive) { const newListener = () => { console.log('[ControlsManager] Override active:', overrideActive, '- Max charge power:', maxChargePower); - this.showOverrideMenu(maxChargePower, overrideActive); + // this.showOverrideMenu(maxChargePower, overrideActive); + this.showOverrideMenuFullScreen(maxChargePower, overrideActive); }; // Remove old listener if it exists @@ -297,15 +513,6 @@ class ControlsManager { iconElement.addEventListener('click', this.menuControlEventListener); } - /** - * Show control details (left header click) - */ - showControlDetails() { - // This could show detailed control information - console.log('[ControlsManager] Show control details clicked'); - // Implementation depends on what you want to show - } - /** * Cleanup when shutting down */ @@ -319,22 +526,3 @@ class ControlsManager { } // ControlsManager instance is created in main.js during initialization - -// Legacy compatibility functions - keep for backward compatibility -function adjustGridChargePower(delta) { - if (controlsManager) { - return controlsManager.adjustGridChargePower(delta); - } -} - -function menu_controls_override(icons, ac_charge_max, auto_active) { - if (controlsManager) { - return controlsManager.showOverrideMenu(ac_charge_max, auto_active); - } -} - -function handleModeChange(mode) { - if (controlsManager) { - return controlsManager.handleModeChange(mode); - } -} \ No newline at end of file diff --git a/src/web/js/main.js b/src/web/js/main.js index 47a729d5..20d8fff1 100644 --- a/src/web/js/main.js +++ b/src/web/js/main.js @@ -21,6 +21,7 @@ let max_charge_power_w = 0; let inverter_mode_num = -1; let chartInstance = null; let menuControlEventListener = null; +let data_controls = null; // Global data_controls for use across modules // Constants are now loaded from constants.js @@ -64,7 +65,7 @@ function handlingErrorInResponse(data_response) { // ADD ASYNC KEYWORD HERE - THIS WAS MISSING! async function showCurrentData() { //console.log("------- showCurrentControls -------"); - const data_controls = await dataManager.fetchCurrentControls(currentTestScenario); + data_controls = await dataManager.fetchCurrentControls(currentTestScenario); showCarChargingData(data_controls); // Use controlsManager to update controls (check if it exists first) diff --git a/src/web/js/ui.js b/src/web/js/ui.js index 631f31cf..5f7bdfb8 100644 --- a/src/web/js/ui.js +++ b/src/web/js/ui.js @@ -176,6 +176,15 @@ function showMainMenu(version) { `; dropdown.innerHTML = ` +
+ + Override Controls +
+ +
+
@@ -329,8 +338,8 @@ const MenuNotifications = { dot.className = 'notification-dot'; dot.style.cssText = ` position: absolute; - top: 2px; - right: 2px; + top: -2px; + right: -2px; width: 6px; height: 6px; background-color: ${dotColor}; @@ -457,6 +466,18 @@ function showAlarmsMenu() { } } +/** + * Show override controls menu using modern full-screen overlay + */ +function showOverrideControlsMenu() { + if (controlsManager) { + controlsManager.showOverrideMenuFullScreen(); + } else { + showFullScreenOverlay("Override Controls", "
Controls system not initialized
"); + setTimeout(() => closeFullScreenOverlay(), 2000); + } +} + /** * Show logs menu using LoggingManager */ @@ -470,19 +491,77 @@ function showLogsMenu() { } /** - * Show info menu (original version info) + * Show info menu using modern full-screen overlay */ function showInfoMenu(version) { - const infoContent = - 'currently installed:

' + version + "

" + - "
" + - '
' + - '' + - '' + - '' + - "
"; + const header = ` +
+ + EOS connect Information +
+ `; - overlayMenu("Version Info", infoContent); + const content = ` +
+ +
+
+ Version Information +
+
Currently installed version:
+
+ ${version} +
+
+ + + + + +
+ + Made with care for the EOS ecosystem +
+
+ `; + + showFullScreenOverlay(header, content); } /** @@ -579,12 +658,15 @@ function showFullScreenOverlay(header, content, close = true) { /** * Close full-screen overlay + * @param {number} waittime - Optional delay before closing (ms) */ -function closeFullScreenOverlay() { +function closeFullScreenOverlay(waittime = 0) { const overlay = document.getElementById('full_screen_overlay'); if (overlay) { - overlay.style.display = 'none'; - + setTimeout(() => { + overlay.style.display = 'none'; + }, waittime); + // Restore background scrolling document.body.style.overflow = ''; From 2d277131f8c178b9e9e22f3dabe34e770d8f7410 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 4 Oct 2025 21:30:44 +0200 Subject: [PATCH 032/132] feat: add EOS Connect icons for control modes and update ControlsManager and ScheduleManager to utilize them --- src/web/js/constants.js | 9 +++++++++ src/web/js/controls.js | 28 +++++++++---------------- src/web/js/schedule.js | 45 ++++++++++++++++++----------------------- 3 files changed, 39 insertions(+), 43 deletions(-) diff --git a/src/web/js/constants.js b/src/web/js/constants.js index a8d1c060..94c0a124 100644 --- a/src/web/js/constants.js +++ b/src/web/js/constants.js @@ -12,6 +12,15 @@ const COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST = "#3399FF"; const COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV = "lightgreen"; const COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV = "darkorange"; +const EOS_CONNECT_ICONS = [ + { icon: "fa-plug-circle-bolt", color: COLOR_MODE_CHARGE_FROM_GRID, title: "Charge From Grid" }, + { icon: "fa-lock", color: COLOR_MODE_AVOID_DISCHARGE, title: "Avoid Discharge" }, + { icon: "fa-battery-half", color: COLOR_MODE_DISCHARGE_ALLOWED, title: "Discharge Allowed" }, + { icon: "fa-charging-station", color: COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST, title: "Avoid Discharge Due to E-Car Fast Charge" }, + { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV, title: "Discharge Allowed During E-Car Charging in PV Mode" }, + { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, title: "Discharge Allowed During E-Car Charging in Min+PV Mode" } +]; + // Global managers - will be initialized in main.js let controlsManager; let scheduleManager; diff --git a/src/web/js/controls.js b/src/web/js/controls.js index db3ccfbd..bc1d891e 100644 --- a/src/web/js/controls.js +++ b/src/web/js/controls.js @@ -6,14 +6,6 @@ class ControlsManager { constructor() { this.menuControlEventListener = null; - this.icons = [ - { icon: "fa-plug-circle-bolt", color: COLOR_MODE_CHARGE_FROM_GRID, title: "Charge From Grid" }, - { icon: "fa-lock", color: COLOR_MODE_AVOID_DISCHARGE, title: "Avoid Discharge" }, - { icon: "fa-battery-half", color: COLOR_MODE_DISCHARGE_ALLOWED, title: "Discharge Allowed" }, - { icon: "fa-charging-station", color: COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST, title: "Avoid Discharge Due to E-Car Fast Charge" }, - { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV, title: "Discharge Allowed During E-Car Charging in PV Mode" }, - { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, title: "Discharge Allowed During E-Car Charging in Min+PV Mode" } - ]; } /** @@ -83,7 +75,7 @@ class ControlsManager {
- ${this.icons.slice(0, 3).map((icon, index) => { + ${EOS_CONNECT_ICONS.slice(0, 3).map((icon, index) => { // Mode numbers in data are 1-based (1,2,3) but our array is 0-based (0,1,2) // So we need to compare (currentModeNum - 1) with index, OR currentModeNum with (index + 1) // const isCurrentMode = overrideActive && (currentModeNum === (index + 1)); @@ -138,7 +130,7 @@ class ControlsManager { Return to Automatic Mode
- Override is currently active (Mode ${this.icons[currentModeNum].title}). Click to cancel the override and return to automatic optimization mode. + Override is currently active (Mode ${EOS_CONNECT_ICONS[currentModeNum].title}). Click to cancel the override and return to automatic optimization mode.
@@ -234,7 +226,7 @@ class ControlsManager { text-align: center; width: 120px; border-radius: 8px; - border: 2px solid ${this.icons[0].color}; + border: 2px solid ${EOS_CONNECT_ICONS[0].color}; background-color: rgba(58, 58, 58, 0.8); color: white; "> @@ -269,7 +261,7 @@ class ControlsManager { // Add mode-specific control logic and touch event listeners setTimeout(() => { // Add click handlers for mode buttons to show/hide relevant controls - this.icons.slice(0, 3).forEach((icon, index) => { + EOS_CONNECT_ICONS.slice(0, 3).forEach((icon, index) => { const button = document.getElementById(`mode_${index}`); if (button && !button.disabled) { const originalOnClick = button.getAttribute('onclick'); @@ -302,7 +294,7 @@ class ControlsManager { */ selectModeForOverride(mode) { // Highlight selected mode button - this.icons.slice(0, 3).forEach((icon, index) => { + EOS_CONNECT_ICONS.slice(0, 3).forEach((icon, index) => { const button = document.getElementById(`mode_${index}`); if (button) { if (index === mode) { @@ -478,7 +470,7 @@ class ControlsManager { iconElement.innerHTML = ""; // Clear previous content - const iconData = this.icons[inverterModeNum] || {}; + const iconData = EOS_CONNECT_ICONS[inverterModeNum] || {}; const { icon, color, title } = iconData; iconElement.innerHTML = ``; diff --git a/src/web/js/schedule.js b/src/web/js/schedule.js index 6a99b3b0..b5d7ce88 100644 --- a/src/web/js/schedule.js +++ b/src/web/js/schedule.js @@ -26,6 +26,8 @@ class ScheduleManager { var discharge_allowed = data_response["discharge_allowed"]; var ac_charge = data_response["ac_charge"]; var inverter_mode_num = data_controls["current_states"]["inverter_mode_num"]; + var manual_override_active = data_controls["current_states"]["override_active"]; + var manual_override_active_until = data_controls["current_states"]["override_end_time"]; var max_charge_power_w = data_controls["max_charge_power_w"]; // Add timezone indicator to schedule header @@ -44,6 +46,8 @@ class ScheduleManager { priceData.forEach((value, index) => { if (index > 23) return; + var currentModeAtHour = (ac_charge[(index + currentHour)]) ? 0 : (discharge_allowed[(index + currentHour)] === 1) ? 2 : 1; + if ((index + 1) % 4 === 0 && (index + 1) !== 0) { var row = document.createElement('div'); row.className = 'table-row'; @@ -78,36 +82,27 @@ class ScheduleManager { buttonDiv.style.display = "inline-block"; buttonDiv.style.textAlign = "center"; + // car charging override active if (index === 0 && inverter_mode_num > 2) { - // override first hour - if eos connect overriding eos + // override first hour - if eos connect overriding eos by evcc + buttonDiv.style.color = EOS_CONNECT_ICONS[inverter_mode_num].color; if (inverter_mode_num === 3) { // MODE_AVOID_DISCHARGE_EVCC_FAST - //buttonDiv.style.backgroundColor = "#3399FF"; - buttonDiv.style.color = COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST; - buttonDiv.innerHTML = " "; + buttonDiv.innerHTML = " "; } else if (inverter_mode_num === 4) { // MODE_DISCHARGE_ALLOWED_EVCC_PV - //buttonDiv.style.backgroundColor = "#3399FF"; - buttonDiv.style.color = COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV; - buttonDiv.innerHTML = " "; + buttonDiv.innerHTML = " "; } else if (inverter_mode_num === 5) { //MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV - //buttonDiv.style.backgroundColor = "rgb(255, 144, 144)"; - buttonDiv.style.color = COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV; - buttonDiv.innerHTML = " "; + buttonDiv.innerHTML = " "; + } + } + else { + // 30 minutes in seconds = 30 * 60 = 1800 + if (manual_override_active && (manual_override_active_until - (labelTime.getTime() / 1000)) > -(45 * 60)) { + buttonDiv.style.color = EOS_CONNECT_ICONS[inverter_mode_num].color; + buttonDiv.innerHTML = " "; + } else { + buttonDiv.style.color = EOS_CONNECT_ICONS[currentModeAtHour].color; + buttonDiv.innerHTML += ""; } - } else if (discharge_allowed[(index + currentHour)] === 1) { - //buttonDiv.style.backgroundColor = "grey"; - buttonDiv.style.color = COLOR_MODE_DISCHARGE_ALLOWED; - buttonDiv.innerHTML = ""; - } else if (ac_charge[(index + currentHour)]) { - //buttonDiv.style.backgroundColor = color_bat_grid_charging; - buttonDiv.style.color = COLOR_MODE_CHARGE_FROM_GRID; - let acChargeValue = ac_charge[(index + currentHour)] === 0 ? "" : (ac_charge[(index + currentHour)] / 1000).toFixed(1) + ' kWh'; - buttonDiv.innerHTML = " " + acChargeValue; - buttonDiv.style.padding = "0 10px"; - buttonDiv.style.width = ""; - } else { - //buttonDiv.style.backgroundColor = ""; - buttonDiv.style.color = COLOR_MODE_AVOID_DISCHARGE; - buttonDiv.innerHTML = ""; } cell2.appendChild(buttonDiv); From ef3ccb1b5b144f6e657ad59882e8c798bfb070c4 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 4 Oct 2025 21:57:06 +0200 Subject: [PATCH 033/132] fix: update logging.js for improved functionality and compatibility --- src/web/js/logging.js | Bin 161720 -> 151922 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/web/js/logging.js b/src/web/js/logging.js index b1d160e314e2aab8905326098217b251bdcb72c4..9b3a5c13260479ae570ee5100a722bfabc906864 100644 GIT binary patch delta 836 zcmbtS-)quQ6hGJ2P%-^#&L7*D-&C4K+az42TWd%Jy^JM2MGwx}LXgeeG8#T;L_|<* zoW<=SMnugvEBy9-P^guEL1-*CBFcvzBKooQ(rf4X5G@LVI2B|@|;cr$AyLHFV9RjoZnjS4YEi(QRo zMyQcVtX1f5F`||iLkz^-}B%VyTBZMk$LdGs3xL#0l1_R%@A-24Byg_=-WtiMdz=+AQu`V@`ZovHS~d zEkC@-h6GO^CcpbJIp2Y6pN(qx!}Jwg*Ege7wsHLnVm5fJnSV^O$G=7nY}+(1EL`g? P-95|Z^`%Un^;drYC4Bn8 delta 846 zcmaKqT}YE*6vvQdk5*5^3S3QRkgkbrBCQ=Q*77ocDiz=ltvo^KFEA zh6d1eOM|NeCT>m^l#+cg4S3)el%Vas8eQuqX?!FTFmf~oTZiu8Qj$?Jk7+}2;+`J+ zALub~Hx6yXjdB3ZVoULPF3LSn-(srghR3bX?=Prf{UAAL}Nw@+LD!$W4SYiiYxc#u>Vx# zzpp?cx_l%mMT^==lhpC8qXPp5HUGXJR6MO2=DqF~=vRf zu!$5pNMQ>0&ctKSE)}HVr->-+f0&5oN2%dXij~plq#kmqhkgD89K4;5Nh=04Kd$EP zKG382YlftqtN>X8(agXvQ`v;4m00ch3s3A()PpBHLwbN z;S3b;?P8#X=}nS{7m4X3N-X|3C6CQ(bwhP18rRgkvm6XjBK7BB6@<-6h?`h5Yv5xw z5FK8|s>rQmjTEY4&AfgPV#6p(xkLne4=13sAzl_|i!&7Uzh<{!) zJMYScDDH{|Js-7!ne^(os|%E9INL6-1s4@CA6z?g{psMI1TGkOPaqKLHxAsSUCh{{Zr@IK%(| From 2b6867668c3e9d401d1c97463f3714622d7e3c76 Mon Sep 17 00:00:00 2001 From: ohAnd Date: Sat, 4 Oct 2025 20:18:17 +0000 Subject: [PATCH 034/132] [AUTO] Update version to 0.1.24.128-develop Files changed: M src/version.py --- src/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.py b/src/version.py index e4c9b93e..d1ef1532 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = '0.2.01.126-develop' +__version__ = '0.1.24.128-develop' From 2545d336427ca9fcf3af9424694dfebeb0c9a30d Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 4 Oct 2025 22:25:52 +0200 Subject: [PATCH 035/132] update version prefix in Docker workflows to 0.2.x --- .github/workflows/docker_develop.yml | 2 +- .github/workflows/docker_main.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker_develop.yml b/.github/workflows/docker_develop.yml index 58229177..691adb88 100644 --- a/.github/workflows/docker_develop.yml +++ b/.github/workflows/docker_develop.yml @@ -14,7 +14,7 @@ on: workflow_dispatch: # allows manual triggering of the workflow env: - VERSION_PREFIX: 0.1.24. + VERSION_PREFIX: 0.2.01. VERSION_SUFFIX: -develop jobs: diff --git a/.github/workflows/docker_main.yml b/.github/workflows/docker_main.yml index e9bca685..9cfcc61d 100644 --- a/.github/workflows/docker_main.yml +++ b/.github/workflows/docker_main.yml @@ -16,7 +16,7 @@ on: workflow_dispatch: # allows manual triggering of the workflow env: - VERSION_PREFIX: 0.1. + VERSION_PREFIX: 0.2. jobs: publish_image: From 8d350262b0e9479e09368b1c52a51bdb770bd9e4 Mon Sep 17 00:00:00 2001 From: ohAnd Date: Sat, 4 Oct 2025 20:26:10 +0000 Subject: [PATCH 036/132] [AUTO] Update version to 0.2.01.129-develop Files changed: M src/version.py --- src/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.py b/src/version.py index d1ef1532..6824362f 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = '0.1.24.128-develop' +__version__ = '0.2.01.129-develop' From 2be5a2b002e68a5f60df55d70460cc593eddadc4 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sun, 5 Oct 2025 09:17:15 +0200 Subject: [PATCH 037/132] fix: web api pathes for logger to align also with HA addon --- src/web/js/logging.js | Bin 151922 -> 151912 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/web/js/logging.js b/src/web/js/logging.js index 9b3a5c13260479ae570ee5100a722bfabc906864..2630a06fd3a38ba00fc3627ffa637b3bc26f18de 100644 GIT binary patch delta 71 zcmV-N0J#71q6z4t39zPq2W$Xu0A~PmlaV$dv(SEe3X{N<7PGdEd;ybS0t}PD0t%D< d0S2@BilzdyqK)hWvyhQ41cShpx4@MFvb$M_ATj^| delta 69 zcmaE{i1X7T&JD}zIQ1EF81fm?8Hy)QG*g`XK#pg+T_hvh<_C2dT#Wjg-8(~BfYj{X Xx6DAQeWE^)*SvM=_N`MHH}3`jhG`mP From e7d99e41f916a8116ba2f1e05c89a6cdbbb3cb1f Mon Sep 17 00:00:00 2001 From: ohAnd Date: Sun, 5 Oct 2025 07:17:37 +0000 Subject: [PATCH 038/132] [AUTO] Update version to 0.2.01.130-develop Files changed: M src/version.py --- src/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.py b/src/version.py index 6824362f..cf7cb631 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = '0.2.01.129-develop' +__version__ = '0.2.01.130-develop' From 3b43b3c07d41c1709439dcc9f4f7e16beaece797 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:12:38 +0200 Subject: [PATCH 039/132] refactor: improve code formatting and enhance timestamp handling in MemoryLogHandler --- src/eos_connect.py | 2 +- src/log_handler.py | 36 +++++++++++++++++++++++++++++------- src/web/js/logging.js | Bin 151912 -> 152756 bytes 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/eos_connect.py b/src/eos_connect.py index a3fe2895..0e8ce60f 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -1119,7 +1119,7 @@ def serve_test_json_files(filename): content_type="application/json", ) - # Additional security: only allow files with .test.json ending + # Additional security: only allow files with .test.json ending # (all test files must follow this naming convention) if not filename.endswith(".test.json"): logger.warning( diff --git a/src/log_handler.py b/src/log_handler.py index e27b2e97..c1d171b5 100644 --- a/src/log_handler.py +++ b/src/log_handler.py @@ -34,7 +34,7 @@ def __init__(self, max_records=1000, max_alerts=1000): self._shutdown = False # Define alert levels - self.alert_levels = {'WARNING', 'ERROR', 'CRITICAL'} + self.alert_levels = {"WARNING", "ERROR", "CRITICAL"} def emit(self, record): """Store the log record in memory - completely non-blocking version""" @@ -55,8 +55,23 @@ def emit(self, record): return # Create log entry with minimal processing + # Use timezone-aware timestamp if formatter is timezone-aware + if ( + hasattr(self, "formatter") + and self.formatter + and hasattr(self.formatter, "tz") + and self.formatter.tz + ): + # Use the formatter's timezone to create timezone-aware timestamp + timestamp = datetime.fromtimestamp( + record.created, self.formatter.tz + ).isoformat() + else: + # Fallback to naive timestamp (original behavior) + timestamp = datetime.fromtimestamp(record.created).isoformat() + log_entry = { - "timestamp": datetime.fromtimestamp(record.created).isoformat(), + "timestamp": timestamp, "level": record.levelname, "message": ( str(record.msg) if hasattr(record, "msg") else "No message" @@ -160,7 +175,9 @@ def get_alerts(self, levels=None, limit=None, since=None): try: # Filter by specific levels if provided if levels != list(self.alert_levels): - alerts = [alert for alert in alerts if alert.get("level", "") in levels] + alerts = [ + alert for alert in alerts if alert.get("level", "") in levels + ] # Filter by time if provided if since: @@ -169,7 +186,8 @@ def get_alerts(self, levels=None, limit=None, since=None): alerts = [ alert for alert in alerts - if datetime.fromisoformat(alert.get("timestamp", "")) >= since_dt + if datetime.fromisoformat(alert.get("timestamp", "")) + >= since_dt ] except (ValueError, TypeError): pass @@ -232,14 +250,18 @@ def get_buffer_stats(self): "main_buffer": { "current_size": len(self.records), "max_size": self.max_records, - "usage_percent": round((len(self.records) / self.max_records) * 100, 1) + "usage_percent": round( + (len(self.records) / self.max_records) * 100, 1 + ), }, "alert_buffer": { "current_size": len(self.alert_records), "max_size": self.max_alerts, - "usage_percent": round((len(self.alert_records) / self.max_alerts) * 100, 1) + "usage_percent": round( + (len(self.alert_records) / self.max_alerts) * 100, 1 + ), }, - "alert_levels": list(self.alert_levels) + "alert_levels": list(self.alert_levels), } return stats finally: diff --git a/src/web/js/logging.js b/src/web/js/logging.js index 2630a06fd3a38ba00fc3627ffa637b3bc26f18de..0227359abf14cbe386bbba4ad672b03f553bef92 100644 GIT binary patch delta 474 zcmYL`&r1S96vy94ghCxGY(;}cjl3vzwIB(kOHnKO2c)^0um`T| zb?Xoq-Re*1+`$e(r=tI$@9ZwbF#F!{KJWW}?AJ}~@jmwO9BuD9E z*skA*<2Tlm@oiR=lPW80)WcP&>;{%*A)^JAU{a3NsmeB}4sIWeiTU$XATFyJmd?Y$ yQ%69xpymz3VVmJ1#m~8>1*}@#p)X_vEdEJCynU_+tGq1q#Po$e8tWsL9RC4!bZ_(k delta 40 ycmV+@0N4MtsR`(!39z`Cli(f Date: Sun, 5 Oct 2025 13:50:34 +0200 Subject: [PATCH 040/132] feat: clickable links and log [Main] with color in web logger --- src/interfaces/load_interface.py | 2 +- src/web/js/logging.js | Bin 152756 -> 151520 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interfaces/load_interface.py b/src/interfaces/load_interface.py index 83a09249..1e8fe440 100644 --- a/src/interfaces/load_interface.py +++ b/src/interfaces/load_interface.py @@ -465,7 +465,7 @@ def get_load_profile_for_day(self, start_time, end_time): + quote((current_time - timedelta(hours=2)).isoformat()) + "&end_date=" + quote((current_time + timedelta(hours=2)).isoformat()) - + ")" + + " )" ) logger.error( "[LOAD-IF] DATA ERROR load smaller than car load " diff --git a/src/web/js/logging.js b/src/web/js/logging.js index 0227359abf14cbe386bbba4ad672b03f553bef92..fc66ee5dd46b20ee5788520be0ce59a50df3e69c 100644 GIT binary patch delta 4035 zcmb7H3s6+o8NUB@p_>(MSF%A|a9P%smj)M#por^Atx|(#A%@XJad{~*2&)pr#28`? zb!=i8PU7GAXwVp6V;$;sIF>O;jj@b1lsZmo9jCK8j;7XOm^PDI>p1<-x$dHkZKuPq z_nv#s|N8#tIdS-@&__>oU9hkntu{7nImXT%O~ODDO4fD`MTF@MfCo zVEe2s#!JDN&0=2IF90A}V}V$oY? z4+smBSbMEm-}e2js#M95mmmSaz+N!1wzpPxX1X4zCidA>MAKGgZ^xaj11_r_{#N%8VWMME7te9B{Cm7)_R4YKwOekUQ zm2F`c;^09r;qGd%{ z%?7_~V%tyGu=e$Fx~KV!^bEGU2+Q*=aoGL_*qJZg%7QIBh1JZnM2m=x(Q7tDix6I{ zU-?n0VS^-%V3<*q3{1 zWQ_CPG}Q`h)knF43Z0A8=uu8(CjPDfk{cH&waPmBt5jCfTe?!OG}2p!GDC4HQ|QmB z%%v}DmDO~jiB_v0JMUCh$XzGVy`nTIuM#IGoorBQ=vhbKR?@H76VE1P9ZTtY9s?Qb zWZm7F6^))$ReQ@2`EAxs#QI50oXX3}0umwwE_*~=HJxuznn}U6Qq>ogGNnW+WN@mM zid2)5l}Z)8*AgwMW)gbinqR><*6^Vt_Ax+MqHThJ`NiUInev~sXd*)e=k@e#9Famo z#Mf5{FsBkE4GDJ4E{8-Ga>U^9=U|#D>?x5})sp75(jhH$dM5o(QIC)v- zW;&TPG6JHStRxn!GBXVV?Cs+jcyb458MVZqfnvy|conzu#v_N{R0>J$btFhci5E-X zfn>ZmLrrDDS!RL7?AHjK`X>O8E+eRkDAy^olmY@ofHH+(si5aNf@BiKEkVM5PiBfJ z|DP8AyC`8-8Yx&w&pPR&6@-wu319J5+kv$w!>Rz5afuRG8iscU-K;Z&Qx-FS%Mp=V(3Bc3|tx8P6?^|_5> zpKMwanBI3A7|6z zW##=7py=Ulw&;u)3ews8YavIjvbKO|Wa6xie=`wUq!`ids3CJ|$O(oTS}!=Tz)Pz6 zGNEKdH9VgT8eWB@VS!-+6d#=ya8&Us)K ztVC$U8$-zDdN@mhUqY<`b_)$;t~Xt?v#yXCPv%1mS{n__#1000;Glsto)0uahap@P z5cc(2E8n*U;vPmwhKvqQ(LuA|Ihv0LOU>Bd3|6-3FIMJnwz2cQMcDd1Rbxqg7F_iK zg>dM7Sjbz~Lz#&v>>W2WZrKRN+$k02KYJ5;3By$6}m zk6^{WVsKduX^{%XhHs{G?@m}Gq2b5&!0SdO6w`2DzJ)h;K1@QCfhy<2O`i}70|zJ; zD*jBS_NBzj!FlLo$P>IdL!wWsI zUAi-%=HcBvu$kwbf}@h0lVvVxzhR>oC;6sd1I^(ADS-qH%>2Y>u-Pc~VtN%fh0fnV zraU6rhJYUl{y8%HX>j&o`y{GV*5!Wmd-X?7L4($(L1xLq?RyhraR#O|DjNqH6XbF6QHT$%c440%ifHop;lOWUzXVt2D6Q;x=K>Vd6ho6LpZW#tjh2QA zoz+WFF@ngpKSCzZjVys*y#iZ|I0$z)TO|WOi&8}>31^`yRd(vTG;Vn4)><2{gNH0f%%A8>L*H}Z@OwiPcKCC zg*j@!;V6oDJdm!^J(DiCVXw94s+tib;yNfHbLP+;r+J}j!_++Wwo%|FgAEtwt7oKv z;Y$`DoTe5C;v7DwKrOF18T+z94Jb3e`3V0rz;-l97PtS7)gwjL4(A2=6OW h-#1gPlv-gt4?d@!HH8;`zq%G!+jNazE>*9p{{qg76e<7! delta 3639 zcmZ`+e{56t5!byK&o#z-&AiA<-C(G3(=^JwEQHjg=s`pe;}E!H1Otb|7M($qSj%=Y|q zHzxn-GaIIrm*BOT71*n9Rllx#`!OD}oTT=6DElIK&7=3$mXN;HdUvp=)O3IIP{Du$ z-4$xxc>Jg-ai^jlN&cFHbKQlTRKhqu?=vSN$`}y9 zs&W6I(TTZVS#DyhO3jpU7HA~Ni5W9I@-}(WV{TZmQ?v4Q%o0LM9{?A2EHosHDz-^} zInGB*sjUTk=x?gzORV7e5=zfS?kCYd} zXnO&?__Nkp^K8ojCg=Z`SznNOzZ9|h! zrV4!2SFZUnxzcBbSJoHbARSW8Y|f;=V*rozD_>@8)wSYdDtT3B3U3AvuKmGHY$PYF z01dRju4AkBX_S53CY>a}pPpQyQTGomi*jKaiSstv%v{UsKqGkR`wu~cxuw(aLvWlD zRj>e)YtH3)NU?)V&BW7nUn{}~N7nUHY&3tR$BeH}Psu|v_7XpN=hPx|>Bb4b^5?8F z=|K;M``mbIb15agP=)EuLwNggm?;ra_h8~_pSfv^+v`+Tax6%>`ygVTOGS&>f~hG! z4DWaatG8M1nb_vojtk$Q?gzMTGRyE|T&vTRC?r1Es0r@wjCOPF z>osokokQm}3>~&;EZbw~a5Uf)DqlVG>Id@xJD<0xAi=^Fr_#7|qisOu@YyeV@aUzm zdFZrvdJ&40^g%V=`dhTvLDOdkX{?nIw5i0Gg(%Ugxv}c+!&Y|Alh`Vcxat8N$jCrD zNh)G$dA;g{w;zCR94m8j458gO)E|LM*!7E`&5H=hf{I_yf=2cBuzG3i8R<2vKOecQeneJZ3_J{64mE=8^o3pn{sO;^=U6fJo`6@dTen(_^(T$H z`fH^Sxx|qeR2K?iGfHV@ltOiI69-mU{bVE3hqu?|4T8Z~tTL%|aPU#v99W_9%Ch*$ znd^Y@Yy(6wd!L(fb6`TDA_t2?#Qg|FEIcWbgi4BUggV;y52mL>bq0qRoH%74q3)S)Vj z`CKBq5N=O;lX)5Q<(CLFmgu;1S*hq~hl_<%Je>ogqXQ02Dcqq#jCR2WZv84wO=4q* z=MpELgfYN7yaCvCRLv|-JO#%K<#8mp9IKc4#L*wYMNd)f)pI(s4nA~LFE7V2I=>zU z^8>&dC%bzgRnT6Jua-t$2A@d$6o&2jFR|hh#Xv0B0KDAfht@!MfIfYJ(>OYzGxb<0!bH-C#oxT^Ly?9hA3SZp@?*Y@yVtgvWeBJgDJT4>j zP{qqoE*gIh?b9r6;&3R{0rMz*85WV=$!_QU687GW@|{7rIE6B=7W7Re%&ShlXnYOY zCqdfwFRpj*n@~+B-{tb_??YJZd>wWJ#hTSJAv1_jW;4gt@JHwnLvKKDo@CZTIYemj z65bvz%xA%F+Yj57COf6nmqU}Rgie=QASU;vN*aEPhj;F<#RIjONqZ98R(ojwd{`j8 zl+v2t!pS_>1mDBL;q0#>^*g98a9Csx!*02>M(N6NsKnHle*EOi9}E8oxC+FyXchlG z7?UuQA|Y5Ls_eWZ%45Bp&hC#H7yr9Ff1K?wq16ips-_qLRY-=;Wdm!>uz*pzZhQa} z#b}DQQ?RQ<)!c%}ns#atOf7_3%=RfZs2M03A(#=ZuKe1X-!Z7_4-ld=2YBUZJp(oH zZSm3>c+;I;hB)hPEqmirimd=M?H{(ZY^Qt9#Y#t@|9J;^JQ+ zTAP9~X)15Y}kox>Z%x-D+MOxB=JsJHY;akivcJChXUWWKzDL z{5O$qF-%E9(^%HQFf9aI+H?lB1`LM!%zt7X-hCXIi#aJ8f%}y%897TAZi6m7n?w5JzTftPIjLc!hJE zqh-O`JnYFLQjcgne$-0jn6hy#H<`uX16^EyR2%T%f#wM6uhmiJ2`wmYG-*kfV-jqt OXTZul;>Kd_iuPZ+TV6l_ From e68064da67ca855ed6848facb1ad3fbb6ccc04e1 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:04:40 +0200 Subject: [PATCH 041/132] feat: web logger adding search filter --- src/web/js/logging.js | Bin 151520 -> 158454 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/web/js/logging.js b/src/web/js/logging.js index fc66ee5dd46b20ee5788520be0ce59a50df3e69c..583d17c6755cecc1455c53ff2a31ca7e238e6651 100644 GIT binary patch delta 3090 zcmb_eYfO`86n@_Z5K1Yf7g{NouiObx3dn{Rgl)k6U~yv zIORCbAEUS!;x?CHnoQ%C4Sy^;GZV*^eb=tIJ~cW65^juPvnobW4`zqvie1 zZMW0C@seMWSfxw<0eg`6!bdFZFi^s-_md|D`bq5NUm$Gl?5 zunSfg)Ofs*E5T~WL{}l#!e&FE0FM)GT%G1*U1Xx0?P0<}KkG%yJ_748b5-d=F8kP8 z*2nhC@-LJA0CR%nKxw;$vfjsb%a%mh1iX1Bu`VrTNBx*#)!SS{pnmaPY8yM7U4eLnLE5{*hN%UWs0iM-rIC!!QE?&^X>9r=m z&1kL75&~{=9AX5iN;04ztIm3AU~GCAPHf8tPrC|TCSD7gH4>a@&c&%*g*qk3%cdX? z;nKS0Gh8i?;%a%sDxPfQc`)8Q3>TUuvzKBOISQGeUv2b=7@Y8d!T^Ewnb@#_tNnv5 zf5sIwQ)NV4qC)%V??SLkh)2p8aCTpL+8l3itF^v-pPZ$=WN+ClixEUt@+I;pB+8&A zzZ@M&JPtOy)%8KJ{RDR*RRPvTPdCZ*5Dna+0EJGpvFcTFDgM17l7~xel_k5#Y&W43 zu68Z}L=2QVc(T=~>k2^;`CBDJ>%})mxfwOd)D(fw5@3AeGMG{(ptXjp;7G@~&PB+b z1kTIK$w`kOD*mS7M(oc}=wl|z;^3fYPK5KDMsTZ{OYw65;O&0C)bHN>nCFzuo8?H| zuhnPai6;L3Q0Me>32K%Hm}2Ya&$U|ebqUy>>an4UkNS_jo~rQ7RC8B#xCT(g=JD`0hufeGzuALc)|E=3vniQqf4T&K6 z66Tn&`)c5zFQG9M_N*?P&H3PE6b2pXiGT{$KB{B!q#D@iq$E2Q6pwaC{3_VPFdd^~d#M zxN<)qPYv_TlmSX{@V2L(fd@C+;8?EdP&nZpstXA9mDBqNbK7nQG@inoUb zoJ&j{vM=KcZJ3!15AE6l;*L4YW5NcXOm^ri6--T-x^%S-S9_P^w`mL3}0F&iL z82e!bNM9(Sx!4O0XXyJA6GfYC*)Ip-B}igUS27!Y3fOX5)g<|(2-2Cmm~&G@Cvq1=GDwz`d(sh%@kB{S_~%uLh#t858(tg{%UgaO2x?{uhDLi{osB~y=kK8MF)3t1o?8t<2c$&z0`12sHOBD{z*bmiBLO8h)FD=*g`+GiC97mB1N=F zH(QZX`Y7*crVdp>_=YS}j*cP{q%IvK&@O^#UDTlk(@qc1@I1fYpRb|T>(Kj82AsPt z=WggG{IF0@j~4_+^F0Ai>%&3xBt}CP`w87bvLg$>+eTBS5ImJaJZcBB+I?KfpXLXH z)XlATu9|2TTqVa-gq5Pe#o{ve z=cohzBa7=36oj{*Zk87WxFf*~ir5iOm3Kt8GqU`Yp!tF?dp(xT96Hds<1W zY7ezR{Js=HI$|E()?^*A&Yr`)s^0oRY@eYucr&{gDs7=W>9&2tkmJj!MGW<`oYYsp zhgWBdT$v}K7G`(DTq%*wUP%{xd4~kgFOjQ0Ke=6FbDhq^+cY5eoW{*{iqT=?W>oLx z`WxEd*cgrQ+#FqN<1Ki<46F Date: Sun, 5 Oct 2025 21:20:56 +0200 Subject: [PATCH 042/132] feat: create generated bug report for github issue --- src/web/index.html | 1 + src/web/js/bugreport.js | 1431 +++++++++++++++++++++++++++++++++++++++ src/web/js/ui.js | 116 ++-- 3 files changed, 1495 insertions(+), 53 deletions(-) create mode 100644 src/web/js/bugreport.js diff --git a/src/web/index.html b/src/web/index.html index eded49c6..9f7e5011 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -311,6 +311,7 @@ + diff --git a/src/web/js/bugreport.js b/src/web/js/bugreport.js new file mode 100644 index 00000000..ff6df3c6 --- /dev/null +++ b/src/web/js/bugreport.js @@ -0,0 +1,1431 @@ +/** + * Bug Report Manager for EOS Connect + * Handles automated bug report generation with system data collection + */ + +class BugReportManager { + constructor() { + this.repoOwner = 'ohAnd'; + this.repoName = 'openhab'; // Using openhab repo for bug reports + this.maxBodySize = 65536; // GitHub API body size limit (~64KB) + } + + /** + * Show bug report popup with form + */ + async showBugReportPopup() { + console.log('[BugReport] Preparing bug report popup...'); + + // Get version information + let versionInfo = 'Version unknown'; + try { + const response = await fetch('/json/current_controls.json'); + if (response.ok) { + const status = await response.json(); + if (status.eos_connect_version) { + versionInfo = status.eos_connect_version; + } + } + } catch (error) { + console.warn('[BugReport] Could not fetch version info:', error); + } + + const header = ` +
+ + Create Bug Report +
+ `; + + const content = ` +
+
+ +
+ + +
+ + +
+ + +
+ + +
+
+ System Data Selection +
+ +
+ +
+ +
+ +
Critical issues and warnings from recent logs
+
+ +
+ + +
+ +
+ +
Current battery, inverter, and system states
+
+
+ + +
+
+ + +
+ +
+ +
Parameters sent to optimization engine
+
+
+ + +
+
+ + +
+ +
+ +
Results received from optimization engine
+
+
+ + +
+
+ + +
+ +
+ +
Complete log history for debugging
+
+ +
+
+ +
+ + Privacy: Only system configuration and error logs are included. No personal data, credentials, or sensitive information is collected. Please review the data before submission. +
+
+ + +
+ +
+ + +
+

+ How to Create the GitHub Issue: +

+
    +
  1. Fill out title and description above
  2. +
  3. Check which system data should be attached below
  4. +
  5. Click "Copy to Clipboard" to copy formatted data
  6. +
  7. Click "Open GitHub Form" to open pre-filled issue
  8. +
  9. Paste the clipboard content at the end of the GitHub issue description
  10. +
+
+ +
+ + + +
+
+ + +
+ +
+
+
+
+ `; + + showFullScreenOverlay(header, content); + + // Enable/disable the generate buttons based on form validation + const titleInput = document.getElementById('bugTitle'); + const descriptionInput = document.getElementById('bugDescription'); + const generateUrlBtn = document.getElementById('generateUrlBtn'); + + function updateButtonState() { + const isValid = titleInput.value.trim() !== '' && descriptionInput.value.trim() !== ''; + + // Update Copy button + const copyDataBtn = document.getElementById('copyDataBtn'); + copyDataBtn.disabled = !isValid; + copyDataBtn.style.cursor = isValid ? 'pointer' : 'not-allowed'; + copyDataBtn.style.opacity = isValid ? '1' : '0.6'; + + // Update URL button + generateUrlBtn.disabled = !isValid; + generateUrlBtn.style.cursor = isValid ? 'pointer' : 'not-allowed'; + generateUrlBtn.style.opacity = isValid ? '1' : '0.6'; + } + + titleInput.addEventListener('input', updateButtonState); + descriptionInput.addEventListener('input', updateButtonState); + + // Focus on title field + setTimeout(() => titleInput.focus(), 100); + } + + + + /** + * Generate GitHub bug report using URL method (always works, no authentication) + */ + async generateGitHubURL() { + const titleInput = document.getElementById('bugTitle'); + const descriptionInput = document.getElementById('bugDescription'); + const generateUrlBtn = document.getElementById('generateUrlBtn'); + + if (!titleInput.value.trim() || !descriptionInput.value.trim()) { + alert('Please fill in both title and description fields.'); + return; + } + + // Show loading state + generateUrlBtn.innerHTML = 'Collecting...'; + generateUrlBtn.disabled = true; + + try { + // Clean and encode description for URL - keep it simple + let issueBody = descriptionInput.value.trim(); + + // Fix markdown formatting for URL encoding + issueBody = issueBody + .replace(/\*\*([^*]+)\*\*/g, '**$1**') // Fix bold formatting + .replace(/##\s+/g, '## ') // Fix header spacing + .replace(/\n\s*\n\s*\n/g, '\n\n') // Remove excessive newlines + .replace(/\[([^\]]+)\]/g, '$1'); // Remove placeholder brackets + + // Keep the description clean for GitHub URL + + // Update button text + generateUrlBtn.innerHTML = 'Opening...'; + + // Open GitHub with pre-filled form and bug label + this.openGitHubIssueURL(titleInput.value.trim(), issueBody); + + // Close the popup after a brief delay + setTimeout(() => { + closeFullScreenOverlay(); + }, 1000); + + } catch (error) { + console.error('[BugReport] Error generating URL bug report:', error); + + // Show user-friendly error message + const errorMessage = error.message.includes('fetch') + ? 'Unable to collect system data. Opening GitHub form without system data.' + : 'Error collecting system data. Opening GitHub form with basic information.'; + + alert(errorMessage); + + // Fallback: Open GitHub with just title and cleaned description + let fallbackBody = descriptionInput.value.trim() + .replace(/\[([^\]]+)\]/g, '$1') // Remove placeholder brackets + .replace(/\n\s*\n\s*\n/g, '\n\n'); // Clean excessive newlines + this.openGitHubIssueURL(titleInput.value.trim(), fallbackBody); + + // Close popup + setTimeout(() => { + closeFullScreenOverlay(); + }, 1000); + } + } + + /** + * Collect system data for bug report using existing managers + */ + async collectSystemData() { + const data = { + timestamp: new Date().toISOString(), + version: null, + currentControls: null, + optimizeRequest: null, + optimizeResponse: null, + recentLogs: null, + alerts: null, + errors: [] + }; + + try { + // Get version from global if available + if (typeof window.eosConnectVersion !== 'undefined') { + data.version = window.eosConnectVersion; + } + + // Use DataManager for JSON data collection + const promises = []; + + // Current controls using existing DataManager + promises.push( + dataManager.fetchCurrentControls() + .then(result => { + data.currentControls = result; + if (result.eos_connect_version) { + data.version = result.eos_connect_version; + } + }) + .catch(error => { + data.errors.push(`Error fetching current_controls.json: ${error.message}`); + }) + ); + + // Optimization request using existing DataManager + promises.push( + dataManager.fetchOptimizationRequest() + .then(result => { + data.optimizeRequest = result; + }) + .catch(error => { + data.errors.push(`Error fetching optimize_request.json: ${error.message}`); + }) + ); + + // Optimization response using existing DataManager + promises.push( + dataManager.fetchOptimizationResponse() + .then(result => { + data.optimizeResponse = result; + }) + .catch(error => { + data.errors.push(`Error fetching optimize_response.json: ${error.message}`); + }) + ); + + // Recent logs using existing LoggingManager + promises.push( + (async () => { + try { + // Check if loggingManager is available + if (typeof loggingManager !== 'undefined' && loggingManager.fetchLogs) { + const logData = await loggingManager.fetchLogs(null, 100); + data.recentLogs = logData; + } else { + // Fallback to direct fetch if loggingManager not available + const response = await fetch('/logs?limit=100&nocache=' + Date.now()); + if (response.ok) { + data.recentLogs = await response.json(); + } else { + throw new Error(`HTTP ${response.status}`); + } + } + } catch (error) { + data.errors.push(`Error fetching logs: ${error.message}`); + } + })() + ); + + // Alerts using existing LoggingManager + promises.push( + (async () => { + try { + if (typeof loggingManager !== 'undefined' && loggingManager.fetchAlerts) { + const alertData = await loggingManager.fetchAlerts(); + data.alerts = alertData.alerts || loggingManager.alerts || []; + } else { + // Fallback to direct fetch + const response = await fetch('/logs/alerts?nocache=' + Date.now()); + if (response.ok) { + const alertData = await response.json(); + data.alerts = alertData.alerts || []; + } else { + throw new Error(`HTTP ${response.status}`); + } + } + } catch (error) { + data.errors.push(`Error fetching alerts: ${error.message}`); + } + })() + ); + + // Wait for all data collection to complete + await Promise.allSettled(promises); + + console.log(`[BugReport] Data collection completed. ${data.errors.length} errors occurred.`); + return data; + + } catch (error) { + console.error('[BugReport] Critical error in collectSystemData:', error); + data.errors.push(`Critical error: ${error.message}`); + return data; // Return partial data instead of throwing + } + } + + /** + * Generate GitHub issue body with system data (optimized for larger size limits) + */ + generateIssueBody(description, systemData) { + let body = ''; + + // Add user description + body += '## Description\\n\\n'; + body += description + '\\n\\n'; + + // Add system information + body += '## System Information\\n\\n'; + if (systemData.version) { + body += `**EOS Connect Version:** ${systemData.version}\\n`; + } + body += `**Report Generated:** ${systemData.timestamp}\\n`; + if (systemData.errors.length > 0) { + body += `**Data Collection Errors:** ${systemData.errors.length} error(s) occurred\\n`; + } + body += '\\n'; + + // Add system data as collapsed sections with size monitoring + body += '## System Data\\n\\n'; + let currentSize = body.length; + const sizeLimit = this.maxBodySize - 1000; // Reserve space for footer and safety margin + + // Helper function to add section if it fits + const addSectionIfFits = (sectionTitle, sectionData, formatAsJson = true) => { + let sectionContent = `
\\n${sectionTitle}\\n\\n`; + if (formatAsJson) { + sectionContent += '```json\\n' + JSON.stringify(sectionData, null, 2) + '\\n```\\n\\n'; + } else { + sectionContent += '```\\n' + sectionData + '\\n```\\n\\n'; + } + sectionContent += '
\\n\\n'; + + if (currentSize + sectionContent.length < sizeLimit) { + body += sectionContent; + currentSize += sectionContent.length; + return true; + } + return false; + }; + + // Current Controls (highest priority) + if (systemData.currentControls) { + if (!addSectionIfFits('Current Controls & States', systemData.currentControls, true)) { + // Try with just essential data + const essentialControls = { + current_states: systemData.currentControls.current_states || {}, + battery: systemData.currentControls.battery || {}, + timestamp: systemData.currentControls.timestamp, + eos_connect_version: systemData.currentControls.eos_connect_version + }; + addSectionIfFits('Current Controls & States (Essential)', essentialControls, true); + } + } + + // Recent Error/Warning Alerts (high priority) - use LoggingManager alerts + if (systemData.alerts) { + const errorAlerts = systemData.alerts.filter(alert => + alert.level === 'ERROR' || alert.level === 'WARNING' + ); + + if (errorAlerts.length > 0) { + let alertText = ''; + errorAlerts.slice(-50).forEach(alert => { // Get last 50 error/warning alerts + alertText += `${alert.timestamp} [${alert.level}] ${alert.message}\\n`; + }); + addSectionIfFits('Recent Error/Warning Alerts', alertText, false); + } + } + + // Recent Logs (lower priority - general logs) + if (systemData.recentLogs && systemData.recentLogs.logs && currentSize < sizeLimit * 0.7) { + let allLogText = ''; + systemData.recentLogs.logs.slice(0, 30).forEach(log => { // Reduced to 30 entries + allLogText += `${log.timestamp} [${log.level}] ${log.message}\\n`; + }); + addSectionIfFits('Recent Logs (Last 30 entries)', allLogText, false); + } + + // Optimization data (medium priority) + if (systemData.optimizeResponse) { + addSectionIfFits('Last Optimization Response', systemData.optimizeResponse, true); + } + + if (systemData.optimizeRequest) { + addSectionIfFits('Last Optimization Request', systemData.optimizeRequest, true); + } + + // Data collection errors (if any) + if (systemData.errors.length > 0) { + const errorText = systemData.errors.join('\\n'); + addSectionIfFits('Data Collection Errors', errorText, false); + } + + // Add size information + body += `\\n**Data Size Info:** ${Math.round(currentSize / 1024 * 10) / 10}KB / ${Math.round(sizeLimit / 1024)}KB limit\\n\\n`; + + // Add footer + body += '---\\n'; + body += '*This bug report was generated automatically by EOS Connect\'s built-in reporting feature.*'; + + return body; + } + + /** + * Generate truncated GitHub issue body for cases where full data is too large + */ + generateTruncatedIssueBody(description, systemData, isUrlMode = false) { + let body = ''; + + // Add user description + body += '## Description\\n\\n'; + body += description + '\\n\\n'; + + // Add system information + body += '## System Information\\n\\n'; + if (systemData.version) { + body += `**EOS Connect Version:** ${systemData.version}\\n`; + } + body += `**Report Generated:** ${systemData.timestamp}\\n`; + if (systemData.errors.length > 0) { + body += `**Data Collection Errors:** ${systemData.errors.length} error(s) occurred\\n`; + } + body += '\\n'; + + // Add truncated system data + body += '## System Data (Truncated)\\n\\n'; + const sizeNote = isUrlMode + ? '_Note: System data was truncated for URL length limitations. For complete data, please use the "Auto-Create Issue" option._\\n\\n' + : '_Note: System data was truncated due to size limitations. Please check the application logs for full details._\\n\\n'; + body += sizeNote; + + // Add basic system info only + if (systemData.currentControls) { + const basicInfo = { + current_states: systemData.currentControls.current_states || {}, + battery: systemData.currentControls.battery || {}, + timestamp: systemData.currentControls.timestamp, + eos_connect_version: systemData.currentControls.eos_connect_version + }; + body += '
\\nBasic System States\\n\\n'; + body += '```json\\n' + JSON.stringify(basicInfo, null, 2) + '\\n```\\n\\n'; + body += '
\\n\\n'; + } + + // Add only recent error alerts (using LoggingManager alerts) + if (systemData.alerts && !isUrlMode) { + const errorAlerts = systemData.alerts.filter(alert => + alert.level === 'ERROR' || alert.level === 'WARNING' + ).slice(-20); // Get last 20 error/warning alerts + + if (errorAlerts.length > 0) { + body += '
\\nRecent Error/Warning Alerts (Last 20)\\n\\n'; + body += '```\\n'; + errorAlerts.forEach(alert => { + body += `${alert.timestamp} [${alert.level}] ${alert.message}\\n`; + }); + body += '```\\n\\n'; + body += '
\\n\\n'; + } + } else if (systemData.alerts && isUrlMode) { + // For URL mode, only show count of errors + const errorAlerts = systemData.alerts.filter(alert => + alert.level === 'ERROR' || alert.level === 'WARNING' + ); + + if (errorAlerts.length > 0) { + body += `**Recent Errors:** ${errorAlerts.length} error/warning entries in alerts\\n\\n`; + } + } + + // Add data collection errors if any + if (systemData.errors.length > 0) { + body += '
\\nData Collection Errors\\n\\n'; + body += '```\\n'; + systemData.errors.forEach(error => { + body += error + '\\n'; + }); + body += '```\\n\\n'; + body += '
\\n\\n'; + } + + // Add footer + body += '---\\n'; + body += '*This bug report was generated automatically by EOS Connect\'s built-in reporting feature.*\\n'; + body += '*Full system data was truncated due to GitHub URL length limitations.*'; + + return body; + } + + /** + * Create GitHub issue using OAuth authentication or fallback methods + */ + async createGitHubIssue(title, body) { + try { + console.log('[BugReport] Attempting to create GitHub issue...'); + + // Check if we have a stored OAuth token + let accessToken = this.getStoredGitHubToken(); + + // Try to create issue with current token (if any) + let response = await this.attemptIssueCreation(title, body, accessToken); + + if (response && response.ok) { + const result = await response.json(); + console.log('[BugReport] GitHub issue created successfully'); + console.log('[BugReport] Issue URL:', result.html_url); + + // Open the created issue + window.open(result.html_url, '_blank'); + return true; + } + + // Handle authentication required + if (response && response.status === 401) { + const result = await response.json(); + + if (result.auth_required) { + console.log('[BugReport] Authentication required, starting OAuth flow...'); + + // Try OAuth authentication + const newToken = await this.authenticateWithGitHub(); + if (newToken) { + // Retry issue creation with new token + const retryResponse = await this.attemptIssueCreation(title, body, newToken); + if (retryResponse && retryResponse.ok) { + const retryResult = await retryResponse.json(); + console.log('[BugReport] GitHub issue created successfully after authentication'); + window.open(retryResult.html_url, '_blank'); + return true; + } + } + } + } + + // Server proxy not configured - try URL method + if (response && response.status === 503) { + const result = await response.json(); + console.log('[BugReport] Server proxy not configured:', result.message); + console.log('[BugReport] Falling back to URL method...'); + this.openGitHubIssueURL(title, body); + return true; + } + + // If we get here, API methods failed - try URL method + console.log('[BugReport] API methods failed, trying URL method...'); + this.openGitHubIssueURL(title, body); + return true; + + } catch (error) { + console.error('[BugReport] Error in createGitHubIssue:', error); + + // Final fallback to issues overview page + console.log('[BugReport] All methods failed, redirecting to issues overview...'); + this.openGitHubIssuesOverview(); + return false; + } + } + + /** + * Attempt to create GitHub issue with given token + */ + async attemptIssueCreation(title, body, accessToken = null) { + const payload = { + title: title, + body: body, + repo: `${this.repoOwner}/${this.repoName}` + }; + + if (accessToken) { + payload.access_token = accessToken; + } + + return await fetch('/api/github/issues', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + }); + } + + /** + * Authenticate user with GitHub using Device Flow (zero setup required) + */ + async authenticateWithGitHub() { + try { + console.log('[BugReport] Starting GitHub Device Flow authentication...'); + + // Start GitHub Device Flow + const authResponse = await fetch('/api/github/auth/start'); + if (!authResponse.ok) { + throw new Error('Failed to start GitHub authentication'); + } + + const deviceData = await authResponse.json(); + + // Show device code to user + const authPromise = new Promise((resolve) => { + // Create modal with device code instructions + const authModal = ` +
+

+ + GitHub Authentication Required +

+
+

To create a bug report, please authorize EOS Connect:

+
+ Verification Code: +
+ ${deviceData.user_code} +
+
+
+
+ +
+
+

1. Click "Open GitHub Authorization"

+

2. Sign in to GitHub if needed

+

3. Enter the verification code: ${deviceData.user_code}

+

4. Authorize EOS Connect

+

This window will close automatically once authorized

+
+
+ +
+
+ Waiting for authorization... +
+
+ `; + + showFullScreenOverlay( + '
GitHub Authentication
', + authModal + ); + + // Start polling for authentication + this.pollGitHubAuth(deviceData.device_code, resolve); + }); + + return await authPromise; + + } catch (error) { + console.error('[BugReport] GitHub authentication error:', error); + return null; + } + } + + /** + * Poll GitHub for device flow completion + */ + async pollGitHubAuth(deviceCode, resolve) { + const maxAttempts = 60; // 5 minutes with 5-second intervals + let attempts = 0; + let pollInterval = 5000; // Start with 5 seconds + + const poll = async () => { + if (attempts >= maxAttempts) { + document.getElementById('authStatus').textContent = 'Authentication timeout. Please try again.'; + setTimeout(() => resolve(null), 2000); + return; + } + + try { + const response = await fetch('/api/github/auth/poll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code: deviceCode }) + }); + + const result = await response.json(); + + if (result.status === 'success') { + document.getElementById('authStatus').innerHTML = ' Authentication successful!'; + this.storeGitHubToken(result.access_token); + setTimeout(() => { + closeFullScreenOverlay(); + resolve(result.access_token); + }, 1000); + return; + } + + if (result.status === 'slow_down') { + pollInterval += 2000; // Slow down polling + } + + attempts++; + setTimeout(poll, pollInterval); + + } catch (error) { + console.error('[BugReport] Polling error:', error); + attempts++; + setTimeout(poll, pollInterval); + } + }; + + // Start polling + setTimeout(poll, 1000); + } + + /** + * Cancel GitHub authentication + */ + cancelGitHubAuth() { + closeFullScreenOverlay(); + } + + /** + * Store GitHub token in session storage + */ + storeGitHubToken(token) { + try { + sessionStorage.setItem('github_access_token', token); + } catch (error) { + console.warn('[BugReport] Could not store GitHub token:', error); + } + } + + /** + * Get stored GitHub token from session storage + */ + getStoredGitHubToken() { + try { + return sessionStorage.getItem('github_access_token'); + } catch (error) { + console.warn('[BugReport] Could not retrieve GitHub token:', error); + return null; + } + } + + /** + * Open GitHub issues overview page when URL method fails + */ + openGitHubIssuesOverview() { + const issuesUrl = `https://github.com/${this.repoOwner}/${this.repoName}/issues`; + + console.log('[BugReport] Opening GitHub issues overview due to technical difficulties...'); + + // Show user-friendly message + alert('There was a technical issue creating the pre-filled bug report.\n\n' + + 'You will be redirected to the GitHub issues page where you can:\n' + + '1. Click "New issue" to create a manual report\n' + + '2. Include the system data from your EOS Connect web interface\n' + + '3. Check the Logs section and JSON endpoints for debugging data'); + + window.open(issuesUrl, '_blank'); + } + + /** + * Open GitHub issue URL with pre-filled data (fallback method) + */ + openGitHubIssueURL(title, body) { + const baseUrl = `https://github.com/${this.repoOwner}/${this.repoName}/issues/new`; + + // For very large bodies, we'll create a more structured approach + if (body.length > 8000) { + // Create a truncated version for URL and provide instructions + const truncatedBody = this.generateUrlSafeBody(title, body); + const params = new URLSearchParams({ + title: title, + body: truncatedBody, + labels: 'bug' + }); + + const githubUrl = `${baseUrl}?${params.toString()}`; + console.log('[BugReport] Opening GitHub with truncated data due to URL limits...'); + window.open(githubUrl, '_blank'); + } else { + const params = new URLSearchParams({ + title: title, + body: body, + labels: 'bug' + }); + + const githubUrl = `${baseUrl}?${params.toString()}`; + console.log('[BugReport] Opening GitHub issue URL...'); + window.open(githubUrl, '_blank'); + } + } + + /** + * Generate a URL-safe body that fits within GitHub's URL limits + */ + generateUrlSafeBody(title, fullBody) { + const maxUrlBodyLength = 6000; // Conservative limit for URL + + if (fullBody.length <= maxUrlBodyLength) { + return fullBody; + } + + // Create a summary version that fits in URL + const lines = fullBody.split('\\n'); + let safebody = ''; + let currentLength = 0; + + // Always include description section + const descriptionEndIndex = lines.findIndex(line => line.startsWith('## System Information')); + if (descriptionEndIndex > 0) { + const descriptionLines = lines.slice(0, descriptionEndIndex); + safebody = descriptionLines.join('\\n') + '\\n\\n'; + currentLength = safebody.length; + } + + // Add system info + safebody += '## System Information\\n\\n'; + safebody += '_System data was truncated due to URL length limitations._\\n'; + safebody += '_Full system data is available in EOS Connect logs and web interface._\\n\\n'; + + // Add instructions for full data + safebody += '## Full System Data\\n\\n'; + safebody += 'To provide complete system data for debugging:\\n'; + safebody += '1. Access your EOS Connect web interface\\n'; + safebody += '2. Go to Logs section and export recent logs\\n'; + safebody += '3. Check JSON endpoints: `/json/current_controls.json`, `/json/optimize_request.json`, `/json/optimize_response.json`\\n'; + safebody += '4. Attach the relevant files to this issue\\n\\n'; + + safebody += '---\\n'; + safebody += '_This bug report was generated automatically by EOS Connect. Full data truncated due to URL limitations._'; + + return safebody; + } + + /** + * Show preview in a smaller modal that doesn't close the main form + */ + showPreviewModal(title, content) { + // Remove existing preview modal if any + const existingModal = document.getElementById('bugReportPreviewModal'); + if (existingModal) { + existingModal.remove(); + } + + // Create modal HTML + const modal = document.createElement('div'); + modal.id = 'bugReportPreviewModal'; + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: 10000; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + box-sizing: border-box; + `; + + modal.innerHTML = ` +
+
+

${title}

+ +
+
+ ${content} +
+
+ `; + + // Add to document + document.body.appendChild(modal); + + // Close on background click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.remove(); + } + }); + } + + /** + * Create GitHub issue URL with pre-filled data (legacy support) + */ + createGitHubIssueURL(title, body) { + const baseUrl = `https://github.com/${this.repoOwner}/${this.repoName}/issues/new`; + const params = new URLSearchParams({ + title: title, + body: body + }); + + return `${baseUrl}?${params.toString()}`; + } + + /** + * Copy all selected system data to clipboard + */ + async copySystemDataToClipboard() { + const copyBtn = document.getElementById('copyDataBtn'); + + try { + // Show loading state + copyBtn.innerHTML = 'Copying...'; + copyBtn.disabled = true; + + // Collect system data + console.log('[BugReport] Collecting system data for clipboard...'); + const systemData = await this.collectSystemData(); + + // Generate markdown content based on selections + const markdownContent = this.generateMarkdownFromSelections(systemData); + + // Copy to clipboard with fallback + let success = false; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(markdownContent); + success = true; + } else { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = markdownContent; + document.body.appendChild(textArea); + textArea.select(); + success = document.execCommand('copy'); + document.body.removeChild(textArea); + } + } catch (error) { + console.warn('[BugReport] Clipboard API failed, trying fallback:', error); + // Fallback method + const textArea = document.createElement('textarea'); + textArea.value = markdownContent; + document.body.appendChild(textArea); + textArea.select(); + success = document.execCommand('copy'); + document.body.removeChild(textArea); + } + + if (!success) { + throw new Error('All clipboard methods failed'); + } + + // Show success state + copyBtn.innerHTML = 'Copied!'; + copyBtn.style.background = 'rgba(40, 167, 69, 0.2)'; + copyBtn.style.borderColor = '#28a745'; + copyBtn.style.color = '#28a745'; + + // Reset button after 2 seconds + setTimeout(() => { + copyBtn.innerHTML = 'Copy to Clipboard'; + copyBtn.style.background = 'rgba(255, 193, 7, 0.1)'; + copyBtn.style.borderColor = '#ffc107'; + copyBtn.style.color = '#ffc107'; + copyBtn.disabled = false; + }, 2000); + + } catch (error) { + console.error('[BugReport] Error copying to clipboard:', error); + alert('Failed to copy to clipboard. Please try again or copy manually.'); + + // Reset button + copyBtn.innerHTML = 'Copy to Clipboard'; + copyBtn.disabled = false; + } + } + + /** + * Generate markdown content from selected data items + */ + generateMarkdownFromSelections(systemData) { + let markdown = '\n\n---\n\n## 🔧 System Data\n\n'; + + // Check which items are selected + const includeErrors = document.getElementById('include_errors').checked; + const includeControls = document.getElementById('include_controls').checked; + const includeOptRequest = document.getElementById('include_opt_request').checked; + const includeOptResponse = document.getElementById('include_opt_response').checked; + const includeLogs = document.getElementById('include_logs').checked; + + // Add errors/warnings first (most important) - use alerts like in preview + if (includeErrors && systemData.alerts) { + // Use same logic as preview - filter alerts for ERROR and WARNING only + const errorAlerts = systemData.alerts.filter(alert => + alert.level === 'ERROR' || alert.level === 'WARNING' + ).slice(-10); // Get last 10 error/warning alerts + + if (errorAlerts.length > 0) { + markdown += `### ⚠️ Recent Errors & Warnings (${errorAlerts.length} found)\n\n`; + markdown += '```\n'; + errorAlerts.forEach(alert => { + markdown += `${alert.timestamp} [${alert.level}] ${alert.message}\n`; + }); + markdown += '```\n\n'; + } else { + markdown += '### ✅ Recent Errors & Warnings\n\nNo recent errors or warnings found in alerts.\n\n'; + } + } else if (includeErrors) { + markdown += '### ❌ Recent Errors & Warnings\n\nAlerts data not available.\n\n'; + } + + // Add current controls + if (includeControls && systemData.currentControls) { + markdown += '### 🎛️ Current System Controls & States\n\n'; + markdown += '
\nClick to expand system controls\n\n'; + markdown += '```json\n'; + markdown += JSON.stringify(systemData.currentControls, null, 2); + markdown += '\n```\n\n'; + markdown += '
\n\n'; + } + + // Add optimization request + if (includeOptRequest && systemData.optimizeRequest) { + markdown += '### 📤 Last Optimization Request\n\n'; + markdown += '
\nClick to expand optimization request\n\n'; + markdown += '```json\n'; + markdown += JSON.stringify(systemData.optimizeRequest, null, 2); + markdown += '\n```\n\n'; + markdown += '
\n\n'; + } + + // Add optimization response + if (includeOptResponse && systemData.optimizeResponse) { + markdown += '### 📥 Last Optimization Response\n\n'; + markdown += '
\nClick to expand optimization response\n\n'; + markdown += '```json\n'; + markdown += JSON.stringify(systemData.optimizeResponse, null, 2); + markdown += '\n```\n\n'; + markdown += '
\n\n'; + } + + // Add recent logs + if (includeLogs && systemData.recentLogs && systemData.recentLogs.logs) { + const recentLogs = systemData.recentLogs.logs.slice(0, 200); + markdown += '### 📋 Recent Log Entries (Last 200)\n\n'; + markdown += '
\nClick to expand recent logs\n\n'; + markdown += '```\n'; + recentLogs.forEach(log => { + markdown += `${log.timestamp} [${log.level}] ${log.message}\n`; + }); + markdown += '```\n\n'; + markdown += '
\n\n'; + } + + // Add footer + markdown += '---\n'; + markdown += '*This system data was generated automatically by EOS Connect bug reporting feature.*'; + + return markdown; + } + + /** + * Preview data in a popup + */ + async previewData(dataType) { + try { + console.log(`[BugReport] Previewing ${dataType} data...`); + + // Collect system data if not already available + if (!this.cachedSystemData) { + this.cachedSystemData = await this.collectSystemData(); + } + + let content = ''; + let title = ''; + + switch (dataType) { + case 'errors': + title = '⚠️ Recent Errors & Warnings (Last 10)'; + try { + // Use LoggingManager alerts directly - they contain ERROR and WARNING levels + if (typeof loggingManager !== 'undefined' && loggingManager.fetchAlerts) { + await loggingManager.fetchAlerts(); + const alerts = loggingManager.alerts || []; + + const errorAlerts = alerts + .filter(alert => alert.level === 'ERROR' || alert.level === 'WARNING') + .slice(-10); // Get most recent 10 + + if (errorAlerts.length > 0) { + title = `⚠️ Recent Errors & Warnings (${errorAlerts.length} found)`; + content = errorAlerts.map(alert => + `
+ [${alert.level}] ${alert.timestamp}
+ ${alert.message} +
` + ).join(''); + } else { + content = '
✅ No recent errors or warnings found in alerts.
'; + } + } else { + content = '
❌ LoggingManager not available.
'; + } + } catch (error) { + console.error('[BugReport] Error fetching alerts:', error); + content = '
❌ Error loading alerts data.
'; + } + break; + + case 'controls': + title = '🎛️ Current System Controls & States'; + content = `
${JSON.stringify(this.cachedSystemData.currentControls || {}, null, 2)}
`; + break; + + case 'opt_request': + title = '📤 Last Optimization Request'; + content = `
${JSON.stringify(this.cachedSystemData.optimizeRequest || {}, null, 2)}
`; + break; + + case 'opt_response': + title = '📥 Last Optimization Response'; + content = `
${JSON.stringify(this.cachedSystemData.optimizeResponse || {}, null, 2)}
`; + break; + + case 'logs': + title = '📋 Recent Log Entries (Last 200)'; + if (this.cachedSystemData.recentLogs && this.cachedSystemData.recentLogs.logs) { + const recentLogs = this.cachedSystemData.recentLogs.logs.slice(0, 200); + content = `
+ ${recentLogs.map(log => `${log.timestamp} [${log.level}] ${log.message}`).join('
')} +
`; + } else { + content = '
No log data available.
'; + } + break; + + default: + title = 'Preview'; + content = '
Unknown data type.
'; + } + + // Show preview in smaller modal + this.showPreviewModal(title, content); + + } catch (error) { + console.error(`[BugReport] Error previewing ${dataType}:`, error); + alert(`Failed to preview ${dataType} data. Please try again.`); + } + } + + /** + * Copy specific data type to clipboard (formatted as markdown) + */ + async copyToClipboard(dataType) { + try { + console.log(`[BugReport] Copying ${dataType} to clipboard...`); + + // Collect system data if not already available + if (!this.cachedSystemData) { + this.cachedSystemData = await this.collectSystemData(); + } + + let markdown = ''; + + switch (dataType) { + case 'controls': + markdown = '### 🎛️ Current System Controls & States\n\n```json\n'; + markdown += JSON.stringify(this.cachedSystemData.currentControls || {}, null, 2); + markdown += '\n```'; + break; + + case 'opt_request': + markdown = '### 📤 Last Optimization Request\n\n```json\n'; + markdown += JSON.stringify(this.cachedSystemData.optimizeRequest || {}, null, 2); + markdown += '\n```'; + break; + + case 'opt_response': + markdown = '### 📥 Last Optimization Response\n\n```json\n'; + markdown += JSON.stringify(this.cachedSystemData.optimizeResponse || {}, null, 2); + markdown += '\n```'; + break; + + default: + throw new Error(`Unknown data type: ${dataType}`); + } + + // Copy to clipboard with fallback + let success = false; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(markdown); + success = true; + } else { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = markdown; + document.body.appendChild(textArea); + textArea.select(); + success = document.execCommand('copy'); + document.body.removeChild(textArea); + } + } catch (error) { + console.warn('[BugReport] Clipboard API failed, trying fallback:', error); + // Fallback method + const textArea = document.createElement('textarea'); + textArea.value = markdown; + document.body.appendChild(textArea); + textArea.select(); + success = document.execCommand('copy'); + document.body.removeChild(textArea); + } + + if (!success) { + throw new Error('All clipboard methods failed'); + } + + // Show visual feedback + const button = event.target.closest('button'); + const originalContent = button.innerHTML; + button.innerHTML = ''; + button.style.borderColor = '#28a745'; + button.style.color = '#28a745'; + + setTimeout(() => { + button.innerHTML = originalContent; + button.style.borderColor = ''; + button.style.color = ''; + }, 1500); + + } catch (error) { + console.error(`[BugReport] Error copying ${dataType} to clipboard:`, error); + alert(`Failed to copy ${dataType} data to clipboard. Please try again.`); + } + } +} + +// Create global bug report manager instance +const bugReportManager = new BugReportManager(); + +// Global function for backward compatibility +async function sendBugReport() { + await bugReportManager.showBugReportPopup(); +} \ No newline at end of file diff --git a/src/web/js/ui.js b/src/web/js/ui.js index 5f7bdfb8..76402403 100644 --- a/src/web/js/ui.js +++ b/src/web/js/ui.js @@ -25,7 +25,7 @@ function overlayMenu(header, content, close = true) { document.getElementById('overlay_menu_head').innerHTML = header; document.getElementById('overlay_menu_content').innerHTML = content; document.getElementById('overlay_menu_close').style.display = close ? '' : 'none'; - + // Block background scrolling document.body.style.overflow = 'hidden'; } @@ -85,7 +85,7 @@ function initializeValueChangeObservers() { */ function toggleTestPanel() { const testControls = document.getElementById('test_controls'); - + if (testControls.style.display === 'none' || testControls.style.display === '') { testControls.style.display = 'block'; } else { @@ -96,22 +96,22 @@ function toggleTestPanel() { function switchTestScenario() { const select = document.getElementById('test_scenario_select'); const scenario = select.value; - + if (scenario === 'live') { currentTestScenario = TEST_SCENARIOS.LIVE; } else { currentTestScenario = scenario; } - + console.log('[TestMode] Switched to scenario:', currentTestScenario); - + // Automatically refresh data when switching scenarios refreshTestData(); } async function refreshTestData() { console.log('[TestMode] Refreshing data with scenario:', currentTestScenario); - + // Force refresh by calling init() which will use the current test scenario if (typeof init === 'function') { await init(); @@ -133,11 +133,11 @@ function hideTestPanel() { } // Initialize test panel on page load -document.addEventListener('DOMContentLoaded', function() { +document.addEventListener('DOMContentLoaded', function () { // Show test panel ONLY if URL contains test=1 parameter const urlParams = new URLSearchParams(window.location.search); const isTestParam = urlParams.get('test') === '1'; - + if (isTestParam) { console.log('[TestMode] Test mode activated via ?test=1 parameter'); setTimeout(() => showTestPanel(), 1000); // Show after page loads @@ -156,7 +156,7 @@ function showMainMenu(version) { existingDropdown.remove(); return; // Toggle behavior - close if already open } - + // Create dropdown menu const dropdown = document.createElement('div'); dropdown.id = 'main-dropdown-menu'; @@ -174,7 +174,7 @@ function showMainMenu(version) { // font-size: 0.9em; font-size: ${isMobile() ? '1.1em' : '0.9em'}; `; - + dropdown.innerHTML = `
-
- - Info -
-
@@ -217,35 +210,40 @@ function showMainMenu(version) {
- -
Bug Report
- +
+ +
+ + Info
`; - + // Find the menu icon parent container to position relative to it const menuIcon = document.getElementById('current_header_left'); const parentBox = menuIcon.closest('.top-box'); - + // Add relative positioning to parent if not already present if (getComputedStyle(parentBox).position === 'static') { parentBox.style.position = 'relative'; } - + // Append dropdown to parent container parentBox.appendChild(dropdown); - + // Update dropdown notifications using centralized system if (typeof MenuNotifications !== 'undefined') { MenuNotifications.updateDropdown(); } - + // Add click outside listener to close dropdown setTimeout(() => { document.addEventListener('click', handleClickOutside, true); @@ -269,14 +267,14 @@ function closeDropdownMenu() { */ const MenuNotifications = { displayedColor: null, // What's actually displayed: null, 'red', 'orange', 'white', 'gray' - + /** * Initialize the notification system */ init() { console.log('[MenuNotifications] Simple state-aware system initialized'); }, - + /** * Show a dot with specific color (external interface) * Priority order: red > orange > white > gray > none @@ -284,10 +282,10 @@ const MenuNotifications = { */ showDot(requestedColor) { console.log(`[MenuNotifications] Request: show ${requestedColor}, currently displaying: ${this.displayedColor}`); - + // Determine what should be displayed based on priority let targetColor = this.getTargetColor(requestedColor); - + // Only update if the target is different from what's displayed if (targetColor !== this.displayedColor) { console.log(`[MenuNotifications] State change needed: '${this.displayedColor}' → '${targetColor}'`); @@ -297,7 +295,7 @@ const MenuNotifications = { console.log(`[MenuNotifications] No change needed - already displaying '${this.displayedColor}'`); } }, - + /** * Determine target color based on priority rules */ @@ -306,7 +304,7 @@ const MenuNotifications = { // Later can add priority logic for multiple sources return requestedColor; }, - + /** * Render the dot based on displayedColor (only called when state changes) */ @@ -316,22 +314,22 @@ const MenuNotifications = { console.log(`[MenuNotifications] Menu element not found`); return; } - + // Always remove existing dot first (clean slate) const existingDot = menuElement.querySelector('.notification-dot'); if (existingDot) { existingDot.remove(); } - + // Add new dot if needed if (this.displayedColor) { const colors = { 'red': 'rgb(220, 53, 69)', - 'orange': 'rgb(255, 193, 7)', + 'orange': 'rgb(255, 193, 7)', 'white': 'rgb(255, 255, 255)', 'gray': 'rgb(136, 136, 136)' }; - + const dotColor = colors[this.displayedColor]; if (dotColor) { const dot = document.createElement('div'); @@ -355,14 +353,14 @@ const MenuNotifications = { console.log(`[MenuNotifications] Removed dot (no color)`); } }, - + /** * Update dropdown menu notifications */ updateDropdown() { const dropdown = document.getElementById('main-dropdown-menu'); if (!dropdown) return; - + // Only update Alarms menu item (not Logs) const alarmsItem = dropdown.querySelector('div[onclick*="showAlarmsMenu"]'); if (alarmsItem) { @@ -370,10 +368,10 @@ const MenuNotifications = { let status = null; if (this.displayedColor === 'red') status = 'error'; else if (this.displayedColor === 'orange') status = 'warning'; - + this.addDropdownNotification(alarmsItem, status); } - + // Ensure Logs menu item has no notification dot const logsItem = dropdown.querySelector('div[onclick*="showLogsMenu"]'); if (logsItem) { @@ -383,7 +381,7 @@ const MenuNotifications = { } } }, - + /** * Add notification dot to dropdown menu item * @param {Element} menuItem - The menu item element @@ -395,7 +393,7 @@ const MenuNotifications = { if (existingDot) { existingDot.remove(); } - + // Add new notification dot if needed if (status) { const dotColor = status === 'error' ? '#dc3545' : '#ffc107'; @@ -411,11 +409,11 @@ const MenuNotifications = { flex-shrink: 0; border: 1px solid rgba(255,255,255,0.2); `; - + menuItem.appendChild(dot); } }, - + /** * Restore notification after menu element changes (only if actually missing) */ @@ -426,7 +424,7 @@ const MenuNotifications = { // Check if dot actually exists before restoring const menuElement = document.getElementById('current_header_left'); const existingDot = menuElement ? menuElement.querySelector('.notification-dot') : null; - + if (!existingDot) { console.log(`[MenuNotifications] Dot missing, restoring ${this.displayedColor} dot`); this.renderDot(); @@ -448,7 +446,7 @@ window.MenuNotifications = MenuNotifications; function handleClickOutside(event) { const dropdown = document.getElementById('main-dropdown-menu'); const menuIcon = document.getElementById('current_header_left'); - + if (dropdown && !dropdown.contains(event.target) && !menuIcon.contains(event.target)) { closeDropdownMenu(); } @@ -500,7 +498,7 @@ function showInfoMenu(version) { EOS connect Information
`; - + const content = `
@@ -560,7 +558,7 @@ function showInfoMenu(version) {
`; - + showFullScreenOverlay(header, content); } @@ -573,10 +571,10 @@ function showFullScreenOverlay(header, content, close = true) { if (!overlay) { overlay = document.createElement('div'); overlay.id = 'full_screen_overlay'; - + // Responsive padding: very small on mobile, larger on desktop const paddingValue = isMobile() ? '8px' : '60px'; - + overlay.style.cssText = ` position: fixed; top: 0; @@ -600,7 +598,7 @@ function showFullScreenOverlay(header, content, close = true) { const headerPadding = isMobile() ? '12px 15px' : '15px 20px'; const contentPadding = isMobile() ? '15px' : '20px'; const borderRadius = isMobile() ? '6px' : '10px'; - + overlay.innerHTML = `
🧪 Test Mode