diff --git a/.doc/MemoriaDeEvolucion.md b/.doc/MemoriaDeEvolucion.md index 0537fa6..44bcd0e 100644 --- a/.doc/MemoriaDeEvolucion.md +++ b/.doc/MemoriaDeEvolucion.md @@ -1,32 +1,69 @@ -### Objective -Create a python script with flask to do logging and do streaming to plotjuggler of live data from a PLC S7-315 of Siemens. The web gui must allow me to configure the connection data with the PLC and activate the connection. Then Also it must allow me to configure the UDP gateway for doing streaming to PlotJuggler. Also it must allow to create the list of variables to do polling to the PLC. +# Project Evolution Memory -### Application Description +## PLC S7-315 Streamer & Logger -**PLC S7-315 Streamer & Logger** is a web application developed in Python with Flask that enables real-time communication with Siemens S7-315 PLCs for data acquisition, logging, and streaming. +### Latest Modifications (Current Session) -#### Main Features: +#### Persistent Configuration System +**Decision**: Implemented JSON-based persistence for both PLC configuration and variables setup. -1. **PLC Connection**: Configuration and management of TCP/IP connections with S7-315 PLCs using the snap7 library -2. **Variable Management**: Dynamic configuration of variables for polling (real, int, bool, dint) with DB, offset, and type specification -3. **UDP Streaming**: Real-time data transmission to PlotJuggler via UDP with timestamped JSON format -4. **Logging**: Automatic data logging to local file with high-precision timestamps -5. **Web Interface**: Complete control panel for system configuration and status monitoring +**Rationale**: The application needed to remember user configurations between sessions to improve usability and prevent data loss. -#### Technical Architecture: +**Implementation**: +- Created two separate JSON files: `plc_config.json` for system settings and `plc_variables.json` for variable definitions +- Added automatic save/load functionality that triggers on every configuration change +- Configuration includes PLC connection settings, UDP gateway settings, and sampling intervals +- Variables are automatically saved when added or removed from the monitoring list -- **Backend**: Flask with threading for asynchronous streaming -- **PLC Communication**: snap7 client for S7 protocol -- **Streaming**: UDP socket for PlotJuggler transmission -- **Logging**: Python logging system with persistent file -- **Frontend**: HTML/CSS/JavaScript for user interface +**Impact**: Users no longer need to reconfigure the system every time they restart the application, significantly improving the user experience. -#### Operation Flow: +#### Interface Localization to English +**Decision**: Converted all user interface text and code comments from Spanish to English. -1. PLC parameter configuration (IP, rack, slot) -2. UDP destination configuration (host, port) -3. Variable definition for monitoring -4. PLC connection and validation -5. Streaming start with configurable interval -6. Continuous data transmission to PlotJuggler and local logging +**Rationale**: English provides better compatibility with international teams and technical documentation standards. + +**Implementation**: +- Updated all HTML template text labels and messages +- Translated JavaScript functions and comments +- Converted Python docstrings and log messages to English +- Updated confirmation dialogs and status messages + +**Impact**: The application is now accessible to a broader international audience and follows standard technical documentation practices. + +#### Grayscale Visual Design +**Decision**: Replaced the colorful gradient design with a professional grayscale color scheme. + +**Rationale**: Gray tones provide better visual consistency for industrial applications and reduce visual fatigue during extended monitoring sessions. + +**Implementation**: +- Applied grayscale palette using standard gray color codes +- Maintained visual hierarchy through different gray intensities +- Preserved button states and interactive feedback using gray variations +- Ensured proper contrast for accessibility + +**Impact**: The interface now has a more professional, industrial appearance suitable for PLC monitoring applications. + +### Technical Architecture Decisions + +#### Class-Based Streamer Design +The `PLCDataStreamer` class encapsulates all PLC communication logic, providing clear separation of concerns between data acquisition, UDP transmission, and web interface management. + +#### Threaded Streaming Implementation +Streaming operations run in a separate daemon thread to prevent blocking the web interface, ensuring responsive user interaction during data collection. + +#### Modular Configuration Management +Configuration handling is separated into distinct methods for PLC settings, UDP settings, and variable management, allowing independent updates without affecting other system components. + +#### Initialization Order Fix +**Issue Resolved**: Fixed AttributeError during application startup where configuration loading was attempted before logger initialization. + +**Solution**: Reordered the initialization sequence in the PLCDataStreamer constructor to setup logging first, then load configuration files. + +**Technical Impact**: Ensured proper error handling and logging throughout the application lifecycle from the very first startup. + +### Future Considerations + +The persistent configuration system provides a foundation for more advanced features like configuration profiles, backup/restore functionality, and remote configuration management. + +The English interface and standardized design make the application ready for potential integration with larger industrial monitoring systems or deployment in international environments. diff --git a/main.py b/main.py index 48d6ae8..8ecb183 100644 --- a/main.py +++ b/main.py @@ -16,16 +16,19 @@ app.secret_key = "plc_streamer_secret_key" class PLCDataStreamer: def __init__(self): - """Inicializa el streamer de datos del PLC""" - # Configuración por defectoclear - self.plc_config = {"ip": "192.168.1.100", "rack": 0, "slot": 2} + """Initialize the PLC data streamer""" + # Configuration file paths + self.config_file = "plc_config.json" + self.variables_file = "plc_variables.json" + # Default configuration + self.plc_config = {"ip": "192.168.1.100", "rack": 0, "slot": 2} self.udp_config = {"host": "127.0.0.1", "port": 9870} - # Variables configurables + # Configurable variables self.variables = {} - # Estados + # System states self.plc = None self.udp_socket = None self.connected = False @@ -33,11 +36,15 @@ class PLCDataStreamer: self.stream_thread = None self.sampling_interval = 0.1 - # Configurar logging + # Setup logging first self.setup_logging() + # Load configuration from files + self.load_configuration() + self.load_variables() + def setup_logging(self): - """Configura el sistema de logging""" + """Configure the logging system""" logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", @@ -45,29 +52,95 @@ class PLCDataStreamer: ) self.logger = logging.getLogger(__name__) + def load_configuration(self): + """Load PLC and UDP configuration from JSON file""" + try: + if os.path.exists(self.config_file): + with open(self.config_file, "r") as f: + config = json.load(f) + self.plc_config = config.get("plc_config", self.plc_config) + self.udp_config = config.get("udp_config", self.udp_config) + self.sampling_interval = config.get( + "sampling_interval", self.sampling_interval + ) + self.logger.info(f"Configuration loaded from {self.config_file}") + else: + self.logger.info("No configuration file found, using defaults") + except Exception as e: + self.logger.error(f"Error loading configuration: {e}") + + def save_configuration(self): + """Save PLC and UDP configuration to JSON file""" + try: + config = { + "plc_config": self.plc_config, + "udp_config": self.udp_config, + "sampling_interval": self.sampling_interval, + } + with open(self.config_file, "w") as f: + json.dump(config, f, indent=4) + self.logger.info(f"Configuration saved to {self.config_file}") + except Exception as e: + self.logger.error(f"Error saving configuration: {e}") + + def load_variables(self): + """Load variables configuration from JSON file""" + try: + if os.path.exists(self.variables_file): + with open(self.variables_file, "r") as f: + self.variables = json.load(f) + self.logger.info( + f"Variables loaded from {self.variables_file}: {len(self.variables)} variables" + ) + else: + self.logger.info( + "No variables file found, starting with empty variables" + ) + except Exception as e: + self.logger.error(f"Error loading variables: {e}") + + def save_variables(self): + """Save variables configuration to JSON file""" + try: + with open(self.variables_file, "w") as f: + json.dump(self.variables, f, indent=4) + self.logger.info(f"Variables saved to {self.variables_file}") + except Exception as e: + self.logger.error(f"Error saving variables: {e}") + def update_plc_config(self, ip: str, rack: int, slot: int): - """Actualiza la configuración del PLC""" + """Update PLC configuration""" self.plc_config = {"ip": ip, "rack": rack, "slot": slot} - self.logger.info(f"Configuración PLC actualizada: {self.plc_config}") + self.save_configuration() + self.logger.info(f"PLC configuration updated: {self.plc_config}") def update_udp_config(self, host: str, port: int): - """Actualiza la configuración UDP""" + """Update UDP configuration""" self.udp_config = {"host": host, "port": port} - self.logger.info(f"Configuración UDP actualizada: {self.udp_config}") + self.save_configuration() + self.logger.info(f"UDP configuration updated: {self.udp_config}") + + def update_sampling_interval(self, interval: float): + """Update sampling interval""" + self.sampling_interval = interval + self.save_configuration() + self.logger.info(f"Sampling interval updated: {interval}s") def add_variable(self, name: str, db: int, offset: int, var_type: str): - """Añade una variable para polling""" + """Add a variable for polling""" self.variables[name] = {"db": db, "offset": offset, "type": var_type} - self.logger.info(f"Variable añadida: {name} -> DB{db}.{offset} ({var_type})") + self.save_variables() + self.logger.info(f"Variable added: {name} -> DB{db}.{offset} ({var_type})") def remove_variable(self, name: str): - """Elimina una variable del polling""" + """Remove a variable from polling""" if name in self.variables: del self.variables[name] - self.logger.info(f"Variable eliminada: {name}") + self.save_variables() + self.logger.info(f"Variable removed: {name}") def connect_plc(self) -> bool: - """Conecta al PLC S7-315""" + """Connect to S7-315 PLC""" try: if self.plc: self.plc.disconnect() @@ -78,26 +151,26 @@ class PLCDataStreamer: ) self.connected = True - self.logger.info(f"Conectado al PLC {self.plc_config['ip']}") + self.logger.info(f"Connected to PLC {self.plc_config['ip']}") return True except Exception as e: self.connected = False - self.logger.error(f"Error conectando al PLC: {e}") + self.logger.error(f"Error connecting to PLC: {e}") return False def disconnect_plc(self): - """Desconecta del PLC""" + """Disconnect from PLC""" try: if self.plc: self.plc.disconnect() self.connected = False - self.logger.info("Desconectado del PLC") + self.logger.info("Disconnected from PLC") except Exception as e: - self.logger.error(f"Error desconectando del PLC: {e}") + self.logger.error(f"Error disconnecting from PLC: {e}") def read_variable(self, var_config: Dict[str, Any]) -> Any: - """Lee una variable específica del PLC""" + """Read a specific variable from the PLC""" try: db = var_config["db"] offset = var_config["offset"] @@ -121,11 +194,11 @@ class PLCDataStreamer: return value except Exception as e: - self.logger.error(f"Error leyendo variable: {e}") + self.logger.error(f"Error reading variable: {e}") return None def read_all_variables(self) -> Dict[str, Any]: - """Lee todas las variables configuradas""" + """Read all configured variables""" if not self.connected or not self.plc: return {} @@ -138,23 +211,23 @@ class PLCDataStreamer: return data def setup_udp_socket(self) -> bool: - """Configura el socket UDP""" + """Setup UDP socket""" try: if self.udp_socket: self.udp_socket.close() self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.logger.info( - f"Socket UDP configurado para {self.udp_config['host']}:{self.udp_config['port']}" + f"UDP socket configured for {self.udp_config['host']}:{self.udp_config['port']}" ) return True except Exception as e: - self.logger.error(f"Error configurando socket UDP: {e}") + self.logger.error(f"Error configuring UDP socket: {e}") return False def send_to_plotjuggler(self, data: Dict[str, Any]): - """Envía datos a PlotJuggler vía UDP JSON""" + """Send data to PlotJuggler via UDP JSON""" if not self.udp_socket: return @@ -168,46 +241,46 @@ class PLCDataStreamer: ) except Exception as e: - self.logger.error(f"Error enviando datos a PlotJuggler: {e}") + self.logger.error(f"Error sending data to PlotJuggler: {e}") def streaming_loop(self): - """Bucle principal de streaming""" + """Main streaming loop""" self.logger.info( - f"Iniciando streaming con intervalo de {self.sampling_interval}s" + f"Starting streaming with interval of {self.sampling_interval}s" ) while self.streaming: try: start_time = time.time() - # Leer todas las variables + # Read all variables data = self.read_all_variables() if data: - # Enviar a PlotJuggler + # Send to PlotJuggler self.send_to_plotjuggler(data) - # Log de datos + # Log data timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] self.logger.info(f"[{timestamp}] {data}") - # Mantener intervalo de muestreo + # Maintain sampling interval elapsed = time.time() - start_time sleep_time = max(0, self.sampling_interval - elapsed) time.sleep(sleep_time) except Exception as e: - self.logger.error(f"Error en streaming loop: {e}") + self.logger.error(f"Error in streaming loop: {e}") break def start_streaming(self) -> bool: - """Inicia el streaming de datos""" + """Start data streaming""" if not self.connected: - self.logger.error("PLC no conectado") + self.logger.error("PLC not connected") return False if not self.variables: - self.logger.error("No hay variables configuradas") + self.logger.error("No variables configured") return False if not self.setup_udp_socket(): @@ -218,11 +291,11 @@ class PLCDataStreamer: self.stream_thread.daemon = True self.stream_thread.start() - self.logger.info("Streaming iniciado") + self.logger.info("Streaming started") return True def stop_streaming(self): - """Detiene el streaming""" + """Stop streaming""" self.streaming = False if self.stream_thread: self.stream_thread.join(timeout=2) @@ -231,10 +304,10 @@ class PLCDataStreamer: self.udp_socket.close() self.udp_socket = None - self.logger.info("Streaming detenido") + self.logger.info("Streaming stopped") def get_status(self) -> Dict[str, Any]: - """Obtiene el estado actual del sistema""" + """Get current system status""" return { "plc_connected": self.connected, "streaming": self.streaming, @@ -245,13 +318,13 @@ class PLCDataStreamer: } -# Instancia global del streamer +# Global streamer instance streamer = PLCDataStreamer() @app.route("/") def index(): - """Página principal""" + """Main page""" return render_template( "index.html", status=streamer.get_status(), variables=streamer.variables ) @@ -259,15 +332,15 @@ def index(): @app.route("/api/plc/config", methods=["POST"]) def update_plc_config(): - """Actualiza la configuración del PLC""" + """Update PLC configuration""" try: data = request.get_json() - ip = data.get("ip", "192.168.1.100") + 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": "Configuración PLC actualizada"}) + return jsonify({"success": True, "message": "PLC configuration updated"}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @@ -275,14 +348,14 @@ def update_plc_config(): @app.route("/api/udp/config", methods=["POST"]) def update_udp_config(): - """Actualiza la configuración UDP""" + """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": "Configuración UDP actualizada"}) + return jsonify({"success": True, "message": "UDP configuration updated"}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @@ -290,24 +363,24 @@ def update_udp_config(): @app.route("/api/plc/connect", methods=["POST"]) def connect_plc(): - """Conecta al PLC""" + """Connect to PLC""" if streamer.connect_plc(): - return jsonify({"success": True, "message": "Conectado al PLC"}) + return jsonify({"success": True, "message": "Connected to PLC"}) else: - return jsonify({"success": False, "message": "Error conectando al PLC"}), 500 + return jsonify({"success": False, "message": "Error connecting to PLC"}), 500 @app.route("/api/plc/disconnect", methods=["POST"]) def disconnect_plc(): - """Desconecta del PLC""" + """Disconnect from PLC""" streamer.stop_streaming() streamer.disconnect_plc() - return jsonify({"success": True, "message": "Desconectado del PLC"}) + return jsonify({"success": True, "message": "Disconnected from PLC"}) @app.route("/api/variables", methods=["POST"]) def add_variable(): - """Añade una nueva variable""" + """Add a new variable""" try: data = request.get_json() name = data.get("name") @@ -316,10 +389,10 @@ def add_variable(): var_type = data.get("type") if not name or var_type not in ["real", "int", "bool", "dint"]: - return jsonify({"success": False, "message": "Datos inválidos"}), 400 + return jsonify({"success": False, "message": "Invalid data"}), 400 streamer.add_variable(name, db, offset, var_type) - return jsonify({"success": True, "message": f"Variable {name} añadida"}) + return jsonify({"success": True, "message": f"Variable {name} added"}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 @@ -327,30 +400,30 @@ def add_variable(): @app.route("/api/variables/", methods=["DELETE"]) def remove_variable(name): - """Elimina una variable""" + """Remove a variable""" streamer.remove_variable(name) - return jsonify({"success": True, "message": f"Variable {name} eliminada"}) + return jsonify({"success": True, "message": f"Variable {name} removed"}) @app.route("/api/streaming/start", methods=["POST"]) def start_streaming(): - """Inicia el streaming""" + """Start streaming""" if streamer.start_streaming(): - return jsonify({"success": True, "message": "Streaming iniciado"}) + return jsonify({"success": True, "message": "Streaming started"}) else: - return jsonify({"success": False, "message": "Error iniciando streaming"}), 500 + return jsonify({"success": False, "message": "Error starting streaming"}), 500 @app.route("/api/streaming/stop", methods=["POST"]) def stop_streaming(): - """Detiene el streaming""" + """Stop streaming""" streamer.stop_streaming() - return jsonify({"success": True, "message": "Streaming detenido"}) + return jsonify({"success": True, "message": "Streaming stopped"}) @app.route("/api/sampling", methods=["POST"]) def update_sampling(): - """Actualiza el intervalo de muestreo""" + """Update sampling interval""" try: data = request.get_json() interval = float(data.get("interval", 0.1)) @@ -358,10 +431,8 @@ def update_sampling(): if interval < 0.01: interval = 0.01 - streamer.sampling_interval = interval - return jsonify( - {"success": True, "message": f"Intervalo actualizado a {interval}s"} - ) + 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 @@ -369,21 +440,21 @@ def update_sampling(): @app.route("/api/status") def get_status(): - """Obtiene el estado actual""" + """Get current status""" return jsonify(streamer.get_status()) if __name__ == "__main__": - # Crear directorio de templates si no existe + # Create templates directory if it doesn't exist os.makedirs("templates", exist_ok=True) - print("🚀 Iniciando servidor Flask para PLC S7-315 Streamer") - print("📊 Interfaz web disponible en: http://localhost:5050") - print("🔧 Configure su PLC y variables a través de la interfaz web") + 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") try: app.run(debug=True, host="0.0.0.0", port=5050) except KeyboardInterrupt: - print("\n⏹️ Deteniendo servidor...") + print("\n⏹️ Stopping server...") streamer.stop_streaming() streamer.disconnect_plc() diff --git a/templates/index.html b/templates/index.html index bae6e14..402b825 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,5 +1,5 @@ - + @@ -14,9 +14,9 @@ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); min-height: 100vh; - color: #333; + color: #1f2937; } .container { @@ -60,23 +60,23 @@ } .status-connected { - background: linear-gradient(135deg, #4CAF50, #45a049); + background: linear-gradient(135deg, #6b7280, #4b5563); color: white; } .status-disconnected { - background: linear-gradient(135deg, #f44336, #d32f2f); + background: linear-gradient(135deg, #9ca3af, #6b7280); color: white; } .status-streaming { - background: linear-gradient(135deg, #2196F3, #1976D2); + background: linear-gradient(135deg, #374151, #1f2937); color: white; } .status-idle { - background: linear-gradient(135deg, #9E9E9E, #757575); - color: white; + background: linear-gradient(135deg, #d1d5db, #9ca3af); + color: #374151; } .card { @@ -89,10 +89,10 @@ } .card h2 { - color: #667eea; + color: #4b5563; margin-bottom: 20px; font-size: 1.5rem; - border-bottom: 2px solid #667eea; + border-bottom: 2px solid #6b7280; padding-bottom: 10px; } @@ -104,14 +104,14 @@ display: block; margin-bottom: 5px; font-weight: bold; - color: #555; + color: #374151; } .form-group input, .form-group select { width: 100%; padding: 10px; - border: 2px solid #ddd; + border: 2px solid #d1d5db; border-radius: 8px; font-size: 14px; transition: border-color 0.3s; @@ -120,7 +120,7 @@ .form-group input:focus, .form-group select:focus { outline: none; - border-color: #667eea; + border-color: #6b7280; } .form-row { @@ -130,7 +130,7 @@ } .btn { - background: linear-gradient(135deg, #667eea, #764ba2); + background: linear-gradient(135deg, #6b7280, #4b5563); color: white; border: none; padding: 12px 25px; @@ -145,18 +145,33 @@ .btn:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); + background: linear-gradient(135deg, #374151, #1f2937); } .btn-success { - background: linear-gradient(135deg, #4CAF50, #45a049); + background: linear-gradient(135deg, #6b7280, #374151); + } + + .btn-success:hover { + background: linear-gradient(135deg, #4b5563, #1f2937); } .btn-danger { - background: linear-gradient(135deg, #f44336, #d32f2f); + background: linear-gradient(135deg, #9ca3af, #6b7280); + } + + .btn-danger:hover { + background: linear-gradient(135deg, #6b7280, #4b5563); } .btn-warning { - background: linear-gradient(135deg, #ff9800, #f57c00); + background: linear-gradient(135deg, #d1d5db, #9ca3af); + color: #374151; + } + + .btn-warning:hover { + background: linear-gradient(135deg, #9ca3af, #6b7280); + color: white; } .variables-table { @@ -169,17 +184,17 @@ .variables-table td { padding: 12px; text-align: left; - border-bottom: 1px solid #ddd; + border-bottom: 1px solid #e5e7eb; } .variables-table th { - background: linear-gradient(135deg, #667eea, #764ba2); + background: linear-gradient(135deg, #6b7280, #4b5563); color: white; font-weight: bold; } .variables-table tr:hover { - background-color: #f5f5f5; + background-color: #f9fafb; } .alert { @@ -190,15 +205,15 @@ } .alert-success { - background-color: #d4edda; - border: 1px solid #c3e6cb; - color: #155724; + background-color: #f3f4f6; + border: 1px solid #d1d5db; + color: #374151; } .alert-error { - background-color: #f8d7da; - border: 1px solid #f5c6cb; - color: #721c24; + background-color: #fef2f2; + border: 1px solid #fecaca; + color: #7f1d1d; } .controls { @@ -228,37 +243,37 @@

