from flask import ( Flask, render_template, request, jsonify, redirect, url_for, send_from_directory, ) import snap7 import snap7.util import json import socket import time import logging import threading from datetime import datetime from typing import Dict, Any, Optional, List import struct import os import csv from pathlib import Path import atexit import psutil import sys from core.plc_client import PLCDataStreamer app = Flask(__name__) app.secret_key = "plc_streamer_secret_key" 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) # Global streamer instance (will be initialized in main) streamer = None 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("/") def index(): """Main page""" if streamer is None: return "Application not initialized", 503 # Get variables for the current dataset or empty dict if no current dataset current_variables = {} if streamer.current_dataset_id and streamer.current_dataset_id in streamer.datasets: current_variables = streamer.datasets[streamer.current_dataset_id].get( "variables", {} ) return render_template( "index.html", status=streamer.get_status(), variables=current_variables, datasets=streamer.datasets, current_dataset_id=streamer.current_dataset_id, ) @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)) streamer.update_udp_config(host, port) return jsonify({"success": True, "message": "UDP configuration updated"}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @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: return jsonify({"success": False, "message": "Error connecting to PLC"}), 500 @app.route("/api/plc/disconnect", methods=["POST"]) def disconnect_plc(): """Disconnect from PLC""" streamer.stop_streaming() 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": f"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}) # 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 streamer.save_datasets() 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 streaming""" error_response = check_streamer_initialized() if error_response: return error_response if streamer.start_streaming(): return jsonify({"success": True, "message": "Streaming started"}) else: return jsonify({"success": False, "message": "Error starting streaming"}), 500 @app.route("/api/streaming/stop", methods=["POST"]) def stop_streaming(): """Stop streaming""" streamer.stop_streaming() return jsonify({"success": True, "message": "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(): """Start CSV recording independently""" if 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(): """Stop CSV recording independently""" streamer.stop_csv_recording() return jsonify({"success": True, "message": "CSV recording stopped"}) @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/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.events_log), "showing": len(events), } ) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 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: # Create templates directory if it doesn't exist os.makedirs("templates", exist_ok=True) 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) if __name__ == "__main__": try: # Initialize streamer instance streamer = PLCDataStreamer() main() except Exception as e: print(f"💥 Critical error during initialization: {e}") sys.exit(1)