from flask import ( Flask, request, jsonify, send_from_directory, Response, ) from flask_cors import CORS import json import time from datetime import datetime import os import sys try: import tkinter as tk from tkinter import filedialog TKINTER_AVAILABLE = True except ImportError: TKINTER_AVAILABLE = False print("Warning: tkinter not available. File browse functionality will be limited.") from core import PLCDataStreamer from utils.json_manager import JSONManager, SchemaManager from utils.symbol_loader import SymbolLoader from utils.symbol_processor import SymbolProcessor app = Flask(__name__) CORS( app, resources={ r"/api/*": { "origins": [ "http://localhost:5173/app", "http://127.0.0.1:5173/app", "*", ] } }, ) app.secret_key = "plc_streamer_secret_key" # React build directory (for Vite production build) REACT_DIST_DIR = os.path.join(os.path.abspath("."), "frontend", "dist") def resource_path(relative_path): """Get absolute path to resource, works for dev and for PyInstaller""" try: # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = sys._MEIPASS except Exception: # Not running in a bundle base_path = os.path.abspath(".") return os.path.join(base_path, relative_path) def project_path(*parts: str) -> str: """Build absolute path from the project root (based on this file).""" base_dir = os.path.abspath(os.path.dirname(__file__)) return os.path.join(base_dir, *parts) # Global instances streamer = None json_manager = JSONManager() schema_manager = SchemaManager() def check_streamer_initialized(): """Check if streamer is initialized, return error response if not""" if streamer is None: return jsonify({"error": "Application not initialized"}), 503 return None @app.route("/images/") def serve_image(filename): """Serve images from .images directory""" return send_from_directory(".images", filename) @app.route("/favicon.ico") def serve_favicon(): """Serve application favicon from React public folder. Priority: 1) frontend/public/favicon.ico 2) frontend/public/record.png """ # Use absolute paths for reliability (works in dev and bundled) public_dir = project_path("frontend", "public") public_favicon = os.path.join(public_dir, "favicon.ico") public_record = os.path.join(public_dir, "record.png") if os.path.exists(public_favicon): return send_from_directory(public_dir, "favicon.ico") if os.path.exists(public_record): return send_from_directory(public_dir, "record.png") # Final fallback: 404 return Response("Favicon not found", status=404, mimetype="text/plain") @app.route("/record.png") def serve_public_record_png(): """Serve /record.png from the React public folder.""" public_dir = project_path("frontend", "public") public_record = os.path.join(public_dir, "record.png") if os.path.exists(public_record): return send_from_directory(public_dir, "record.png") return Response("record.png not found", status=404, mimetype="text/plain") # ============================== # Frontend (React SPA) # ============================== @app.route("/assets/") def serve_react_assets(filename): """Serve built React assets from Vite (production).""" assets_dir = os.path.join(REACT_DIST_DIR, "assets") return send_from_directory(assets_dir, filename) @app.route("/") @app.route("/app") @app.route("/app/") def serve_react_index(path: str = ""): """Serve React SPA index (expects Vite build at frontend/dist).""" index_path = os.path.join(REACT_DIST_DIR, "index.html") if not os.path.exists(index_path): return Response( "React build not found. Run 'cd frontend && npm install && npm run build' first.", status=500, mimetype="text/plain", ) return send_from_directory(REACT_DIST_DIR, "index.html") ## Legacy UI removed after migration to React # ============================== # Unified JSON Configuration API # ============================== @app.route("/api/config/schemas", methods=["GET"]) def list_config_schemas(): """List all available configuration schemas.""" try: schemas = schema_manager.list_available_schemas() return jsonify({"success": True, "schemas": schemas}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/config/schema/", methods=["GET"]) def get_config_schema(schema_id): """Get a specific JSON schema with optional UI schema.""" try: # Get main schema schema = schema_manager.get_schema(schema_id) if not schema: return ( jsonify({"success": False, "error": f"Schema '{schema_id}' not found"}), 404, ) # Get optional UI schema ui_schema = schema_manager.get_ui_schema(schema_id) response = {"success": True, "schema": schema} if ui_schema: response["ui_schema"] = ui_schema return jsonify(response) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/config/", methods=["GET"]) def read_config(config_id): """Read configuration data from JSON file.""" try: data = json_manager.read_json(config_id) return jsonify({"success": True, "data": data}) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/config/", methods=["PUT"]) def write_config(config_id): """Write configuration data to JSON file.""" try: payload = request.get_json(force=True, silent=False) if not payload: return jsonify({"success": False, "error": "No JSON data provided"}), 400 # Write the data json_manager.write_json(config_id, payload) # Automatically reload backend configuration for specific config types if streamer: try: if config_id == "plc": streamer.config_manager.load_configuration() elif config_id in ["dataset-definitions", "dataset-variables"]: # Reload dataset configuration to pick up changes streamer.reload_dataset_configuration() if streamer.logger: streamer.logger.info( f"Auto-reloaded backend configuration after updating {config_id}" ) elif config_id in ["plot-definitions", "plot-variables"]: # Plot configurations don't need backend reload currently pass except Exception as e: # Log the error but don't fail the save operation if streamer and streamer.logger: streamer.logger.warning( f"Could not auto-reload {config_id} config in backend: {e}" ) else: print( f"Warning: Could not auto-reload {config_id} config in backend: {e}" ) return jsonify( { "success": True, "message": f"Configuration '{config_id}' saved successfully", } ) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/config//export", methods=["GET"]) def export_config(config_id): """Export configuration as downloadable JSON file.""" try: data = json_manager.read_json(config_id) # Prepare download response content = json.dumps(data, indent=2, ensure_ascii=False) filename = f"{config_id}_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" response = Response(content, mimetype="application/json") response.headers["Content-Disposition"] = f"attachment; filename={filename}" return response except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/config//reload", methods=["POST"]) def reload_config(config_id): """Notify backend to reload configuration from JSON files.""" error_response = check_streamer_initialized() if error_response: return error_response try: if config_id == "plc": streamer.config_manager.load_configuration() elif config_id in ["dataset-definitions", "dataset-variables"]: # Reload dataset configuration using the new method streamer.reload_dataset_configuration() elif config_id in ["plot-definitions", "plot-variables"]: # Reload plot configuration if needed pass return jsonify( { "success": True, "message": f"Configuration '{config_id}' reloaded successfully", } ) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/config/dataset-variables/expanded", methods=["GET"]) def get_expanded_dataset_variables(): """Get dataset variables with symbolic variables expanded.""" error_response = check_streamer_initialized() if error_response: return error_response try: # Get the expanded variables from the streamer's config manager expanded_data = {"variables": []} if ( hasattr(streamer.config_manager, "datasets") and streamer.config_manager.datasets ): for dataset_id, dataset_config in streamer.config_manager.datasets.items(): if "variables" in dataset_config and dataset_config["variables"]: expanded_data["variables"].append( { "dataset_id": dataset_id, "variables": dataset_config["variables"], } ) return jsonify({"success": True, "data": expanded_data}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 # ============================== # Operational API (PLC Control, Streaming, etc.) # ============================== @app.route("/api/plc/config", methods=["POST"]) def update_plc_config(): """Update PLC configuration""" error_response = check_streamer_initialized() if error_response: return error_response try: data = request.get_json() ip = data.get("ip", "10.1.33.11") rack = int(data.get("rack", 0)) slot = int(data.get("slot", 2)) streamer.update_plc_config(ip, rack, slot) return jsonify({"success": True, "message": "PLC configuration updated"}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @app.route("/api/udp/config", methods=["POST"]) def update_udp_config(): """Update UDP configuration""" try: data = request.get_json() host = data.get("host", "127.0.0.1") port = int(data.get("port", 9870)) sampling_interval = data.get("sampling_interval") # Optional, can be None streamer.update_udp_config(host, port, sampling_interval) return jsonify({"success": True, "message": "UDP configuration updated"}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @app.route("/api/csv/config", methods=["GET"]) def get_csv_config(): """Get CSV recording configuration""" error_response = check_streamer_initialized() if error_response: return error_response try: csv_config = streamer.config_manager.csv_config.copy() # Add current directory information current_dir = streamer.config_manager.get_csv_directory_path() csv_config["current_directory"] = os.path.abspath(current_dir) csv_config["directory_exists"] = os.path.exists(current_dir) # Add disk space info disk_info = streamer.get_disk_space_info() if disk_info: csv_config["disk_space"] = disk_info return jsonify({"success": True, "config": csv_config}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 @app.route("/api/csv/config", methods=["POST"]) def update_csv_config(): """Update CSV recording configuration""" error_response = check_streamer_initialized() if error_response: return error_response try: data = request.get_json() # Extract valid configuration parameters config_updates = {} valid_params = { "records_directory", "rotation_enabled", "max_size_mb", "max_days", "max_hours", "cleanup_interval_hours", } for param in valid_params: if param in data: config_updates[param] = data[param] if not config_updates: return ( jsonify( { "success": False, "message": "No valid configuration parameters provided", } ), 400, ) # Update configuration result = streamer.config_manager.update_csv_config(**config_updates) return jsonify( { "success": True, "message": "CSV configuration updated successfully", "old_config": result["old_config"], "new_config": result["new_config"], } ) except ValueError as e: return jsonify({"success": False, "message": str(e)}), 400 except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 @app.route("/api/csv/cleanup", methods=["POST"]) def trigger_csv_cleanup(): """Manually trigger CSV cleanup""" error_response = check_streamer_initialized() if error_response: return error_response try: # Perform cleanup streamer.streamer.perform_csv_cleanup() return jsonify( {"success": True, "message": "CSV cleanup completed successfully"} ) except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 @app.route("/api/csv/directory/info", methods=["GET"]) def get_csv_directory_info(): """Get information about CSV directory and files""" error_response = check_streamer_initialized() if error_response: return error_response try: base_dir = streamer.config_manager.get_csv_directory_path() info = { "base_directory": os.path.abspath(base_dir), "directory_exists": os.path.exists(base_dir), "total_files": 0, "total_size_mb": 0, "oldest_file": None, "newest_file": None, "day_folders": [], } if os.path.exists(base_dir): total_size = 0 oldest_time = None newest_time = None for day_folder in os.listdir(base_dir): day_path = os.path.join(base_dir, day_folder) if os.path.isdir(day_path): day_info = {"name": day_folder, "files": 0, "size_mb": 0} for file_name in os.listdir(day_path): if file_name.endswith(".csv"): file_path = os.path.join(day_path, file_name) if os.path.isfile(file_path): stat = os.stat(file_path) file_size = stat.st_size file_time = stat.st_mtime info["total_files"] += 1 day_info["files"] += 1 total_size += file_size day_info["size_mb"] += file_size / (1024 * 1024) if oldest_time is None or file_time < oldest_time: oldest_time = file_time info["oldest_file"] = datetime.fromtimestamp( file_time ).isoformat() if newest_time is None or file_time > newest_time: newest_time = file_time info["newest_file"] = datetime.fromtimestamp( file_time ).isoformat() day_info["size_mb"] = round(day_info["size_mb"], 2) info["day_folders"].append(day_info) info["total_size_mb"] = round(total_size / (1024 * 1024), 2) info["day_folders"].sort(key=lambda x: x["name"], reverse=True) return jsonify({"success": True, "info": info}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 @app.route("/api/plc/connect", methods=["POST"]) def connect_plc(): """Connect to PLC""" error_response = check_streamer_initialized() if error_response: return error_response if streamer.connect_plc(): return jsonify({"success": True, "message": "Connected to PLC"}) else: # Extraer detalle de error si disponible last_error = None try: last_error = streamer.plc_client.last_error except Exception: last_error = None return ( jsonify( { "success": False, "message": "Error connecting to PLC", "error": last_error, "plc_config": streamer.config_manager.plc_config, } ), 500, ) @app.route("/api/plc/disconnect", methods=["POST"]) def disconnect_plc(): """Disconnect from PLC""" streamer.disconnect_plc() return jsonify({"success": True, "message": "Disconnected from PLC"}) @app.route("/api/variables", methods=["POST"]) def add_variable(): """Add a new variable""" try: data = request.get_json() name = data.get("name") area = data.get("area") db = int(data.get("db")) offset = int(data.get("offset")) var_type = data.get("type") bit = data.get("bit") # Added bit parameter valid_types = [ "real", "int", "bool", "dint", "word", "byte", "uint", "udint", "sint", "usint", ] valid_areas = ["db", "mw", "m", "pew", "pe", "paw", "pa", "e", "a", "mb"] if ( not name or not area or var_type not in valid_types or area.lower() not in valid_areas ): return jsonify({"success": False, "message": "Invalid data"}), 400 if area.lower() in ["e", "a", "mb"] and bit is None: return ( jsonify( { "success": False, "message": "Bit position must be specified for bit areas", } ), 400, ) if area.lower() in ["e", "a", "mb"] and (bit < 0 or bit > 7): return ( jsonify( { "success": False, "message": "Bit position must be between 0 and 7", } ), 400, ) streamer.add_variable(name, area, db, offset, var_type, bit) return jsonify({"success": True, "message": f"Variable {name} added"}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @app.route("/api/variables/", methods=["DELETE"]) def remove_variable(name): """Remove a variable""" streamer.remove_variable(name) return jsonify({"success": True, "message": f"Variable {name} removed"}) @app.route("/api/variables/", methods=["GET"]) def get_variable(name): """Get a specific variable configuration from current dataset""" try: if not streamer.current_dataset_id: return ( jsonify({"success": False, "message": "No dataset selected"}), 400, ) current_variables = streamer.get_dataset_variables(streamer.current_dataset_id) if name not in current_variables: return ( jsonify({"success": False, "message": f"Variable {name} not found"}), 404, ) var_config = current_variables[name].copy() var_config["name"] = name # Check if variable is in streaming list for current dataset streaming_vars = streamer.datasets[streamer.current_dataset_id].get( "streaming_variables", [] ) var_config["streaming"] = name in streaming_vars return jsonify({"success": True, "variable": var_config}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @app.route("/api/variables/", methods=["PUT"]) def update_variable(name): """Update an existing variable in current dataset""" try: if not streamer.current_dataset_id: return jsonify({"success": False, "message": "No dataset selected"}), 400 data = request.get_json() new_name = data.get("name", name) area = data.get("area") db = int(data.get("db", 1)) offset = int(data.get("offset")) var_type = data.get("type") bit = data.get("bit") valid_types = [ "real", "int", "bool", "dint", "word", "byte", "uint", "udint", "sint", "usint", ] valid_areas = ["db", "mw", "m", "pew", "pe", "paw", "pa", "e", "a", "mb"] if ( not new_name or not area or var_type not in valid_types or area.lower() not in valid_areas ): return jsonify({"success": False, "message": "Invalid data"}), 400 if area.lower() in ["e", "a", "mb"] and bit is None: return ( jsonify( { "success": False, "message": "Bit position must be specified for bit areas", } ), 400, ) if area.lower() in ["e", "a", "mb"] and (bit < 0 or bit > 7): return ( jsonify( { "success": False, "message": "Bit position must be between 0 and 7", } ), 400, ) current_dataset_id = streamer.current_dataset_id current_variables = streamer.get_dataset_variables(current_dataset_id) # Check if variable exists in current dataset if name not in current_variables: return ( jsonify({"success": False, "message": f"Variable {name} not found"}), 404, ) # Check if new name already exists (if name is changing) if name != new_name and new_name in current_variables: return ( jsonify( { "success": False, "message": f"Variable {new_name} already exists", } ), 400, ) # Preserve streaming state streaming_vars = streamer.datasets[current_dataset_id].get( "streaming_variables", [] ) was_streaming = name in streaming_vars # Remove old variable streamer.remove_variable_from_dataset(current_dataset_id, name) # Add updated variable streamer.add_variable_to_dataset( current_dataset_id, new_name, area, db, offset, var_type, bit, was_streaming ) return jsonify({"success": True, "message": "Variable updated successfully"}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @app.route("/api/variables//streaming", methods=["POST"]) def toggle_variable_streaming(name): """Toggle streaming for a specific variable in current dataset""" try: if not streamer.current_dataset_id: return jsonify({"success": False, "message": "No dataset selected"}), 400 data = request.get_json() enabled = data.get("enabled", False) # Use the new dataset-specific method streamer.toggle_variable_streaming(streamer.current_dataset_id, name, enabled) status = "enabled" if enabled else "disabled" return jsonify( {"success": True, "message": f"Variable {name} streaming {status}"} ) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @app.route("/api/variables/streaming", methods=["GET"]) def get_streaming_variables(): """Get list of variables enabled for streaming in current dataset""" if streamer is None: return jsonify({"success": False, "error": "Streamer not initialized"}), 503 # Get streaming variables from current dataset streaming_vars = [] if streamer.current_dataset_id and streamer.current_dataset_id in streamer.datasets: streaming_vars = streamer.datasets[streamer.current_dataset_id].get( "streaming_variables", [] ) return jsonify({"success": True, "streaming_variables": streaming_vars}) @app.route("/api/datasets//variables/values", methods=["GET"]) def get_dataset_variable_values(dataset_id): """Get current values of all variables in a dataset - CACHE ONLY""" error_response = check_streamer_initialized() if error_response: return error_response try: # Check if dataset exists if dataset_id not in streamer.datasets: return ( jsonify( {"success": False, "message": f"Dataset '{dataset_id}' not found"} ), 404, ) # Get dataset variables dataset_variables = streamer.get_dataset_variables(dataset_id) if not dataset_variables: return jsonify( { "success": True, "message": "No variables defined in this dataset", "values": {}, "source": "no_variables", } ) # Check if dataset is active (required for cache to be populated) if dataset_id not in streamer.active_datasets: return jsonify( { "success": False, "message": f"Dataset '{dataset_id}' is not active. Activate the dataset to start reading variables and populate cache.", "error_type": "dataset_inactive", "values": {}, "detailed_errors": {}, "stats": { "success": 0, "failed": 0, "total": len(dataset_variables), }, "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "source": "no_cache_dataset_inactive", "is_cached": False, } ) # Check if PLC is connected (required for streaming to populate cache) if not streamer.plc_client.is_connected(): return jsonify( { "success": False, "message": "PLC not connected. Connect to PLC and activate dataset to populate cache.", "error_type": "plc_disconnected", "values": {}, "detailed_errors": {}, "stats": { "success": 0, "failed": 0, "total": len(dataset_variables), }, "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "source": "no_cache_plc_disconnected", "is_cached": False, } ) # Get cached values - this is the ONLY source of data according to application principles if not streamer.has_cached_values(dataset_id): return jsonify( { "success": False, "message": f"No cached values available for dataset '{dataset_id}'. Cache is populated by the streaming process at the dataset's configured interval. Please wait for the next reading cycle.", "error_type": "no_cache_available", "values": {}, "detailed_errors": {}, "stats": { "success": 0, "failed": 0, "total": len(dataset_variables), }, "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "source": "no_cache_available", "is_cached": False, "note": f"Dataset reads every {streamer.config_manager.get_dataset_sampling_interval(dataset_id)}s", } ) # Get cached values (the ONLY valid source according to application design) read_result = streamer.get_cached_dataset_values(dataset_id) # Convert timestamp from ISO format to readable format for consistency if read_result.get("timestamp"): try: cached_timestamp = datetime.fromisoformat(read_result["timestamp"]) read_result["timestamp"] = cached_timestamp.strftime( "%Y-%m-%d %H:%M:%S" ) except: pass # Keep original timestamp if conversion fails # Extract values and handle diagnostics if not read_result.get("success", False): # Complete failure case error_msg = read_result.get("error", "Unknown error reading variables") error_type = read_result.get("error_type", "unknown") # Log detailed error information if streamer.logger: streamer.logger.error( f"Cached values indicate failure for dataset '{dataset_id}': {error_msg}" ) if read_result.get("errors"): for var_name, var_error in read_result["errors"].items(): streamer.logger.error(f" Variable '{var_name}': {var_error}") return ( jsonify( { "success": False, "message": error_msg, "error_type": error_type, "values": {}, "detailed_errors": read_result.get("errors", {}), "stats": read_result.get("stats", {}), "timestamp": read_result.get( "timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S") ), "source": "cache", "is_cached": True, } ), 500, ) # Success or partial success case raw_values = read_result.get("values", {}) variable_errors = read_result.get("errors", {}) stats = read_result.get("stats", {}) # Format values for display formatted_values = {} error_details = {} for var_name, value in raw_values.items(): if value is not None: var_config = dataset_variables[var_name] var_type = var_config.get("type", "unknown") # Format value based on type try: if var_type == "real": formatted_values[var_name] = ( f"{value:.3f}" if isinstance(value, (int, float)) else str(value) ) elif var_type == "bool": formatted_values[var_name] = "TRUE" if value else "FALSE" elif var_type in [ "int", "uint", "dint", "udint", "word", "byte", "sint", "usint", ]: formatted_values[var_name] = ( str(int(value)) if isinstance(value, (int, float)) else str(value) ) else: formatted_values[var_name] = str(value) except Exception as format_error: formatted_values[var_name] = "FORMAT_ERROR" error_details[var_name] = f"Format error: {str(format_error)}" else: # Variable had an error - get the specific error message specific_error = variable_errors.get(var_name, "Unknown error") formatted_values[var_name] = "ERROR" error_details[var_name] = specific_error # Prepare response message total_vars = stats.get("total", len(dataset_variables)) success_vars = stats.get("success", 0) failed_vars = stats.get("failed", 0) # Determine data source for message (always cache now) data_source = "cache" source_text = " (from streaming cache)" if failed_vars == 0: message = ( f"Successfully displaying all {success_vars} variables{source_text}" ) response_success = True else: message = f"Partial data available: {success_vars}/{total_vars} variables have valid cached values, {failed_vars} failed{source_text}" response_success = True # Still success if we got some values # Log warnings for partial failures if streamer.logger: streamer.logger.warning( f"Partial failure in cached values for dataset '{dataset_id}': {message}" ) for var_name, var_error in error_details.items(): if formatted_values.get(var_name) == "ERROR": streamer.logger.warning(f" Variable '{var_name}': {var_error}") return jsonify( { "success": response_success, "message": message, "values": formatted_values, "detailed_errors": error_details, "stats": stats, "timestamp": read_result.get( "timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S") ), "warning": read_result.get("warning"), "source": data_source, "is_cached": True, "cache_info": f"Dataset reads every {streamer.config_manager.get_dataset_sampling_interval(dataset_id)}s", } ) except Exception as e: return ( jsonify( { "success": False, "message": f"Error retrieving cached variable values: {str(e)}", "values": {}, } ), 500, ) # Dataset Management API Endpoints @app.route("/api/datasets", methods=["GET"]) def get_datasets(): """Get all datasets""" error_response = check_streamer_initialized() if error_response: return error_response return jsonify( { "success": True, "datasets": streamer.datasets, "active_datasets": list(streamer.active_datasets), "current_dataset_id": streamer.current_dataset_id, } ) @app.route("/api/datasets", methods=["POST"]) def create_dataset(): """Create a new dataset""" error_response = check_streamer_initialized() if error_response: return error_response try: data = request.get_json() dataset_id = data.get("dataset_id", "").strip() name = data.get("name", "").strip() prefix = data.get("prefix", "").strip() sampling_interval = data.get("sampling_interval") if not dataset_id or not name or not prefix: return ( jsonify( { "success": False, "message": "Dataset ID, name, and prefix are required", } ), 400, ) # Validate sampling interval if provided if sampling_interval is not None: sampling_interval = float(sampling_interval) if sampling_interval < 0.01: return ( jsonify( { "success": False, "message": "Sampling interval must be at least 0.01 seconds", } ), 400, ) streamer.create_dataset(dataset_id, name, prefix, sampling_interval) return jsonify( { "success": True, "message": f"Dataset '{name}' created successfully", "dataset_id": dataset_id, } ) except ValueError as e: return jsonify({"success": False, "message": str(e)}), 400 except Exception as e: return ( jsonify({"success": False, "message": f"Error creating dataset: {str(e)}"}), 500, ) @app.route("/api/datasets/", methods=["DELETE"]) def delete_dataset(dataset_id): """Delete a dataset""" error_response = check_streamer_initialized() if error_response: return error_response try: streamer.delete_dataset(dataset_id) return jsonify({"success": True, "message": "Dataset deleted successfully"}) except ValueError as e: return jsonify({"success": False, "message": str(e)}), 404 except Exception as e: return ( jsonify({"success": False, "message": f"Error deleting dataset: {str(e)}"}), 500, ) @app.route("/api/datasets//activate", methods=["POST"]) def activate_dataset(dataset_id): """Activate a dataset for streaming""" error_response = check_streamer_initialized() if error_response: return error_response try: streamer.activate_dataset(dataset_id) return jsonify({"success": True, "message": "Dataset activated successfully"}) except ValueError as e: return jsonify({"success": False, "message": str(e)}), 404 except RuntimeError as e: return jsonify({"success": False, "message": str(e)}), 400 except Exception as e: return ( jsonify( {"success": False, "message": f"Error activating dataset: {str(e)}"} ), 500, ) @app.route("/api/datasets//deactivate", methods=["POST"]) def deactivate_dataset(dataset_id): """Deactivate a dataset""" error_response = check_streamer_initialized() if error_response: return error_response try: streamer.deactivate_dataset(dataset_id) return jsonify({"success": True, "message": "Dataset deactivated successfully"}) except ValueError as e: return jsonify({"success": False, "message": str(e)}), 404 except Exception as e: return ( jsonify( {"success": False, "message": f"Error deactivating dataset: {str(e)}"} ), 500, ) @app.route("/api/datasets//variables", methods=["POST"]) def add_variable_to_dataset(dataset_id): """Add a variable to a dataset""" error_response = check_streamer_initialized() if error_response: return error_response try: data = request.get_json() name = data.get("name", "").strip() area = data.get("area", "").strip() db = data.get("db", 1) offset = data.get("offset", 0) var_type = data.get("type", "").strip() bit = data.get("bit") streaming = data.get("streaming", False) if not name or not area or not var_type: return ( jsonify( { "success": False, "message": "Variable name, area, and type are required", } ), 400, ) streamer.add_variable_to_dataset( dataset_id, name, area, db, offset, var_type, bit, streaming ) return jsonify( { "success": True, "message": f"Variable '{name}' added to dataset successfully", } ) except ValueError as e: return jsonify({"success": False, "message": str(e)}), 400 except Exception as e: return ( jsonify({"success": False, "message": f"Error adding variable: {str(e)}"}), 500, ) @app.route("/api/datasets//variables/", methods=["DELETE"]) def remove_variable_from_dataset(dataset_id, variable_name): """Remove a variable from a dataset""" error_response = check_streamer_initialized() if error_response: return error_response try: streamer.remove_variable_from_dataset(dataset_id, variable_name) return jsonify( { "success": True, "message": f"Variable '{variable_name}' removed from dataset successfully", } ) except ValueError as e: return jsonify({"success": False, "message": str(e)}), 404 except Exception as e: return ( jsonify( {"success": False, "message": f"Error removing variable: {str(e)}"} ), 500, ) @app.route( "/api/datasets//variables//streaming", methods=["POST"] ) def toggle_variable_streaming_in_dataset(dataset_id, variable_name): """Toggle streaming for a variable in a dataset""" error_response = check_streamer_initialized() if error_response: return error_response try: data = request.get_json() enabled = data.get("enabled", False) streamer.toggle_variable_streaming(dataset_id, variable_name, enabled) return jsonify( { "success": True, "message": f"Variable '{variable_name}' streaming {'enabled' if enabled else 'disabled'}", } ) except ValueError as e: return jsonify({"success": False, "message": str(e)}), 404 except Exception as e: return ( jsonify( {"success": False, "message": f"Error toggling streaming: {str(e)}"} ), 500, ) @app.route("/api/datasets/current", methods=["POST"]) def set_current_dataset(): """Set the current dataset for editing""" error_response = check_streamer_initialized() if error_response: return error_response try: data = request.get_json() dataset_id = data.get("dataset_id") if dataset_id and dataset_id in streamer.datasets: streamer.current_dataset_id = dataset_id # Note: No need to save - this is just changing current selection in memory return jsonify( { "success": True, "message": "Current dataset updated successfully", "current_dataset_id": dataset_id, } ) else: return jsonify({"success": False, "message": "Invalid dataset ID"}), 400 except Exception as e: return ( jsonify( { "success": False, "message": f"Error setting current dataset: {str(e)}", } ), 500, ) @app.route("/api/streaming/start", methods=["POST"]) def start_streaming(): """Start UDP streaming (legacy endpoint - now only starts UDP streaming)""" error_response = check_streamer_initialized() if error_response: return error_response if streamer.start_streaming(): return jsonify({"success": True, "message": "UDP streaming started"}) else: return ( jsonify({"success": False, "message": "Error starting UDP streaming"}), 500, ) @app.route("/api/streaming/stop", methods=["POST"]) def stop_streaming(): """Stop UDP streaming (legacy endpoint - now only stops UDP streaming)""" error_response = check_streamer_initialized() if error_response: return error_response streamer.stop_streaming() return jsonify({"success": True, "message": "UDP streaming stopped"}) @app.route("/api/sampling", methods=["POST"]) def update_sampling(): """Update sampling interval""" try: data = request.get_json() interval = float(data.get("interval", 0.1)) if interval < 0.01: interval = 0.01 streamer.update_sampling_interval(interval) return jsonify({"success": True, "message": f"Interval updated to {interval}s"}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @app.route("/api/csv/start", methods=["POST"]) def start_csv_recording_legacy(): """Start CSV recording independently (legacy endpoint)""" error_response = check_streamer_initialized() if error_response: return error_response if streamer.data_streamer.start_csv_recording(): return jsonify({"success": True, "message": "CSV recording started"}) else: return ( jsonify({"success": False, "message": "Error starting CSV recording"}), 500, ) @app.route("/api/csv/stop", methods=["POST"]) def stop_csv_recording_legacy(): """Stop CSV recording independently (legacy endpoint)""" error_response = check_streamer_initialized() if error_response: return error_response streamer.data_streamer.stop_csv_recording() return jsonify({"success": True, "message": "CSV recording stopped"}) @app.route("/api/csv/recording/start", methods=["POST"]) def start_csv_recording(): """Start CSV recording independently of UDP streaming""" error_response = check_streamer_initialized() if error_response: return error_response if streamer.data_streamer.start_csv_recording(): return jsonify({"success": True, "message": "CSV recording started"}) else: return ( jsonify({"success": False, "message": "Error starting CSV recording"}), 500, ) @app.route("/api/csv/recording/stop", methods=["POST"]) def stop_csv_recording(): """Stop CSV recording independently of UDP streaming""" error_response = check_streamer_initialized() if error_response: return error_response streamer.data_streamer.stop_csv_recording() return jsonify({"success": True, "message": "CSV recording stopped"}) # πŸ”‘ NEW: UDP Streaming Control (Independent) @app.route("/api/udp/streaming/start", methods=["POST"]) def start_udp_streaming(): """Start UDP streaming to PlotJuggler independently of CSV recording""" error_response = check_streamer_initialized() if error_response: return error_response if streamer.data_streamer.start_udp_streaming(): return jsonify( {"success": True, "message": "UDP streaming to PlotJuggler started"} ) else: return ( jsonify({"success": False, "message": "Error starting UDP streaming"}), 500, ) @app.route("/api/udp/streaming/stop", methods=["POST"]) def stop_udp_streaming(): """Stop UDP streaming to PlotJuggler independently of CSV recording""" error_response = check_streamer_initialized() if error_response: return error_response streamer.data_streamer.stop_udp_streaming() return jsonify({"success": True, "message": "UDP streaming to PlotJuggler stopped"}) # πŸ” HEALTH CHECK ENDPOINT @app.route("/api/health", methods=["GET"]) def health_check(): """Simple health check endpoint""" try: streamer_status = "initialized" if streamer is not None else "not_initialized" plot_manager_status = ( "available" if ( streamer and hasattr(streamer, "data_streamer") and hasattr(streamer.data_streamer, "plot_manager") ) else "not_available" ) return jsonify( { "status": "ok", "streamer": streamer_status, "plot_manager": plot_manager_status, "timestamp": time.time(), } ) except Exception as e: return ( jsonify({"status": "error", "error": str(e), "timestamp": time.time()}), 500, ) # πŸ“ˆ PLOT MANAGER API ENDPOINTS @app.route("/api/plots", methods=["GET"]) def get_plots(): """Get all plot sessions status""" print("πŸ” DEBUG: /api/plots endpoint called") error_response = check_streamer_initialized() if error_response: print("❌ DEBUG: Streamer not initialized") return error_response print("βœ… DEBUG: Streamer is initialized") try: print("πŸ” DEBUG: Accessing streamer.data_streamer.plot_manager...") plot_manager = streamer.data_streamer.plot_manager print(f"βœ… DEBUG: Plot manager obtained: {type(plot_manager)}") print("πŸ” DEBUG: Calling get_all_sessions_status()...") sessions = plot_manager.get_all_sessions_status() print(f"βœ… DEBUG: Sessions obtained: {len(sessions)} sessions") print(f"πŸ“Š DEBUG: Sessions data: {sessions}") result = {"sessions": sessions} print(f"βœ… DEBUG: Returning result: {result}") return jsonify(result) except Exception as e: print(f"πŸ’₯ DEBUG: Exception in get_plots: {type(e).__name__}: {str(e)}") import traceback print(f"πŸ“‹ DEBUG: Full traceback:") traceback.print_exc() return jsonify({"error": str(e)}), 500 @app.route("/api/plots", methods=["POST"]) def create_plot(): """Create a new plot session""" error_response = check_streamer_initialized() if error_response: return error_response try: data = request.get_json() # Validar datos requeridos if not data.get("variables"): return jsonify({"error": "At least one variable is required"}), 400 if not data.get("time_window"): return jsonify({"error": "Time window is required"}), 400 # Validar que las variables existen en datasets activos available_vars = streamer.data_streamer.plot_manager.get_available_variables( streamer.data_streamer.get_active_datasets(), streamer.config_manager.datasets, ) invalid_vars = [var for var in data["variables"] if var not in available_vars] if invalid_vars: return ( jsonify( { "error": f"Variables not available: {', '.join(invalid_vars)}", "available_variables": available_vars, } ), 400, ) # Validar trigger si estΓ‘ habilitado if data.get("trigger_enabled") and data.get("trigger_variable"): boolean_vars = streamer.data_streamer.plot_manager.get_boolean_variables( streamer.data_streamer.get_active_datasets(), streamer.config_manager.datasets, ) if data["trigger_variable"] not in boolean_vars: return ( jsonify( { "error": f"Trigger variable '{data['trigger_variable']}' is not a boolean variable", "boolean_variables": boolean_vars, } ), 400, ) # Crear configuraciΓ³n de la sesiΓ³n config = { "id": data.get("id"), # Use the plot ID from the definition "name": data.get( "name", f"Plot {len(streamer.data_streamer.plot_manager.sessions) + 1}" ), "variables": data["variables"], "time_window": int(data["time_window"]), "y_min": data.get("y_min"), "y_max": data.get("y_max"), "trigger_variable": data.get("trigger_variable"), "trigger_enabled": data.get("trigger_enabled", False), "trigger_on_true": data.get("trigger_on_true", True), } # Convertir valores numΓ©ricos si estΓ‘n presentes if config["y_min"] is not None: config["y_min"] = float(config["y_min"]) if config["y_max"] is not None: config["y_max"] = float(config["y_max"]) # Check if multiple sessions should be allowed (default: True for backward compatibility) allow_multiple = data.get("allow_multiple", True) session_id = streamer.data_streamer.plot_manager.create_session( config, allow_multiple ) return jsonify( { "success": True, "session_id": session_id, "message": f"Plot session '{config['name']}' created successfully", } ) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plots/", methods=["DELETE"]) def remove_plot(session_id): """Remove a plot session""" error_response = check_streamer_initialized() if error_response: return error_response try: success = streamer.data_streamer.plot_manager.remove_session(session_id) if success: return jsonify({"success": True, "message": "Plot session removed"}) else: return jsonify({"error": "Plot session not found"}), 404 except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plots//control", methods=["POST"]) def control_plot(session_id): """Control a plot session (start/stop/pause/resume/clear)""" error_response = check_streamer_initialized() if error_response: return error_response try: data = request.get_json() action = data.get("action") if action not in ["start", "stop", "pause", "resume", "clear"]: return ( jsonify( {"error": "Invalid action. Use: start, stop, pause, resume, clear"} ), 400, ) success = streamer.data_streamer.plot_manager.control_session( session_id, action ) if success: return jsonify({"success": True, "message": f"Plot session {action}ed"}) else: return jsonify({"error": "Plot session not found"}), 404 except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plots//data", methods=["GET"]) def get_plot_data(session_id): """Get plot data for a specific session""" error_response = check_streamer_initialized() if error_response: return error_response try: plot_data = streamer.data_streamer.plot_manager.get_session_data(session_id) if plot_data: return jsonify(plot_data) else: return jsonify({"error": "Plot session not found"}), 404 except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plots//config", methods=["GET"]) def get_plot_config(session_id): """Get plot configuration for a specific session""" error_response = check_streamer_initialized() if error_response: return error_response try: config = streamer.data_streamer.plot_manager.get_session_config(session_id) if config: return jsonify({"success": True, "config": config}) else: return jsonify({"error": "Plot session not found"}), 404 except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plots//config", methods=["PUT"]) def update_plot_config(session_id): """Update plot configuration for a specific session""" error_response = check_streamer_initialized() if error_response: return error_response try: data = request.get_json() # Validar datos requeridos if not data.get("variables"): return jsonify({"error": "At least one variable is required"}), 400 if not data.get("time_window"): return jsonify({"error": "Time window is required"}), 400 # Validar que las variables existen en datasets activos available_vars = streamer.data_streamer.plot_manager.get_available_variables( streamer.data_streamer.get_active_datasets(), streamer.config_manager.datasets, ) invalid_vars = [var for var in data["variables"] if var not in available_vars] if invalid_vars: return ( jsonify( { "error": f"Variables not available: {', '.join(invalid_vars)}", "available_variables": available_vars, } ), 400, ) # Validar trigger si estΓ‘ habilitado if data.get("trigger_enabled") and data.get("trigger_variable"): boolean_vars = streamer.data_streamer.plot_manager.get_boolean_variables( streamer.data_streamer.get_active_datasets(), streamer.config_manager.datasets, ) if data["trigger_variable"] not in boolean_vars: return ( jsonify( { "error": f"Trigger variable '{data['trigger_variable']}' is not a boolean variable", "boolean_variables": boolean_vars, } ), 400, ) # Crear configuraciΓ³n actualizada config = { "name": data.get("name", f"Plot {session_id}"), "variables": data["variables"], "time_window": int(data["time_window"]), "y_min": data.get("y_min"), "y_max": data.get("y_max"), "trigger_variable": data.get("trigger_variable"), "trigger_enabled": data.get("trigger_enabled", False), "trigger_on_true": data.get("trigger_on_true", True), } # Convertir valores numΓ©ricos si estΓ‘n presentes if config["y_min"] is not None: config["y_min"] = float(config["y_min"]) if config["y_max"] is not None: config["y_max"] = float(config["y_max"]) # Actualizar configuraciΓ³n success = streamer.data_streamer.plot_manager.update_session_config( session_id, config ) if success: return jsonify( { "success": True, "message": f"Plot session '{config['name']}' updated successfully", } ) else: return jsonify({"error": "Plot session not found"}), 404 except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plots/variables", methods=["GET"]) def get_plot_variables(): """Get available variables for plotting""" error_response = check_streamer_initialized() if error_response: return error_response try: available_vars = streamer.data_streamer.plot_manager.get_available_variables( streamer.data_streamer.get_active_datasets(), streamer.config_manager.datasets, ) boolean_vars = streamer.data_streamer.plot_manager.get_boolean_variables( streamer.data_streamer.get_active_datasets(), streamer.config_manager.datasets, ) return jsonify( { "available_variables": available_vars, "boolean_variables": boolean_vars, "active_datasets_count": len( streamer.data_streamer.get_active_datasets() ), } ) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plots/historical", methods=["POST"]) def get_historical_data(): """Get historical data from CSV files for plot initialization""" print("πŸ” DEBUG: Historical endpoint called") try: data = request.get_json() print(f"πŸ” DEBUG: Request data: {data}") if not data: print("❌ DEBUG: No data provided") return jsonify({"error": "No data provided"}), 400 variables = data.get("variables", []) time_window_seconds = data.get("time_window", 60) print(f"πŸ” DEBUG: Variables: {variables}") print(f"πŸ” DEBUG: Time window: {time_window_seconds}") if not variables: print("❌ DEBUG: No variables specified") return jsonify({"error": "No variables specified"}), 400 # Import pandas and glob (datetime already imported globally) try: print("πŸ” DEBUG: Importing pandas...") import pandas as pd print("πŸ” DEBUG: Importing glob...") import glob print("πŸ” DEBUG: Importing timedelta...") from datetime import timedelta print("πŸ” DEBUG: All imports successful") except ImportError as e: print(f"❌ DEBUG: Import failed: {e}") return jsonify({"error": f"pandas import failed: {str(e)}"}), 500 except Exception as e: print(f"❌ DEBUG: Unexpected import error: {e}") import traceback traceback.print_exc() return jsonify({"error": f"Import error: {str(e)}"}), 500 # Calculate time range try: print("πŸ” DEBUG: Calculating time range...") end_time = datetime.now() start_time = end_time - timedelta(seconds=time_window_seconds) print(f"πŸ” DEBUG: Time range calculated: {start_time} to {end_time}") except Exception as e: print(f"❌ DEBUG: Time calculation error: {e}") import traceback traceback.print_exc() return jsonify({"error": f"Time calculation failed: {str(e)}"}), 500 # Get records directory try: print("πŸ” DEBUG: Getting records directory...") records_dir = os.path.join(os.path.dirname(__file__), "records") print(f"πŸ” DEBUG: Records directory: {records_dir}") print(f"πŸ” DEBUG: Records dir exists: {os.path.exists(records_dir)}") if not os.path.exists(records_dir): print("πŸ” DEBUG: Records directory not found, returning empty data") return jsonify({"data": []}) historical_data = [] # Get date folders to search (today and yesterday in case time window spans days) print("πŸ” DEBUG: Calculating date folders...") today = end_time.strftime("%d-%m-%Y") yesterday = (end_time - timedelta(days=1)).strftime("%d-%m-%Y") print(f"πŸ” DEBUG: Searching dates: {yesterday}, {today}") except Exception as e: print(f"❌ DEBUG: Records directory error: {e}") import traceback traceback.print_exc() return jsonify({"error": f"Records directory error: {str(e)}"}), 500 date_folders = [] for date_folder in [yesterday, today]: folder_path = os.path.join(records_dir, date_folder) print( f"πŸ” DEBUG: Checking folder: {folder_path}, exists: {os.path.exists(folder_path)}" ) if os.path.exists(folder_path): date_folders.append(folder_path) print(f"πŸ” DEBUG: Found date folders: {date_folders}") # Search for CSV files with any of the required variables for folder_path in date_folders: csv_files = glob.glob(os.path.join(folder_path, "*.csv")) print(f"πŸ” DEBUG: CSV files in {folder_path}: {csv_files}") for csv_file in csv_files: try: print(f"πŸ” DEBUG: Processing CSV file: {csv_file}") # Read first line to check if any required variables are present with proper encoding handling header_line = None for encoding in ["utf-8", "utf-8-sig", "utf-16", "latin-1"]: try: with open(csv_file, "r", encoding=encoding) as f: header_line = f.readline().strip() if header_line: print( f"πŸ” DEBUG: Successfully read header with {encoding} encoding" ) break except (UnicodeDecodeError, UnicodeError): continue except Exception: continue if not header_line: print( f"πŸ” DEBUG: Could not read header from {csv_file}, skipping" ) continue # Clean header line from BOM and normalize header_line = header_line.replace("\ufeff", "").replace("\x00", "") headers = [ h.strip().replace("\x00", "") for h in header_line.split(",") ] # Clean any remaining unicode artifacts headers = [ h for h in headers if h and len(h.replace("\x00", "").strip()) > 0 ] print(f"πŸ” DEBUG: Headers in {csv_file}: {headers}") # Check if any of our variables are in this file matching_vars = [var for var in variables if var in headers] print(f"πŸ” DEBUG: Matching variables: {matching_vars}") if not matching_vars: print( f"πŸ” DEBUG: No matching variables in {csv_file}, skipping" ) continue # Read the CSV file with proper encoding and error handling print(f"πŸ” DEBUG: Reading CSV file with pandas...") df = None for encoding in ["utf-8", "utf-8-sig", "utf-16", "latin-1"]: try: df = pd.read_csv( csv_file, encoding=encoding, on_bad_lines="skip" ) print( f"πŸ” DEBUG: CSV loaded with {encoding}, shape: {df.shape}" ) break except ( UnicodeDecodeError, UnicodeError, pd.errors.ParserError, ): continue except Exception as e: print(f"πŸ” DEBUG: Error reading with {encoding}: {e}") continue if df is None: print( f"Warning: Could not read CSV file {csv_file} with any encoding" ) continue # Clean column names from BOM and unicode artifacts df.columns = [ col.replace("\ufeff", "").replace("\x00", "").strip() for col in df.columns ] # Convert timestamp to datetime with flexible parsing print(f"πŸ” DEBUG: Converting timestamps...") timestamp_col = None for col in df.columns: if "timestamp" in col.lower(): timestamp_col = col break if timestamp_col is None: print( f"πŸ” DEBUG: No timestamp column found in {csv_file}, skipping" ) continue try: # Try multiple timestamp formats df[timestamp_col] = pd.to_datetime( df[timestamp_col], errors="coerce" ) # Remove rows with invalid timestamps df = df.dropna(subset=[timestamp_col]) if df.empty: print( f"πŸ” DEBUG: No valid timestamps in {csv_file}, skipping" ) continue # Normalize column name to 'timestamp' if timestamp_col != "timestamp": df = df.rename(columns={timestamp_col: "timestamp"}) print( f"πŸ” DEBUG: Timestamp range: {df['timestamp'].min()} to {df['timestamp'].max()}" ) print(f"πŸ” DEBUG: Filter range: {start_time} to {end_time}") except Exception as e: print( f"πŸ” DEBUG: Timestamp conversion failed for {csv_file}: {e}" ) continue # Recalculate matching variables after column cleaning clean_headers = list(df.columns) matching_vars = [var for var in variables if var in clean_headers] print( f"πŸ” DEBUG: Matching variables after cleaning: {matching_vars}" ) if not matching_vars: print( f"πŸ” DEBUG: No matching variables after cleaning in {csv_file}, skipping" ) continue # Filter by time range mask = (df["timestamp"] >= start_time) & ( df["timestamp"] <= end_time ) filtered_df = df[mask] print(f"πŸ” DEBUG: Filtered dataframe shape: {filtered_df.shape}") if filtered_df.empty: print(f"πŸ” DEBUG: No data in time range for {csv_file}") continue # Extract data for matching variables only print(f"πŸ” DEBUG: Extracting data for variables: {matching_vars}") for _, row in filtered_df.iterrows(): timestamp = row["timestamp"] for var in matching_vars: if var in row and pd.notna(row[var]): try: # Convert value to appropriate type value = row[var] # Handle boolean values if isinstance(value, str): value_lower = value.lower().strip() if value_lower == "true": value = True elif value_lower == "false": value = False else: try: value = float(value) except ValueError: continue elif isinstance(value, (int, float)): value = float(value) else: continue historical_data.append( { "timestamp": timestamp.isoformat(), "variable": var, "value": value, } ) except Exception as e: # Skip invalid values print( f"πŸ” DEBUG: Skipping invalid value for {var}: {e}" ) continue except Exception as e: # Skip files that can't be read print(f"Warning: Could not read CSV file {csv_file}: {e}") continue # Sort by timestamp historical_data.sort(key=lambda x: x["timestamp"]) print(f"πŸ” DEBUG: Total historical data points found: {len(historical_data)}") print( f"πŸ” DEBUG: Variables found: {list(set([item['variable'] for item in historical_data]))}" ) return jsonify( { "data": historical_data, "time_range": { "start": start_time.isoformat(), "end": end_time.isoformat(), }, "variables_found": list( set([item["variable"] for item in historical_data]) ), "total_points": len(historical_data), } ) except ImportError as e: return ( jsonify( { "error": f"pandas is required for historical data processing: {str(e)}" } ), 500, ) except Exception as e: import traceback traceback.print_exc() return jsonify({"error": f"Internal server error: {str(e)}"}), 500 @app.route("/api/plots/sessions/", methods=["GET"]) def get_plot_sessions(plot_id): """Get all session IDs for a specific plot ID""" error_response = check_streamer_initialized() if error_response: return error_response try: session_ids = streamer.data_streamer.plot_manager.get_sessions_by_plot_id( plot_id ) return jsonify( { "success": True, "plot_id": plot_id, "session_ids": session_ids, "session_count": len(session_ids), } ) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plots/cleanup", methods=["POST"]) def cleanup_plot_sessions(): """Manually cleanup inactive plot sessions""" error_response = check_streamer_initialized() if error_response: return error_response try: data = request.get_json() or {} max_age_seconds = data.get("max_age_seconds", 3600) # Default 1 hour removed_count = streamer.data_streamer.plot_manager.cleanup_inactive_sessions( max_age_seconds ) return jsonify( { "success": True, "removed_sessions": removed_count, "message": f"Cleaned up {removed_count} inactive plot sessions", } ) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/status") def get_status(): """Get current status""" if streamer is None: return jsonify({"error": "Application not initialized"}), 503 return jsonify(streamer.get_status()) @app.route("/api/plc/reconnection/status") def get_plc_reconnection_status(): """Get detailed PLC reconnection status""" error_response = check_streamer_initialized() if error_response: return error_response try: status = streamer.plc_client.get_reconnection_status() connection_info = streamer.plc_client.get_connection_info() return jsonify( {"success": True, "reconnection": status, "connection": connection_info} ) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/plc/reconnection/enable", methods=["POST"]) def enable_plc_reconnection(): """Enable automatic PLC reconnection""" error_response = check_streamer_initialized() if error_response: return error_response try: streamer.plc_client.enable_automatic_reconnection(True) return jsonify( {"success": True, "message": "Automatic PLC reconnection enabled"} ) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/plc/reconnection/disable", methods=["POST"]) def disable_plc_reconnection(): """Disable automatic PLC reconnection""" error_response = check_streamer_initialized() if error_response: return error_response try: streamer.plc_client.enable_automatic_reconnection(False) return jsonify( {"success": True, "message": "Automatic PLC reconnection disabled"} ) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/events") def get_events(): """Get recent events from the application log""" error_response = check_streamer_initialized() if error_response: return error_response try: limit = request.args.get("limit", 50, type=int) limit = min(limit, 200) # Maximum 200 events per request events = streamer.get_recent_events(limit) return jsonify( { "success": True, "events": events, "total_events": len(streamer.event_logger.events_log), "showing": len(events), } ) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/stream/variables", methods=["GET"]) def stream_variables(): """Stream variable values in real-time using Server-Sent Events""" error_response = check_streamer_initialized() if error_response: return error_response # Get request parameters outside the generator dataset_id = request.args.get("dataset_id") interval = float(request.args.get("interval", 1.0)) # Default 1 second if not dataset_id: return jsonify({"error": "Dataset ID required"}), 400 if dataset_id not in streamer.datasets: return jsonify({"error": f"Dataset {dataset_id} not found"}), 404 def generate(): """Generate SSE data stream - CACHE ONLY""" # Send initial connection message yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established - monitoring cache'})}\n\n" last_values = {} while True: try: # Check basic preconditions for cache availability if not streamer.plc_client.is_connected(): # PLC not connected - cache won't be populated data = { "type": "plc_disconnected", "message": "PLC not connected - cache not being populated", "timestamp": datetime.now().isoformat(), } yield f"data: {json.dumps(data)}\n\n" time.sleep(interval) continue # Check if dataset is active if dataset_id not in streamer.active_datasets: data = { "type": "dataset_inactive", "message": f"Dataset '{dataset_id}' is not active - activate to populate cache", "timestamp": datetime.now().isoformat(), } yield f"data: {json.dumps(data)}\n\n" time.sleep(interval) continue # Get dataset variables dataset_variables = streamer.get_dataset_variables(dataset_id) if not dataset_variables: # No variables in dataset data = { "type": "no_variables", "message": "No variables defined in this dataset", "timestamp": datetime.now().isoformat(), } yield f"data: {json.dumps(data)}\n\n" time.sleep(interval) continue # Get cached values - the ONLY source according to application principles if not streamer.has_cached_values(dataset_id): # No cache available yet - dataset might be starting up sampling_interval = ( streamer.config_manager.get_dataset_sampling_interval( dataset_id ) ) data = { "type": "no_cache", "message": f"Waiting for cache to be populated (dataset reads every {sampling_interval}s)", "timestamp": datetime.now().isoformat(), "sampling_interval": sampling_interval, } yield f"data: {json.dumps(data)}\n\n" time.sleep(interval) continue # Get cached values (the ONLY valid source) read_result = streamer.get_cached_dataset_values(dataset_id) if read_result.get("success", False): values = read_result.get("values", {}) timestamp = read_result.get("timestamp", datetime.now().isoformat()) # Format values for display formatted_values = {} for var_name, value in values.items(): if value is not None: var_config = dataset_variables[var_name] var_type = var_config.get("type", "unknown") try: if var_type == "real": formatted_values[var_name] = ( f"{value:.3f}" if isinstance(value, (int, float)) else str(value) ) elif var_type == "bool": formatted_values[var_name] = ( "TRUE" if value else "FALSE" ) elif var_type in [ "int", "uint", "dint", "udint", "word", "byte", "sint", "usint", ]: formatted_values[var_name] = ( str(int(value)) if isinstance(value, (int, float)) else str(value) ) else: formatted_values[var_name] = str(value) except: formatted_values[var_name] = "FORMAT_ERROR" else: formatted_values[var_name] = "ERROR" # Only send if values changed if formatted_values != last_values: data = { "type": "values", "values": formatted_values, "timestamp": timestamp, "source": "cache", "stats": read_result.get("stats", {}), "cache_age_info": f"Dataset reads every {streamer.config_manager.get_dataset_sampling_interval(dataset_id)}s", } yield f"data: {json.dumps(data)}\n\n" last_values = formatted_values.copy() else: # Send error data from cache error_data = { "type": "cache_error", "message": read_result.get( "error", "Cached data indicates error" ), "timestamp": datetime.now().isoformat(), "error_type": read_result.get("error_type", "unknown"), "source": "cache", } yield f"data: {json.dumps(error_data)}\n\n" time.sleep(interval) except Exception as e: error_data = { "type": "stream_error", "message": f"SSE stream error: {str(e)}", "timestamp": datetime.now().isoformat(), "source": "cache_monitoring", } yield f"data: {json.dumps(error_data)}\n\n" time.sleep(interval) return Response( generate(), mimetype="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Cache-Control", }, ) @app.route("/api/stream/status", methods=["GET"]) def stream_status(): """Stream application status in real-time using Server-Sent Events""" error_response = check_streamer_initialized() if error_response: return error_response # Get request parameters outside the generator interval = float(request.args.get("interval", 2.0)) # Default 2 seconds def generate(): """Generate SSE status stream""" last_status = None # Send initial connection message yield f"data: {json.dumps({'type': 'connected', 'message': 'Status stream connected'})}\n\n" while True: try: # Get current status current_status = streamer.get_status() # Only send if status changed if current_status != last_status: data = { "type": "status", "status": current_status, "timestamp": datetime.now().isoformat(), } yield f"data: {json.dumps(data)}\n\n" last_status = current_status time.sleep(interval) except Exception as e: error_data = { "type": "error", "message": f"Status stream error: {str(e)}", "timestamp": datetime.now().isoformat(), } yield f"data: {json.dumps(error_data)}\n\n" time.sleep(interval) return Response( generate(), mimetype="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Cache-Control", }, ) def graceful_shutdown(): """Perform graceful shutdown""" print("\n⏹️ Performing graceful shutdown...") try: streamer.stop_streaming() streamer.disconnect_plc() streamer.release_instance_lock() print("βœ… Shutdown completed successfully") except Exception as e: print(f"⚠️ Error during shutdown: {e}") def main(): """Main application entry point with error handling and recovery""" max_retries = 3 retry_count = 0 while retry_count < max_retries: try: print("πŸš€ Starting Flask server for PLC S7-315 Streamer") print("πŸ“Š Web interface available at: http://localhost:5050") print("πŸ”§ Configure your PLC and variables through the web interface") # Initialize streamer (this will handle instance locking and auto-recovery) global streamer # Start Flask application app.run(debug=False, host="0.0.0.0", port=5050, use_reloader=False) # If we reach here, the server stopped normally break except RuntimeError as e: if "Another instance" in str(e): print(f"❌ {e}") print("πŸ’‘ Tip: Stop the other instance or wait for it to finish") sys.exit(1) else: print(f"⚠️ Runtime error: {e}") retry_count += 1 except KeyboardInterrupt: print("\n⏸️ Received interrupt signal...") graceful_shutdown() break except Exception as e: print(f"πŸ’₯ Unexpected error: {e}") retry_count += 1 if retry_count < max_retries: print(f"πŸ”„ Attempting restart ({retry_count}/{max_retries})...") time.sleep(2) # Wait before retry else: print("❌ Maximum retries reached. Exiting...") graceful_shutdown() sys.exit(1) # ============================================================================== # Symbol Management API Endpoints # ============================================================================== @app.route("/api/utils/browse-file", methods=["POST"]) def browse_file(): """Open file dialog to browse for ASC symbol files.""" try: if not TKINTER_AVAILABLE: return ( jsonify( { "success": False, "error": "File browser not available. Please enter the file path manually.", } ), 400, ) data = request.get_json() title = data.get("title", "Select File") filetypes = data.get("filetypes", [["All Files", "*.*"]]) # Create a temporary tkinter root window root = tk.Tk() root.withdraw() # Hide the root window root.attributes("-topmost", True) # Bring to front # Open file dialog file_path = filedialog.askopenfilename(title=title, filetypes=filetypes) root.destroy() # Clean up if file_path: return jsonify({"success": True, "file_path": file_path}) else: return jsonify({"success": True, "cancelled": True}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/symbols/load", methods=["POST"]) def load_symbols(): """Load symbols from ASC file and save to JSON.""" try: data = request.get_json() asc_file_path = data.get("asc_file_path") if not asc_file_path: return ( jsonify({"success": False, "error": "ASC file path is required"}), 400, ) if not os.path.exists(asc_file_path): return ( jsonify( {"success": False, "error": f"ASC file not found: {asc_file_path}"} ), 404, ) # Initialize symbol loader with optional logger logger = None try: logger = ( streamer.event_logger if "streamer" in globals() and streamer else None ) except: pass # Use None logger if streamer is not available symbol_loader = SymbolLoader(logger) # Load symbols and save to JSON json_output_path = project_path("config", "data", "plc_symbols.json") # Ensure the directory exists os.makedirs(os.path.dirname(json_output_path), exist_ok=True) symbols_count = symbol_loader.load_asc_and_save_json( asc_file_path, json_output_path ) # Log the success if logger: logger.log_event( "info", "symbols_loaded", f"Loaded {symbols_count} symbols from {asc_file_path}", ) return jsonify( { "success": True, "symbols_count": symbols_count, "json_path": json_output_path, } ) except Exception as e: error_msg = f"Error loading symbols: {str(e)}" print(f"DEBUG - Symbol loading error: {error_msg}") # Debug print print(f"DEBUG - Exception type: {type(e).__name__}") # Print full stack trace import traceback print("DEBUG - Full stack trace:") traceback.print_exc() # Log the error if "streamer" in globals() and streamer and streamer.event_logger: streamer.event_logger.log_event("error", "symbols_load_failed", error_msg) return jsonify({"success": False, "error": error_msg}), 500 @app.route("/api/symbols", methods=["GET"]) def get_symbols(): """Get loaded symbols from JSON file.""" try: json_path = project_path("config", "data", "plc_symbols.json") if not os.path.exists(json_path): return jsonify({"success": True, "symbols": [], "total_count": 0}) with open(json_path, "r", encoding="utf-8") as file: symbols_data = json.load(file) return jsonify({"success": True, **symbols_data}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/symbols/search", methods=["POST"]) def search_symbols(): """Search symbols by name or description.""" try: data = request.get_json() query = data.get("query", "").lower() limit = data.get("limit", 50) json_path = project_path("config", "data", "plc_symbols.json") if not os.path.exists(json_path): return jsonify({"success": True, "symbols": [], "total_count": 0}) with open(json_path, "r", encoding="utf-8") as file: symbols_data = json.load(file) all_symbols = symbols_data.get("symbols", []) if not query: # Return first N symbols if no query filtered_symbols = all_symbols[:limit] else: # Search in name and description filtered_symbols = [] for symbol in all_symbols: if ( query in symbol.get("name", "").lower() or query in symbol.get("description", "").lower() ): filtered_symbols.append(symbol) if len(filtered_symbols) >= limit: break return jsonify( { "success": True, "symbols": filtered_symbols, "total_count": len(filtered_symbols), "query": query, } ) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/symbols/process-variables", methods=["POST"]) def process_symbol_variables(): """Process dataset variables, expanding symbol-based ones.""" try: print(f"[DEBUG] process_symbol_variables called") data = request.get_json() print(f"[DEBUG] Request data: {data}") variables = data.get("variables", []) print(f"[DEBUG] Variables to process: {len(variables)}") if not variables: print(f"[DEBUG] No variables provided") return ( jsonify({"success": False, "error": "Variables array is required"}), 400, ) # Initialize symbol processor with optional logger logger = None try: logger = ( streamer.event_logger if "streamer" in globals() and streamer else None ) except Exception as e: print(f"[DEBUG] Logger setup failed: {e}") pass # Use None logger if streamer is not available print(f"[DEBUG] Creating SymbolProcessor with logger: {logger is not None}") symbol_processor = SymbolProcessor(logger) # Get symbols path symbols_path = project_path("config", "data", "plc_symbols.json") print(f"[DEBUG] Symbols path: {symbols_path}") print(f"[DEBUG] Symbols file exists: {os.path.exists(symbols_path)}") if not os.path.exists(symbols_path): print(f"[DEBUG] Symbols file not found") return ( jsonify( { "success": False, "error": "No symbols file found. Please load an ASC file first.", } ), 404, ) # Process variables print(f"[DEBUG] Processing variables...") processed_variables = symbol_processor.process_dataset_variables( variables, symbols_path ) print(f"[DEBUG] Processed {len(processed_variables)} variables") # Validate the processed variables print(f"[DEBUG] Validating variables...") validation = symbol_processor.validate_symbol_variables(variables, symbols_path) print(f"[DEBUG] Validation result: {validation}") result = { "success": True, "processed_variables": processed_variables, "validation": validation, } print( f"[DEBUG] Returning result with {len(processed_variables)} processed variables" ) return jsonify(result) except Exception as e: print(f"[ERROR] Exception in process_symbol_variables: {str(e)}") print(f"[ERROR] Exception type: {type(e)}") import traceback print(f"[ERROR] Traceback: {traceback.format_exc()}") return jsonify({"success": False, "error": str(e)}), 500 # CSV File Browser Endpoints @app.route("/api/csv/files", methods=["GET"]) def get_csv_files(): """Get structured list of CSV files organized by dataset, date, and hour""" try: records_dir = resource_path("records") if not os.path.exists(records_dir): return jsonify({"files": [], "tree": []}) file_tree = [] files_flat = [] # Scan records directory for date_dir in os.listdir(records_dir): date_path = os.path.join(records_dir, date_dir) if not os.path.isdir(date_path): continue date_node = { "id": f"date_{date_dir}", "name": f"πŸ“… {date_dir}", "value": date_dir, "type": "date", "children": [], } # Group files by dataset datasets = {} for filename in os.listdir(date_path): if not filename.endswith(".csv"): continue file_path = os.path.join(date_path, filename) # Extract dataset name (everything before the first underscore + number) dataset_name = filename.split("_")[0] if len(filename.split("_")) > 1: # If there's a second part, it might be part of dataset name parts = filename.split("_") if not parts[-1].replace(".csv", "").isdigit(): dataset_name = "_".join(parts[:-1]) # Get file info try: stat = os.stat(file_path) file_size = stat.st_size file_mtime = datetime.fromtimestamp(stat.st_mtime) # Try to get CSV info (first 3 columns and row count) csv_info = get_csv_file_info(file_path) file_info = { "id": f"file_{date_dir}_{filename}", "name": f"πŸ“Š {filename}", "value": filename, "type": "file", "path": file_path, "date": date_dir, "dataset": dataset_name, "size": file_size, "size_human": format_file_size(file_size), "modified": file_mtime.isoformat(), "modified_human": file_mtime.strftime("%H:%M:%S"), **csv_info, } files_flat.append(file_info) if dataset_name not in datasets: datasets[dataset_name] = { "id": f"dataset_{date_dir}_{dataset_name}", "name": f"πŸ“Š {dataset_name}", "value": dataset_name, "type": "dataset", "children": [], } datasets[dataset_name]["children"].append(file_info) except Exception as e: print(f"Error processing file {filename}: {e}") continue # Add datasets to date node date_node["children"] = list(datasets.values()) if date_node["children"]: # Only add dates that have files file_tree.append(date_node) # Sort by date (newest first) file_tree.sort(key=lambda x: x["value"], reverse=True) return jsonify( {"files": files_flat, "tree": file_tree, "total_files": len(files_flat)} ) except Exception as e: return jsonify({"error": str(e), "files": [], "tree": []}), 500 def get_csv_file_info(file_path): """Get CSV file information: first 3 columns and row count""" try: import csv with open(file_path, "r", encoding="utf-8") as f: reader = csv.reader(f) header = next(reader, []) # Count rows (approximately, for performance) row_count = sum(1 for _ in reader) # Get first 3 column names first_columns = header[:3] if header else [] return { "columns": first_columns, "column_count": len(header), "row_count": row_count, "preview": ", ".join(first_columns[:3]), } except Exception: return {"columns": [], "column_count": 0, "row_count": 0, "preview": "Unknown"} def format_file_size(size_bytes): """Format file size in human readable format""" if size_bytes == 0: return "0 B" size_names = ["B", "KB", "MB", "GB"] import math i = int(math.floor(math.log(size_bytes, 1024))) p = math.pow(1024, i) s = round(size_bytes / p, 2) return f"{s} {size_names[i]}" @app.route("/api/plotjuggler/launch", methods=["POST"]) def launch_plotjuggler(): """Launch PlotJuggler with selected CSV files""" try: data = request.get_json() file_paths = data.get("files", []) if not file_paths: return jsonify({"error": "No files provided"}), 400 # Get PlotJuggler path from system state plotjuggler_path = get_plotjuggler_path() if not plotjuggler_path: return jsonify({"error": "PlotJuggler not found"}), 404 # Launch PlotJuggler with files import subprocess if len(file_paths) == 1: # Single file - use --datafile for compatibility cmd = [plotjuggler_path, "--nosplash", "--datafile", file_paths[0]] else: # Multiple files - use single instance with multiple -d flags cmd = [plotjuggler_path, "-n"] # -n for no splash for file_path in file_paths: cmd.extend(["-d", file_path]) # Execute command subprocess.Popen(cmd, shell=True) return jsonify( { "success": True, "message": f"PlotJuggler launched with {len(file_paths)} file(s)", "command": " ".join([f'"{arg}"' if " " in arg else arg for arg in cmd]), "files_count": len(file_paths), } ) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plotjuggler/launch-streamer", methods=["POST"]) def launch_plotjuggler_streamer(): """Launch PlotJuggler with UDP streamer configured""" try: # Get PlotJuggler path from system state plotjuggler_path = get_plotjuggler_path() if not plotjuggler_path: return jsonify({"error": "PlotJuggler not found"}), 404 # Launch PlotJuggler with UDP streamer parameters import subprocess cmd = [ plotjuggler_path, "-n", # no recent files menu "--start_streamer", "UDP streamer", ] subprocess.Popen(cmd, shell=True) return jsonify( { "success": True, "message": "PlotJuggler launched with UDP streamer", "command": " ".join(cmd), } ) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plotjuggler/path", methods=["GET"]) def get_plotjuggler_path_endpoint(): """Get PlotJuggler executable path""" try: path = get_plotjuggler_path() return jsonify({"path": path, "found": path is not None}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/plotjuggler/path", methods=["POST"]) def set_plotjuggler_path(): """Set PlotJuggler executable path""" try: data = request.get_json() path = data.get("path", "") if not path or not os.path.exists(path): return jsonify({"error": "Invalid path provided"}), 400 # Save to system state save_plotjuggler_path(path) return jsonify( {"success": True, "message": "PlotJuggler path saved", "path": path} ) except Exception as e: return jsonify({"error": str(e)}), 500 def get_plotjuggler_path(): """Get PlotJuggler executable path, search if not found""" try: # Load from system state state_file = resource_path("system_state.json") if os.path.exists(state_file): with open(state_file, "r") as f: state = json.load(f) saved_path = state.get("plotjuggler_path") if saved_path and os.path.exists(saved_path): return saved_path # Search for PlotJuggler search_paths = [ r"C:\Program Files\PlotJuggler\plotjuggler.exe", r"C:\Program Files (x86)\PlotJuggler\plotjuggler.exe", r"C:\PlotJuggler\plotjuggler.exe", ] for path in search_paths: if os.path.exists(path): save_plotjuggler_path(path) return path return None except Exception as e: print(f"Error getting PlotJuggler path: {e}") return None def save_plotjuggler_path(path): """Save PlotJuggler path to system state""" try: state_file = resource_path("system_state.json") state = {} if os.path.exists(state_file): with open(state_file, "r") as f: state = json.load(f) state["plotjuggler_path"] = path state["last_update"] = datetime.now().isoformat() with open(state_file, "w") as f: json.dump(state, f, indent=4) except Exception as e: print(f"Error saving PlotJuggler path: {e}") @app.route("/api/csv/open-excel", methods=["POST"]) def open_csv_in_excel(): """Open CSV file in Excel (or default spreadsheet app)""" try: data = request.get_json() file_path = data.get("file_path", "") if not file_path or not os.path.exists(file_path): return jsonify({"error": "File not found"}), 404 import subprocess import platform system = platform.system() if system == "Windows": # Use os.startfile on Windows os.startfile(file_path) elif system == "Darwin": # macOS subprocess.Popen(["open", file_path]) else: # Linux subprocess.Popen(["xdg-open", file_path]) return jsonify( { "success": True, "message": f"Opened {os.path.basename(file_path)} in default application", } ) except Exception as e: return jsonify({"error": str(e)}), 500 if __name__ == "__main__": try: # Initialize streamer instance streamer = PLCDataStreamer() main() except Exception as e: print(f"πŸ’₯ Critical error during initialization: {e}") sys.exit(1)