🏭 PLC S7-315 Streamer & Logger

-

Sistema de monitoreo y streaming en tiempo real

+

Real-time monitoring and streaming system

- +
- 🔌 PLC: Desconectado + 🔌 PLC: Disconnected
- 📡 Streaming: Inactivo + 📡 Streaming: Inactive
📊 Variables: {{ status.variables_count }}
- ⏱️ Intervalo: {{ status.sampling_interval }}s + ⏱️ Interval: {{ status.sampling_interval }}s
- +
- +
-

⚙️ Configuración PLC S7-315

+

⚙️ PLC S7-315 Configuration

- +
@@ -271,50 +286,49 @@
- - - + + +
- +
-

🌐 Configuración Gateway UDP (PlotJuggler)

+

🌐 UDP Gateway Configuration (PlotJuggler)

- +
- +
- +
- - + +
- +
-

📋 Variables del PLC

+

📋 PLC Variables

- +
- - + +
@@ -325,7 +339,7 @@
- +
- +
- + - + - - + + @@ -356,7 +370,7 @@ {% endfor %} @@ -364,19 +378,19 @@
NombreName Data Block OffsetTipoAccionesTypeActions
{{ var.offset }} {{ var.type.upper() }} - +
- +
-

🚀 Control de Streaming

+

🚀 Streaming Control

- - - + + +