diff --git a/.doc/MemoriaDeEvolucion.md b/.doc/MemoriaDeEvolucion.md index fcea877..54f58a2 100644 --- a/.doc/MemoriaDeEvolucion.md +++ b/.doc/MemoriaDeEvolucion.md @@ -5,7 +5,7 @@ ## Functional Description of the Application This application is a web server (created with the Flask framework in Python) that acts as an intermediary to monitor and record data in CSV format from a PLC Siemens S7 with the SNAP7 library to be used on a low-resource PC connected to the PLC. -It must be as simple as possible to allow the pack using PyInstaller +It must be as simple as possible to allow the pack using PyInstaller and must be capable of running completelly offline from internet. #### Its key functions are: @@ -30,6 +30,88 @@ The application is designed to be persistent across restarts, restoring the prev ### Latest Modifications (Current Session) +#### Real-Time Plotting System Implementation +**Decision**: Implementar un sistema completo de plotting en tiempo real con trigger de variables boolean y uso exclusivo del cache del recording. + +**Rationale**: Los usuarios necesitaban visualización en tiempo real de variables del PLC sin agregar carga adicional al sistema. El sistema de plotting debe ser independiente del UDP streaming existente y usar solo los datos del cache para mantener la eficiencia. + +**Implementation**: + +**Backend Architecture** (`core/plot_manager.py`): +- **PlotSession Class**: Maneja sesiones individuales de plotting con configuración específica +- **PlotManager Class**: Gestiona todas las sesiones con thread safety +- **Trigger System**: Variables boolean que reinician automáticamente el trace +- **Cache Integration**: Usa exclusivamente datos del cache del recording (sin carga PLC adicional) + +**Key Features**: +- **Boolean Trigger**: Variables bool pueden reiniciar traces automáticamente +- **Multiple Sessions**: Múltiples plot sessions independientes simultáneas +- **Performance Optimized**: Solo usa cache del recording, no lecturas PLC adicionales +- **Flexible Configuration**: Ventana de tiempo, rango Y, trigger configurable + +**Frontend Implementation** (`static/js/plotting.js`): +- **Chart.js Integration**: Gráficos modernos y responsivos +- **Auto-update System**: Actualización automática cada 500ms +- **Modal Interface**: Creación intuitiva de nuevas sesiones +- **Real-time Controls**: Start/Stop/Pause/Clear individual por sesión + +**API Endpoints** (`main.py`): +- **GET /api/plots**: Estado de todas las sesiones +- **POST /api/plots**: Crear nueva sesión +- **DELETE /api/plots/**: Eliminar sesión +- **POST /api/plots//control**: Control de sesión (start/stop/pause/clear) +- **GET /api/plots//data**: Datos para Chart.js +- **GET /api/plots/variables**: Variables disponibles (solo datasets activos) + +**Tab System Integration**: +- **New Tab Interface**: Sistema de tabs para organizar funcionalidades +- **Plotting Tab**: Dedicado al sistema de plotting +- **Events Tab**: Para logs y eventos del sistema +- **Responsive Design**: Adaptable a diferentes tamaños de pantalla + +**Technical Benefits**: +- **Zero PLC Load**: No agrega lecturas adicionales al PLC +- **Cache Efficiency**: Reutiliza datos del sistema de recording +- **Thread Safety**: Operaciones concurrentes seguras +- **Memory Management**: Deques con tamaño limitado para evitar memory leaks + +**User Experience**: +- **Intuitive Interface**: Creación fácil de plots con modal +- **Real-time Feedback**: Estadísticas en tiempo real de cada sesión +- **Visual Controls**: Botones claros para controlar sesiones +- **Error Handling**: Manejo robusto de errores y feedback al usuario + +**Trigger System Details**: +- **Boolean Variables Only**: Solo variables de tipo BOOL pueden ser triggers +- **Configurable Logic**: Trigger en True o False según configuración +- **Automatic Restart**: Limpia datos y reinicia trace cuando se activa +- **State Tracking**: Mantiene estado del trigger para detectar cambios + +**Integration with Existing System**: +- **DataStreamer Enhancement**: Integración automática con el cache existente +- **No Breaking Changes**: Compatible con sistema existente +- **Performance Neutral**: No afecta rendimiento del sistema principal +- **Event Logging**: Logs detallados de operaciones de plotting + +**Configuration Options**: +- **Time Window**: 10-3600 segundos configurable +- **Y-Axis Range**: Automático o manual (min/max) +- **Variable Selection**: Solo variables de datasets activos +- **Trigger Configuration**: Variable bool + lógica (True/False) + +**Industrial Benefits**: +- **Process Monitoring**: Visualización en tiempo real de variables críticas +- **Trigger Analysis**: Análisis de eventos específicos con reinicio automático +- **Multiple Views**: Diferentes perspectivas del mismo proceso +- **Historical Context**: Mantiene contexto temporal con ventanas configurables + +**Dependencies Added**: +- **Chart.js**: Librería de gráficos (CDN) +- **Flask-SocketIO**: Para futuras mejoras con WebSockets +- **Date-fns**: Para manejo de fechas en Chart.js + +Esta implementación proporciona una herramienta poderosa para monitoreo en tiempo real sin comprometer el rendimiento del sistema, manteniendo la arquitectura existente y agregando funcionalidad significativa para análisis de procesos industriales. + #### Critical Fix: CSV Recording vs UDP Streaming Separation and Thread Join Error Resolution **Issue**: El sistema tenía un error crítico `RuntimeError: cannot join current thread` al detener streaming, y había confusión entre CSV recording (que debe ser automático) y UDP streaming (que debe ser manual). Al detener streaming UDP se detenía también el recording CSV, violando el diseño del sistema. diff --git a/PLOTTING_SYSTEM.md b/PLOTTING_SYSTEM.md new file mode 100644 index 0000000..6348492 --- /dev/null +++ b/PLOTTING_SYSTEM.md @@ -0,0 +1,205 @@ +# 📈 Sistema de Plotting en Tiempo Real + +## Descripción General + +El sistema de plotting implementado permite crear gráficos interactivos en tiempo real usando los datos del cache de recording del sistema. Esto significa que **no agrega carga adicional al PLC** ya que utiliza los mismos datos que se están grabando automáticamente en CSV. + +## Características Principales + +### 🎯 Sistema de Trigger +- **Variables Boolean**: Usa variables boolean como trigger para reiniciar automáticamente el trace +- **Configuración Flexible**: Puede trigger en True o False según la configuración +- **Reinicio Automático**: Cuando se activa el trigger, el gráfico se limpia y comienza un nuevo trace + +### ⚡ Performance Optimizada +- **Cache del Recording**: Utiliza exclusivamente los datos del cache del sistema de recording +- **Sin Carga PLC**: No realiza lecturas adicionales al PLC +- **Actualización Eficiente**: Actualización automática cada 500ms para plots activos + +### 📊 Múltiples Sesiones +- **Sesiones Independientes**: Puede crear múltiples plot sessions simultáneamente +- **Configuración Individual**: Cada sesión tiene su propia configuración de variables, tiempo y trigger +- **Control Granular**: Start/Stop/Pause/Clear individual para cada sesión + +### 🎨 Interfaz Intuitiva +- **Chart.js**: Gráficos modernos y responsivos +- **Controles Visuales**: Botones claros para controlar cada sesión +- **Información en Tiempo Real**: Muestra estadísticas de cada sesión + +## Arquitectura del Sistema + +### Backend (Python) + +#### `core/plot_manager.py` +- **PlotSession**: Clase que maneja una sesión individual de plotting +- **PlotManager**: Clase principal que gestiona todas las sesiones +- **Integración con DataStreamer**: Se actualiza automáticamente con los datos del cache + +#### Características Técnicas: +- **Thread Safety**: Uso de locks para operaciones concurrentes +- **Memory Management**: Deques con tamaño limitado para evitar memory leaks +- **Error Handling**: Manejo robusto de errores y logging + +### Frontend (JavaScript) + +#### `static/js/plotting.js` +- **PlotManager Class**: Maneja la interfaz de usuario y comunicación con el backend +- **Chart.js Integration**: Configuración optimizada para datos en tiempo real +- **Modal System**: Interfaz para crear nuevas sesiones de plotting + +#### Características Técnicas: +- **Auto-update**: Actualización automática cada 500ms +- **Responsive Design**: Adaptable a diferentes tamaños de pantalla +- **Error Recovery**: Manejo de errores de red y reconexión automática + +## API Endpoints + +### GET `/api/plots` +Obtiene el estado de todas las sesiones de plotting activas. + +### POST `/api/plots` +Crea una nueva sesión de plotting. + +**Parámetros:** +```json +{ + "name": "Temperature Monitoring", + "variables": ["temp1", "temp2", "pressure"], + "time_window": 60, + "y_min": 0, + "y_max": 100, + "trigger_enabled": true, + "trigger_variable": "start_cycle", + "trigger_on_true": true +} +``` + +### DELETE `/api/plots/` +Elimina una sesión de plotting específica. + +### POST `/api/plots//control` +Controla una sesión de plotting. + +**Parámetros:** +```json +{ + "action": "start|stop|pause|resume|clear" +} +``` + +### GET `/api/plots//data` +Obtiene los datos de una sesión específica para Chart.js. + +### GET `/api/plots/variables` +Obtiene las variables disponibles para plotting (solo de datasets activos). + +## Configuración de Sesiones + +### Parámetros Básicos +- **name**: Nombre descriptivo de la sesión +- **variables**: Lista de variables a graficar (solo de datasets activos) +- **time_window**: Ventana de tiempo en segundos (10-3600) + +### Configuración de Eje Y +- **y_min**: Valor mínimo del eje Y (opcional, automático si no se especifica) +- **y_max**: Valor máximo del eje Y (opcional, automático si no se especifica) + +### Sistema de Trigger +- **trigger_enabled**: Habilita/deshabilita el sistema de trigger +- **trigger_variable**: Variable boolean a usar como trigger +- **trigger_on_true**: Si es true, trigger en True; si es false, trigger en False + +## Flujo de Datos + +1. **DataStreamer** lee variables del PLC y actualiza el cache +2. **PlotManager** detecta sesiones activas y actualiza sus datos +3. **Frontend** solicita datos cada 500ms y actualiza Chart.js +4. **Trigger System** verifica cambios en variables boolean y reinicia traces + +## Variables Disponibles + +### Para Plotting +- Solo variables de **datasets activos** +- Todos los tipos de datos soportados (REAL, INT, BOOL, etc.) + +### Para Trigger +- Solo variables de tipo **BOOL** +- De datasets activos únicamente + +## Control de Sesiones + +### Estados de Sesión +- **Active**: Sesión activa y recibiendo datos +- **Paused**: Sesión pausada (no recibe nuevos datos) +- **Stopped**: Sesión detenida (no visible en interfaz) + +### Acciones Disponibles +- **Start**: Inicia o reanuda la sesión +- **Pause**: Pausa la sesión (mantiene datos) +- **Clear**: Limpia todos los datos (reinicia trace) +- **Stop**: Detiene la sesión completamente +- **Remove**: Elimina la sesión + +## Integración con el Sistema Existente + +### Compatibilidad +- **Sin Cambios**: No requiere modificaciones al sistema de recording existente +- **Cache Sharing**: Utiliza el mismo cache que CSV recording y UDP streaming +- **Performance**: No afecta el rendimiento del sistema principal + +### Dependencias +- **Chart.js**: Librería de gráficos (CDN) +- **Flask-SocketIO**: Para futuras mejoras con WebSockets +- **Date-fns**: Para manejo de fechas en Chart.js + +## Limitaciones y Consideraciones + +### Limitaciones Actuales +- **Variables Activas**: Solo variables de datasets activos +- **Tiempo Real**: Actualización cada 500ms (no instantánea) +- **Memoria**: Máximo 10 puntos por segundo por variable + +### Consideraciones de Performance +- **Múltiples Sesiones**: Cada sesión consume memoria adicional +- **Variables Boolean**: Limitadas a variables de tipo BOOL +- **Cache Dependencia**: Requiere que el recording esté activo + +## Futuras Mejoras + +### WebSockets +- Actualización en tiempo real sin polling +- Mejor performance y menor latencia + +### Configuración Avanzada +- Múltiples triggers por sesión +- Filtros de datos más sofisticados +- Exportación de datos de gráficos + +### Interfaz Mejorada +- Zoom y pan en gráficos +- Múltiples escalas Y +- Templates de configuración + +## Troubleshooting + +### Problemas Comunes + +#### No se ven datos en el gráfico +1. Verificar que el PLC esté conectado +2. Verificar que los datasets estén activos +3. Verificar que las variables existan en datasets activos + +#### Error al crear sesión +1. Verificar que las variables seleccionadas existan +2. Verificar que el trigger variable sea de tipo BOOL +3. Verificar que el time window esté entre 10-3600 segundos + +#### Gráfico no se actualiza +1. Verificar que la sesión esté activa (no pausada) +2. Verificar conexión de red +3. Verificar que el recording esté funcionando + +### Logs y Debugging +- Los logs del sistema incluyen información de plotting +- Console del navegador muestra errores de JavaScript +- Network tab muestra requests a la API \ No newline at end of file diff --git a/application_events.json b/application_events.json index 7119b1b..51b1ddc 100644 --- a/application_events.json +++ b/application_events.json @@ -3084,8 +3084,197 @@ "udp_port": 9870, "datasets_available": 1 } + }, + { + "timestamp": "2025-07-20T23:49:17.370512", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-20T23:49:17.434505", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: DAR", + "details": { + "dataset_id": "dar", + "variables_count": 6, + "streaming_count": 4, + "prefix": "dar" + } + }, + { + "timestamp": "2025-07-20T23:49:17.439505", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 1 datasets activated", + "details": { + "activated_datasets": 1, + "total_datasets": 2 + } + }, + { + "timestamp": "2025-07-20T23:49:17.449529", + "level": "info", + "event_type": "udp_streaming_started", + "message": "UDP streaming to PlotJuggler started", + "details": { + "udp_host": "127.0.0.1", + "udp_port": 9870, + "datasets_available": 1 + } + }, + { + "timestamp": "2025-07-20T23:50:20.235616", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'Test' created", + "details": { + "session_id": "plot_0", + "variables": [ + "UR29_Brix" + ], + "time_window": 60, + "trigger_variable": null + } + }, + { + "timestamp": "2025-07-20T23:51:02.883495", + "level": "info", + "event_type": "plot_session_removed", + "message": "Plot session 'Test' removed", + "details": { + "session_id": "plot_0" + } + }, + { + "timestamp": "2025-07-20T23:51:16.267327", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'test' created", + "details": { + "session_id": "plot_1", + "variables": [ + "UR29_Brix", + "UR29_Brix_Digital", + "UR62_Brix" + ], + "time_window": 60, + "trigger_variable": null + } + }, + { + "timestamp": "2025-07-20T23:52:19.663479", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'test' created", + "details": { + "session_id": "plot_2", + "variables": [ + "UR29_Brix", + "UR62_Brix" + ], + "time_window": 60, + "trigger_variable": null + } + }, + { + "timestamp": "2025-07-21T09:21:24.720368", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-21T09:21:37.488725", + "level": "info", + "event_type": "config_change", + "message": "PLC configuration updated: 10.1.33.249:0/2", + "details": { + "old_config": { + "ip": "10.1.33.249", + "rack": 0, + "slot": 2 + }, + "new_config": { + "ip": "10.1.33.249", + "rack": 0, + "slot": 2 + } + } + }, + { + "timestamp": "2025-07-21T09:21:43.453084", + "level": "info", + "event_type": "config_change", + "message": "PLC configuration updated: 10.1.33.11:0/2", + "details": { + "old_config": { + "ip": "10.1.33.249", + "rack": 0, + "slot": 2 + }, + "new_config": { + "ip": "10.1.33.11", + "rack": 0, + "slot": 2 + } + } + }, + { + "timestamp": "2025-07-21T09:21:45.846722", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: DAR", + "details": { + "dataset_id": "dar", + "variables_count": 6, + "streaming_count": 4, + "prefix": "dar" + } + }, + { + "timestamp": "2025-07-21T09:21:45.854021", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 1 datasets activated", + "details": { + "activated_datasets": 1, + "total_datasets": 2 + } + }, + { + "timestamp": "2025-07-21T09:21:45.857409", + "level": "info", + "event_type": "plc_connection", + "message": "Successfully connected to PLC 10.1.33.11 and auto-started CSV recording for 1 datasets", + "details": { + "ip": "10.1.33.11", + "rack": 0, + "slot": 2, + "auto_started_recording": true, + "recording_datasets": 1, + "dataset_names": [ + "DAR" + ] + } + }, + { + "timestamp": "2025-07-21T09:25:26.290146", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'Cond' created", + "details": { + "session_id": "plot_0", + "variables": [ + "CTS306_PV" + ], + "time_window": 10, + "trigger_variable": null + } } ], - "last_updated": "2025-07-20T23:25:10.215131", - "total_entries": 294 + "last_updated": "2025-07-21T09:25:26.290146", + "total_entries": 309 } \ No newline at end of file diff --git a/core/plot_manager.py b/core/plot_manager.py new file mode 100644 index 0000000..01f4de2 --- /dev/null +++ b/core/plot_manager.py @@ -0,0 +1,345 @@ +import threading +import time +import json +from collections import deque +from datetime import datetime, timedelta +from typing import Dict, Any, Optional, List, Set +import logging + + +class PlotSession: + """Representa una sesión de plotting individual con configuración específica""" + + def __init__(self, session_id: str, config: Dict[str, Any]): + self.session_id = session_id + self.name = config.get("name", f"Plot {session_id}") + self.variables = config.get("variables", []) # Lista de nombres de variables + self.time_window = config.get( + "time_window", 60 + ) # Ventana de tiempo en segundos + self.y_min = config.get("y_min") # Mínimo Y (None = automático) + self.y_max = config.get("y_max") # Máximo Y (None = automático) + + # Configuración de trigger + self.trigger_variable = config.get( + "trigger_variable" + ) # Variable bool para trigger + self.trigger_on_true = config.get( + "trigger_on_true", True + ) # Trigger en True o False + self.trigger_enabled = config.get( + "trigger_enabled", False + ) # Si está habilitado el trigger + + # Estado de la sesión + self.is_active = True + self.is_paused = False + self.last_trigger_state = None # Último estado del trigger + + # Datos de la sesión + self.data = {} # variable_name -> deque of (timestamp, value) + self.session_start_time = time.time() + self.last_data_update = None + + # Inicializar deques para cada variable (máximo 10 muestras por segundo) + max_points = int(self.time_window * 10) + for var in self.variables: + self.data[var] = deque(maxlen=max_points) + + # Colores para las variables + self.colors = [ + "#FF6384", + "#36A2EB", + "#FFCE56", + "#4BC0C0", + "#9966FF", + "#FF9F40", + "#FF6384", + "#C9CBCF", + "#4BC0C0", + "#FF6384", + ] + + def add_data_point(self, variable: str, timestamp: float, value: Any) -> bool: + """Agregar un punto de datos a la sesión""" + if not self.is_active or self.is_paused or variable not in self.data: + return False + + self.data[variable].append((timestamp, value)) + self.last_data_update = timestamp + return True + + def check_trigger(self, trigger_value: bool) -> bool: + """Verificar si debe reiniciar el trace basado en el trigger""" + if not self.trigger_enabled or self.trigger_variable is None: + return False + + # Detectar cambio de estado + if self.last_trigger_state is None: + self.last_trigger_state = trigger_value + return False + + # Verificar si el cambio coincide con la configuración + if self.last_trigger_state != trigger_value and ( + (self.trigger_on_true and trigger_value) + or (not self.trigger_on_true and not trigger_value) + ): + + self.last_trigger_state = trigger_value + return True + + self.last_trigger_state = trigger_value + return False + + def clear_data(self): + """Limpiar todos los datos de la sesión (reiniciar trace)""" + for var in self.data: + self.data[var].clear() + self.session_start_time = time.time() + + def get_plot_data(self) -> Dict[str, Any]: + """Obtener datos formateados para Chart.js""" + datasets = [] + + for i, variable in enumerate(self.variables): + if variable in self.data and self.data[variable]: + # Convertir datos a formato Chart.js + data_points = [] + for timestamp, value in self.data[variable]: + data_points.append( + { + "x": timestamp * 1000, # Chart.js espera milisegundos + "y": value, + } + ) + + color_index = i % len(self.colors) + datasets.append( + { + "label": variable, + "data": data_points, + "borderColor": self.colors[color_index], + "backgroundColor": self.colors[color_index] + + "20", # 20 = 12% opacity + "fill": False, + "tension": 0.1, + } + ) + + # Calcular Y min/max automático si no están definidos + y_min = self.y_min + y_max = self.y_max + + if y_min is None or y_max is None: + all_values = [] + for var_data in self.data.values(): + all_values.extend( + [point[1] for point in var_data if point[1] is not None] + ) + + if all_values: + if y_min is None: + y_min = min(all_values) - (max(all_values) - min(all_values)) * 0.1 + if y_max is None: + y_max = max(all_values) + (max(all_values) - min(all_values)) * 0.1 + + return { + "session_id": self.session_id, + "name": self.name, + "datasets": datasets, + "y_min": y_min, + "y_max": y_max, + "time_window": self.time_window, + "is_active": self.is_active, + "is_paused": self.is_paused, + "trigger_variable": self.trigger_variable, + "trigger_enabled": self.trigger_enabled, + "trigger_on_true": self.trigger_on_true, + "last_update": self.last_data_update, + "data_points_count": sum(len(data) for data in self.data.values()), + } + + def get_status(self) -> Dict[str, Any]: + """Obtener estado de la sesión""" + return { + "session_id": self.session_id, + "name": self.name, + "is_active": self.is_active, + "is_paused": self.is_paused, + "variables_count": len(self.variables), + "data_points_count": sum(len(data) for data in self.data.values()), + "trigger_variable": self.trigger_variable, + "trigger_enabled": self.trigger_enabled, + "last_update": self.last_data_update, + } + + +class PlotManager: + """Maneja todas las sesiones de plotting del sistema""" + + def __init__(self, event_logger=None, logger=None): + self.sessions: Dict[str, PlotSession] = {} + self.session_counter = 0 + self.lock = threading.Lock() + self.event_logger = event_logger + self.logger = logger + + def create_session(self, config: Dict[str, Any]) -> str: + """Crear una nueva sesión de plotting""" + with self.lock: + session_id = f"plot_{self.session_counter}" + self.session_counter += 1 + + session = PlotSession(session_id, config) + self.sessions[session_id] = session + + if self.logger: + self.logger.info( + f"Created plot session '{session.name}' with {len(session.variables)} variables" + ) + + if self.event_logger: + self.event_logger.log_event( + "info", + "plot_session_created", + f"Plot session '{session.name}' created", + { + "session_id": session_id, + "variables": session.variables, + "time_window": session.time_window, + "trigger_variable": session.trigger_variable, + }, + ) + + return session_id + + def remove_session(self, session_id: str) -> bool: + """Eliminar una sesión de plotting""" + with self.lock: + if session_id in self.sessions: + session = self.sessions[session_id] + session.is_active = False + + if self.logger: + self.logger.info(f"Removed plot session '{session.name}'") + + if self.event_logger: + self.event_logger.log_event( + "info", + "plot_session_removed", + f"Plot session '{session.name}' removed", + {"session_id": session_id}, + ) + + del self.sessions[session_id] + return True + return False + + def control_session(self, session_id: str, action: str) -> bool: + """Controlar una sesión (start/stop/pause/resume/clear)""" + with self.lock: + if session_id not in self.sessions: + return False + + session = self.sessions[session_id] + + if action == "start": + session.is_active = True + session.is_paused = False + if self.logger: + self.logger.info(f"Started plot session '{session.name}'") + + elif action == "stop": + session.is_active = False + if self.logger: + self.logger.info(f"Stopped plot session '{session.name}'") + + elif action == "pause": + session.is_paused = True + if self.logger: + self.logger.info(f"Paused plot session '{session.name}'") + + elif action == "resume": + session.is_paused = False + if self.logger: + self.logger.info(f"Resumed plot session '{session.name}'") + + elif action == "clear": + session.clear_data() + if self.logger: + self.logger.info(f"Cleared data for plot session '{session.name}'") + + return True + + def update_data(self, dataset_id: str, variables_data: Dict[str, Any]) -> None: + """Actualizar datos de todas las sesiones activas usando cache del recording""" + with self.lock: + for session in self.sessions.values(): + if not session.is_active: + continue + + # Verificar trigger primero + if session.trigger_enabled and session.trigger_variable: + if session.trigger_variable in variables_data: + trigger_value = variables_data[session.trigger_variable] + if isinstance(trigger_value, bool) and session.check_trigger( + trigger_value + ): + # Reiniciar trace + session.clear_data() + if self.logger: + self.logger.info( + f"Trigger activated for plot session '{session.name}' - trace restarted" + ) + + # Actualizar datos de variables + for variable in session.variables: + if variable in variables_data: + value = variables_data[variable] + timestamp = time.time() + session.add_data_point(variable, timestamp, value) + + def get_session_data(self, session_id: str) -> Optional[Dict[str, Any]]: + """Obtener datos de una sesión específica""" + with self.lock: + if session_id in self.sessions: + return self.sessions[session_id].get_plot_data() + return None + + def get_all_sessions_status(self) -> List[Dict[str, Any]]: + """Obtener estado de todas las sesiones""" + with self.lock: + return [session.get_status() for session in self.sessions.values()] + + def get_active_sessions_count(self) -> int: + """Obtener número de sesiones activas""" + with self.lock: + return sum(1 for session in self.sessions.values() if session.is_active) + + def get_available_variables( + self, active_datasets: Set[str], datasets_config: Dict[str, Any] + ) -> List[str]: + """Obtener lista de variables disponibles para plotting (solo de datasets activos)""" + available_vars = set() + + for dataset_id in active_datasets: + if dataset_id in datasets_config: + dataset_vars = datasets_config[dataset_id].get("variables", {}) + available_vars.update(dataset_vars.keys()) + + return sorted(list(available_vars)) + + def get_boolean_variables( + self, active_datasets: Set[str], datasets_config: Dict[str, Any] + ) -> List[str]: + """Obtener lista de variables boolean disponibles para trigger""" + boolean_vars = [] + + for dataset_id in active_datasets: + if dataset_id in datasets_config: + dataset_vars = datasets_config[dataset_id].get("variables", {}) + for var_name, var_config in dataset_vars.items(): + if var_config.get("var_type", "").upper() == "BOOL": + boolean_vars.append(var_name) + + return sorted(boolean_vars) diff --git a/core/streamer.py b/core/streamer.py index 4fc4b79..3ec29a6 100644 --- a/core/streamer.py +++ b/core/streamer.py @@ -8,6 +8,7 @@ import sys from datetime import datetime from typing import Dict, Any, Optional, Set from pathlib import Path +from .plot_manager import PlotManager def resource_path(relative_path): @@ -62,6 +63,9 @@ class DataStreamer: self.last_read_timestamps = {} # dataset_id -> timestamp self.last_read_errors = {} # dataset_id -> {var_name: error_message} + # 📈 PLOT MANAGER - Real-time plotting system + self.plot_manager = PlotManager(event_logger, logger) + def setup_udp_socket(self) -> bool: """Setup UDP socket for PlotJuggler communication""" try: @@ -499,6 +503,10 @@ class DataStreamer: if streaming_data: self.send_to_plotjuggler(streaming_data) + # 📈 PLOT MANAGER: Update all active plot sessions with cache data + if self.plot_manager.get_active_sessions_count() > 0: + self.plot_manager.update_data(dataset_id, all_data) + # Log data timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] if self.logger: diff --git a/main.py b/main.py index dc0fc74..5e6ea55 100644 --- a/main.py +++ b/main.py @@ -1205,6 +1205,203 @@ def stop_udp_streaming(): return jsonify({"success": True, "message": "UDP streaming to PlotJuggler stopped"}) +# 📈 PLOT MANAGER API ENDPOINTS +@app.route("/api/plots", methods=["GET"]) +def get_plots(): + """Get all plot sessions status""" + error_response = check_streamer_initialized() + if error_response: + return error_response + + try: + sessions = streamer.data_streamer.plot_manager.get_all_sessions_status() + return jsonify({"sessions": sessions}) + except Exception as e: + 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 = { + "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"]) + + session_id = streamer.data_streamer.plot_manager.create_session(config) + + 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/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/status") def get_status(): """Get current status""" diff --git a/plc_config.json b/plc_config.json index 75fc815..8213e32 100644 --- a/plc_config.json +++ b/plc_config.json @@ -1,6 +1,6 @@ { "plc_config": { - "ip": "10.1.33.249", + "ip": "10.1.33.11", "rack": 0, "slot": 2 }, @@ -16,6 +16,6 @@ "max_days": 30, "max_hours": null, "cleanup_interval_hours": 24, - "last_cleanup": "2025-07-19T23:30:11.005072" + "last_cleanup": "2025-07-20T23:49:17.540633" } } \ No newline at end of file diff --git a/plc_datasets.json b/plc_datasets.json index 4e04d1a..3e0c0a5 100644 --- a/plc_datasets.json +++ b/plc_datasets.json @@ -70,5 +70,5 @@ ], "current_dataset_id": "dar", "version": "1.0", - "last_update": "2025-07-20T23:25:07.495201" + "last_update": "2025-07-21T09:21:45.845720" } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2044834..cb6190f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Flask==2.3.3 python-snap7==1.3 -psutil==5.9.5 \ No newline at end of file +psutil==5.9.5 +flask-socketio==5.3.6 \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css index ae72f4a..aad22bf 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -517,4 +517,280 @@ textarea { background-color: var(--pico-color-red-100); color: var(--pico-color-red-800); border: 1px solid var(--pico-color-red-300); +} + +/* 📈 PLOT SYSTEM STYLES */ +.plot-session { + background: var(--pico-card-background-color); + border: var(--pico-border-width) solid var(--pico-border-color); + border-radius: var(--pico-border-radius); + margin-bottom: 1rem; + overflow: hidden; +} + +.plot-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--pico-muted-background-color); + border-bottom: var(--pico-border-width) solid var(--pico-border-color); +} + +.plot-header h4 { + margin: 0; + color: var(--pico-h4-color); + font-size: 1.1rem; +} + +.plot-controls { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.plot-controls .btn { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + min-width: auto; +} + +.plot-info { + padding: 0.5rem 1rem; + background: var(--pico-card-background-color); + border-bottom: var(--pico-border-width) solid var(--pico-border-color); + font-size: 0.85rem; + color: var(--pico-muted-color); +} + +.plot-stats { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.plot-canvas { + padding: 1rem; + height: 300px; + position: relative; +} + +.plot-canvas canvas { + max-height: 100%; +} + +/* Modal de creación de plots */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +.modal-content { + background-color: var(--pico-card-background-color); + margin: 5% auto; + padding: 2rem; + border: var(--pico-border-width) solid var(--pico-border-color); + border-radius: var(--pico-border-radius); + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; +} + +.modal-content h4 { + margin-top: 0; + color: var(--pico-h4-color); +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: bold; + color: var(--pico-form-element-label-color); +} + +.form-group input, +.form-group select { + width: 100%; + padding: 0.5rem; + border: var(--pico-border-width) solid var(--pico-border-color); + border-radius: var(--pico-border-radius); + background: var(--pico-form-element-background-color); + color: var(--pico-form-element-color); +} + +.form-group input:focus, +.form-group select:focus { + border-color: var(--pico-primary); + outline: none; +} + +.variable-checkbox { + margin-bottom: 0.5rem; +} + +.variable-checkbox label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: normal; + cursor: pointer; +} + +.variable-checkbox input[type="checkbox"] { + width: auto; + margin: 0; +} + +.range-inputs { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.range-inputs input { + flex: 1; +} + +.range-inputs span { + color: var(--pico-muted-color); + font-weight: bold; +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; + padding-top: 1rem; + border-top: var(--pico-border-width) solid var(--pico-border-color); +} + +.form-actions .btn { + min-width: 100px; +} + +#trigger-config { + padding: 1rem; + background: var(--pico-muted-background-color); + border: var(--pico-border-width) solid var(--pico-border-color); + border-radius: var(--pico-border-radius); + margin-top: 0.5rem; +} + +#trigger-config .form-group { + margin-bottom: 0.5rem; +} + +#trigger-config .form-group:last-child { + margin-bottom: 0; +} + +/* Responsive design para plots */ +@media (max-width: 768px) { + .plot-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .plot-controls { + width: 100%; + justify-content: flex-start; + } + + .plot-stats { + flex-direction: column; + gap: 0.25rem; + } + + .modal-content { + margin: 10% auto; + width: 95%; + padding: 1rem; + } + + .form-actions { + flex-direction: column; + } + + .range-inputs { + flex-direction: column; + align-items: stretch; + } +} + +/* TAB SYSTEM STYLES */ +.tabs { + display: flex; + border-bottom: var(--pico-border-width) solid var(--pico-border-color); + margin-bottom: 1.5rem; + background: var(--pico-card-background-color); + border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0; + overflow-x: auto; +} + +.tab-btn { + padding: 1rem 1.5rem; + border: none; + background: none; + color: var(--pico-muted-color); + cursor: pointer; + font-weight: 500; + border-bottom: 3px solid transparent; + transition: all 0.2s ease; + white-space: nowrap; + min-width: 120px; + text-align: center; +} + +.tab-btn:hover { + color: var(--pico-primary); + background: var(--pico-muted-background-color); +} + +.tab-btn.active { + color: var(--pico-primary); + border-bottom-color: var(--pico-primary); + background: var(--pico-card-background-color); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Responsive tabs */ +@media (max-width: 768px) { + .tabs { + flex-direction: column; + border-bottom: none; + border-right: var(--pico-border-width) solid var(--pico-border-color); + border-radius: var(--pico-border-radius) 0 0 var(--pico-border-radius); + margin-bottom: 1rem; + } + + .tab-btn { + border-bottom: none; + border-right: 3px solid transparent; + text-align: left; + min-width: auto; + } + + .tab-btn.active { + border-right-color: var(--pico-primary); + } } \ No newline at end of file diff --git a/static/js/plotting.js b/static/js/plotting.js new file mode 100644 index 0000000..b6dae28 --- /dev/null +++ b/static/js/plotting.js @@ -0,0 +1,509 @@ +/** + * 📈 Real-Time Plotting System + * Maneja sesiones de plotting con Chart.js y comunicación con el backend + */ + +class PlotManager { + constructor() { + this.sessions = new Map(); // session_id -> Chart instance + this.updateInterval = null; + this.isInitialized = false; + + // Colores para las variables + this.colors = [ + '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', + '#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384' + ]; + + this.init(); + } + + init() { + // Cargar sesiones existentes al inicializar + this.loadExistingSessions(); + + // Iniciar actualización automática + this.startAutoUpdate(); + + this.isInitialized = true; + console.log('📈 Plot Manager initialized'); + } + + async loadExistingSessions() { + try { + const response = await fetch('/api/plots'); + const data = await response.json(); + + if (data.sessions) { + for (const session of data.sessions) { + if (session.is_active) { + this.createPlotSession(session.session_id, session); + } + } + } + } catch (error) { + console.error('Error loading existing plot sessions:', error); + } + } + + startAutoUpdate() { + // Actualizar datos cada 500ms para plots activos + this.updateInterval = setInterval(() => { + this.updateAllSessions(); + }, 500); + } + + stopAutoUpdate() { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + } + + async updateAllSessions() { + const activeSessions = Array.from(this.sessions.keys()); + + for (const sessionId of activeSessions) { + await this.updateSessionData(sessionId); + } + } + + async updateSessionData(sessionId) { + try { + const response = await fetch(`/api/plots/${sessionId}/data`); + const plotData = await response.json(); + + if (plotData.datasets) { + this.updateChart(sessionId, plotData); + } + } catch (error) { + console.error(`Error updating session ${sessionId}:`, error); + } + } + + createPlotSession(sessionId, config) { + // Crear contenedor para el plot + const container = document.createElement('div'); + container.className = 'plot-session'; + container.id = `plot-${sessionId}`; + + container.innerHTML = ` +
+

📈 ${config.name || `Plot ${sessionId}`}

+
+ + + + + +
+
+
+ + Variables: ${config.variables_count || 0} | + Data Points: 0 | + ${config.trigger_enabled ? `Trigger: ${config.trigger_variable} (${config.trigger_on_true ? 'True' : 'False'})` : 'No Trigger'} + +
+
+ +
+ `; + + document.getElementById('plot-sessions-container').appendChild(container); + + // Crear Chart.js + const ctx = document.getElementById(`chart-${sessionId}`).getContext('2d'); + const chart = new Chart(ctx, { + type: 'line', + data: { + datasets: [] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 0 // Sin animación para mejor performance + }, + scales: { + x: { + type: 'time', + time: { + unit: 'second', + displayFormats: { + second: 'HH:mm:ss' + } + }, + title: { + display: true, + text: 'Time' + } + }, + y: { + title: { + display: true, + text: 'Value' + } + } + }, + plugins: { + legend: { + display: true, + position: 'top' + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + interaction: { + mode: 'nearest', + axis: 'x', + intersect: false + } + } + }); + + this.sessions.set(sessionId, chart); + + // Actualizar datos iniciales + this.updateSessionData(sessionId); + + console.log(`📈 Created plot session: ${sessionId}`); + } + + updateChart(sessionId, plotData) { + const chart = this.sessions.get(sessionId); + if (!chart) return; + + // Actualizar datasets + chart.data.datasets = plotData.datasets; + + // Actualizar escalas Y si están definidas + if (plotData.y_min !== undefined || plotData.y_max !== undefined) { + chart.options.scales.y.min = plotData.y_min; + chart.options.scales.y.max = plotData.y_max; + } + + // Actualizar contador de puntos + const pointsElement = document.getElementById(`points-${sessionId}`); + if (pointsElement) { + pointsElement.textContent = plotData.data_points_count || 0; + } + + // Actualizar chart + chart.update('none'); + } + + async controlPlot(sessionId, action) { + try { + const response = await fetch(`/api/plots/${sessionId}/control`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ action: action }) + }); + + const result = await response.json(); + + if (result.success) { + // Actualizar UI según la acción + if (action === 'stop') { + this.removeChart(sessionId); + } else { + // Actualizar datos inmediatamente + await this.updateSessionData(sessionId); + } + + showNotification(result.message, 'success'); + } else { + showNotification(result.error, 'error'); + } + } catch (error) { + console.error(`Error controlling plot ${sessionId}:`, error); + showNotification('Error controlling plot session', 'error'); + } + } + + async removePlot(sessionId) { + try { + const response = await fetch(`/api/plots/${sessionId}`, { + method: 'DELETE' + }); + + const result = await response.json(); + + if (result.success) { + this.removeChart(sessionId); + showNotification('Plot session removed', 'success'); + } else { + showNotification(result.error, 'error'); + } + } catch (error) { + console.error(`Error removing plot ${sessionId}:`, error); + showNotification('Error removing plot session', 'error'); + } + } + + removeChart(sessionId) { + // Destruir Chart.js + const chart = this.sessions.get(sessionId); + if (chart) { + chart.destroy(); + this.sessions.delete(sessionId); + } + + // Remover contenedor + const container = document.getElementById(`plot-${sessionId}`); + if (container) { + container.remove(); + } + } + + async createNewPlot(config) { + try { + const response = await fetch('/api/plots', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(config) + }); + + const result = await response.json(); + + if (result.success) { + // Crear la sesión visual + const sessionConfig = { + name: config.name, + variables_count: config.variables.length, + trigger_enabled: config.trigger_enabled, + trigger_variable: config.trigger_variable, + trigger_on_true: config.trigger_on_true + }; + + this.createPlotSession(result.session_id, sessionConfig); + showNotification(result.message, 'success'); + + return result.session_id; + } else { + showNotification(result.error, 'error'); + return null; + } + } catch (error) { + console.error('Error creating new plot:', error); + showNotification('Error creating plot session', 'error'); + return null; + } + } + + getColor(variable, alpha = 1.0) { + const index = this.hashCode(variable) % this.colors.length; + return this.colors[index]; + } + + hashCode(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); + } + + destroy() { + this.stopAutoUpdate(); + + // Destruir todos los charts + for (const [sessionId, chart] of this.sessions) { + chart.destroy(); + } + this.sessions.clear(); + + console.log('📈 Plot Manager destroyed'); + } +} + +// Funciones para el modal de creación de plots +let plotModal = null; +let availableVariables = []; +let booleanVariables = []; + +async function showNewPlotModal() { + if (!plotModal) { + plotModal = document.getElementById('new-plot-modal'); + } + + // Cargar variables disponibles + await loadPlotVariables(); + + // Limpiar formulario + document.getElementById('new-plot-form').reset(); + + // Mostrar modal + plotModal.style.display = 'block'; +} + +function closeNewPlotModal() { + if (plotModal) { + plotModal.style.display = 'none'; + } +} + +async function loadPlotVariables() { + try { + const response = await fetch('/api/plots/variables'); + const data = await response.json(); + + availableVariables = data.available_variables || []; + booleanVariables = data.boolean_variables || []; + + // Actualizar selector de variables + updateVariableSelector(); + + // Actualizar selector de trigger + updateTriggerSelector(); + + } catch (error) { + console.error('Error loading plot variables:', error); + showNotification('Error loading available variables', 'error'); + } +} + +function updateVariableSelector() { + const container = document.getElementById('plot-variables-selector'); + container.innerHTML = ''; + + if (availableVariables.length === 0) { + container.innerHTML = '

No variables available. Activate datasets first.

'; + return; + } + + for (const variable of availableVariables) { + const div = document.createElement('div'); + div.className = 'variable-checkbox'; + div.innerHTML = ` + + `; + container.appendChild(div); + } +} + +function updateTriggerSelector() { + const triggerSelect = document.getElementById('plot-trigger-variable'); + const triggerContainer = document.getElementById('trigger-config'); + + if (booleanVariables.length === 0) { + triggerContainer.style.display = 'none'; + return; + } + + triggerContainer.style.display = 'block'; + triggerSelect.innerHTML = ''; + + for (const variable of booleanVariables) { + const option = document.createElement('option'); + option.value = variable; + option.textContent = variable; + triggerSelect.appendChild(option); + } +} + +function toggleTriggerConfig() { + const triggerEnabled = document.getElementById('plot-trigger-enabled'); + const triggerConfig = document.getElementById('trigger-config'); + + triggerConfig.style.display = triggerEnabled.checked ? 'block' : 'none'; +} + +async function createNewPlot() { + const form = document.getElementById('new-plot-form'); + const formData = new FormData(form); + + // Obtener variables seleccionadas + const selectedVariables = Array.from(document.querySelectorAll('input[name="plot_variables"]:checked')) + .map(cb => cb.value); + + if (selectedVariables.length === 0) { + showNotification('Please select at least one variable', 'error'); + return; + } + + // Obtener configuración + const config = { + name: document.getElementById('plot-name').value || `Plot ${Date.now()}`, + variables: selectedVariables, + time_window: parseInt(document.getElementById('plot-time-window').value) || 60, + y_min: document.getElementById('plot-y-min').value || null, + y_max: document.getElementById('plot-y-max').value || null, + trigger_enabled: document.getElementById('plot-trigger-enabled').checked, + trigger_variable: document.getElementById('plot-trigger-variable').value || null, + trigger_on_true: document.getElementById('plot-trigger-on-true').checked + }; + + // Convertir valores numéricos + if (config.y_min) config.y_min = parseFloat(config.y_min); + if (config.y_max) config.y_max = parseFloat(config.y_max); + + // Crear plot + const sessionId = await plotManager.createNewPlot(config); + + if (sessionId) { + closeNewPlotModal(); + } +} + +// Inicialización +let plotManager = null; + +document.addEventListener('DOMContentLoaded', function () { + // Inicializar Plot Manager + plotManager = new PlotManager(); + + // Event listeners para el modal + document.getElementById('new-plot-btn').addEventListener('click', showNewPlotModal); + document.getElementById('new-plot-form').addEventListener('submit', function (e) { + e.preventDefault(); + createNewPlot(); + }); + + // Cerrar modal con Escape + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && plotModal && plotModal.style.display === 'block') { + closeNewPlotModal(); + } + }); + + // Cerrar modal haciendo clic fuera + window.addEventListener('click', function (e) { + if (e.target === plotModal) { + closeNewPlotModal(); + } + }); +}); + +// Función de utilidad para notificaciones +function showNotification(message, type = 'info') { + // Implementar según tu sistema de notificaciones + console.log(`${type.toUpperCase()}: ${message}`); + + // Si tienes un sistema de notificaciones, úsalo aquí + if (typeof showAlert === 'function') { + showAlert(message, type); + } +} \ No newline at end of file diff --git a/static/js/tabs.js b/static/js/tabs.js new file mode 100644 index 0000000..4830892 --- /dev/null +++ b/static/js/tabs.js @@ -0,0 +1,88 @@ +/** + * Tab System Management + * Maneja la navegación entre tabs en la aplicación + */ + +class TabManager { + constructor() { + this.currentTab = 'datasets'; + this.init(); + } + + init() { + // Event listeners para los botones de tab + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const tabName = e.target.dataset.tab; + this.switchTab(tabName); + }); + }); + + // Inicializar con el tab activo por defecto + this.switchTab(this.currentTab); + + console.log('📑 Tab Manager initialized'); + } + + switchTab(tabName) { + // Remover clase active de todos los tabs + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.remove('active'); + }); + + // Activar el tab seleccionado + const activeBtn = document.querySelector(`[data-tab="${tabName}"]`); + const activeContent = document.getElementById(`${tabName}-tab`); + + if (activeBtn && activeContent) { + activeBtn.classList.add('active'); + activeContent.classList.add('active'); + this.currentTab = tabName; + + // Eventos específicos por tab + this.handleTabSpecificEvents(tabName); + + console.log(`📑 Switched to tab: ${tabName}`); + } + } + + handleTabSpecificEvents(tabName) { + switch (tabName) { + case 'plotting': + // Inicializar plotting si no está inicializado + if (typeof plotManager !== 'undefined' && !plotManager.isInitialized) { + plotManager.init(); + } + break; + + case 'events': + // Cargar eventos si no están cargados + if (typeof loadEvents === 'function') { + loadEvents(); + } + break; + + case 'datasets': + // Actualizar datasets si es necesario + if (typeof loadDatasets === 'function') { + loadDatasets(); + } + break; + } + } + + getCurrentTab() { + return this.currentTab; + } +} + +// Inicialización +let tabManager = null; + +document.addEventListener('DOMContentLoaded', function () { + tabManager = new TabManager(); +}); \ No newline at end of file diff --git a/system_state.json b/system_state.json index 6ebbbd2..16c9e08 100644 --- a/system_state.json +++ b/system_state.json @@ -1,11 +1,11 @@ { "last_state": { "should_connect": true, - "should_stream": true, + "should_stream": false, "active_datasets": [ "dar" ] }, "auto_recovery_enabled": true, - "last_update": "2025-07-20T23:25:10.215131" + "last_update": "2025-07-21T09:21:45.854021" } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 473981e..63980e2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -123,367 +123,383 @@ - -
-
-
- 📊 Dataset & Variables Management -
- - - -
-
-
+ + - -
- - -
-
📡 PlotJuggler UDP Streaming Control
-
-

📡 UDP Streaming: Sends only variables marked for streaming to PlotJuggler via UDP -

-

💾 Automatic Recording: When PLC is connected, all datasets with variables - automatically record to CSV files

-

📁 File Organization: records/[dd-mm-yyyy]/[prefix]_[hour].csv (e.g., temp_14.csv, - pressure_14.csv)

-

⏱️ Independent Operation: CSV recording works independently of UDP streaming - - always active when PLC is connected

-
-
- - - -
-
- - -
-
📁 CSV Recording Configuration
-
-

📂 Directory Management: Configure where CSV files are saved and manage file - rotation

-

🔄 Automatic Cleanup: Set limits by size, days, or hours to automatically delete old - files

-

💿 Disk Space: Monitor available space and estimated recording time remaining

-
- - -
-
-
- 📁 Records Directory: - Loading... -
-
- 🔄 Rotation Enabled: - Loading... -
-
- 📊 Max Size: - Loading... -
-
- 📅 Max Days: - Loading... -
-
- ⏰ Max Hours: - Loading... -
-
- 🧹 Cleanup Interval: - Loading... -
-
-
- - -
-
- 📊 Directory Information -
-

Loading directory information...

-
-
-
- - -
- ⚙️ Modify Configuration -
-
-
- - - Base directory where CSV files will be saved -
-
+ + +
- Automatically delete old files based on limits below + + + + + +
+ + + + +
+

📊 Variables in Dataset

+
+
+ +
- -
-
- - - Maximum total size of all CSV files in MB (leave empty for no limit) -
-
- - - Delete files older than this many days (leave empty for no limit) -
-
- -
-
- - - Delete files older than this many hours (overrides days setting) -
-
- - - How often to run automatic cleanup -
-
- -
- - - -
- -
-
- - -
-
📋 Application Events Log
-
-

📝 Event Tracking: Connection events, configuration changes, errors and system - status

-

💾 Persistent Storage: Events are saved to disk and persist between application - restarts

-
- -
- - - -
- Loading log statistics... + + + + + + + + + + + + + + {% for name, var in variables.items() %} + + + + + + + + + + {% endfor %} + +
NameMemory AreaOffsetTypeCurrent ValueStream to PlotJugglerActions
{{ name }} + {% if var.area == 'db' or var.get('db') %} + DB{{ var.get('db', 'N/A') }}.{{ var.offset }} + {% elif var.area == 'mw' or var.area == 'm' %} + MW{{ var.offset }} + {% elif var.area == 'pew' or var.area == 'pe' %} + PEW{{ var.offset }} + {% elif var.area == 'paw' or var.area == 'pa' %} + PAW{{ var.offset }} + {% elif var.area == 'e' %} + E{{ var.offset }}.{{ var.bit }} + {% elif var.area == 'a' %} + A{{ var.offset }}.{{ var.bit }} + {% elif var.area == 'mb' %} + M{{ var.offset }}.{{ var.bit }} + {% else %} + DB{{ var.get('db', 'N/A') }}.{{ var.offset }} + {% endif %} + {{ var.offset }}{{ var.type.upper() }} + -- + + + + + +
-
-
-
-
- 📡 System - Loading... -
-
Loading application events...
+ +
+

📊 Please select a dataset to manage its variables

+

Or create a new dataset to get started

-
-
+ + + +
+
📡 PlotJuggler UDP Streaming Control
+
+

📡 UDP Streaming: Sends only variables marked for streaming to PlotJuggler via + UDP +

+

💾 Automatic Recording: When PLC is connected, all datasets with variables + automatically record to CSV files

+

📁 File Organization: records/[dd-mm-yyyy]/[prefix]_[hour].csv (e.g., + temp_14.csv, + pressure_14.csv)

+

⏱️ Independent Operation: CSV recording works independently of UDP streaming - + always active when PLC is connected

+
+
+ + + +
+
+ + +
+
📁 CSV Recording Configuration
+
+

📂 Directory Management: Configure where CSV files are saved and manage file + rotation

+

🔄 Automatic Cleanup: Set limits by size, days, or hours to automatically delete + old + files

+

💿 Disk Space: Monitor available space and estimated recording time remaining +

+
+ + +
+
+
+ 📁 Records Directory: + Loading... +
+
+ 🔄 Rotation Enabled: + Loading... +
+
+ 📊 Max Size: + Loading... +
+
+ 📅 Max Days: + Loading... +
+
+ ⏰ Max Hours: + Loading... +
+
+ 🧹 Cleanup Interval: + Loading... +
+
+
+ + +
+
+ 📊 Directory Information +
+

Loading directory information...

+
+
+
+ + +
+ ⚙️ Modify Configuration +
+
+
+ + + Base directory where CSV files will be saved +
+
+ + Automatically delete old files based on limits below +
+
+ +
+
+ + + Maximum total size of all CSV files in MB (leave empty for no limit) +
+
+ + + Delete files older than this many days (leave empty for no limit) +
+
+ +
+
+ + + Delete files older than this many hours (overrides days setting) +
+
+ + + How often to run automatic cleanup +
+
+ +
+ + + +
+
+
+
+ + +
+
📋 Application Events Log
+
+

📝 Event Tracking: Connection events, configuration changes, errors and system + status

+

💾 Persistent Storage: Events are saved to disk and persist between application + restarts

+
+ +
+ + + +
+ Loading log statistics... +
+
+ +
+
+
+ 📡 System + Loading... +
+
Loading application events...
+
+
+
@@ -555,6 +571,122 @@ + + + +
+
+
+
+ 📈 Real-Time Plotting + +
+
+ +
+

📈 Real-Time Plotting: Create interactive charts using cached data from active + datasets

+

🎯 Trigger System: Use boolean variables to automatically restart traces when + conditions are met

+

⚡ Performance: Uses recording cache - no additional PLC load

+

📊 Multiple Sessions: Create multiple independent plot sessions with different + variables and settings

+
+ + +
+
+

📈 No plot sessions created yet

+

Click "New Plot" to create your first real-time chart

+
+
+
+
+ + +
+
+
📋 Events & System Logs
+
+

📋 Event Logging: Monitor system events, errors, and operational status

+

🔍 Real-time Updates: Events are automatically updated as they occur

+

📊 Filtering: Filter events by type and time range

+
+ +
+ + +
+ Loading... events +
+
+ +
+

Loading events...

+
+
+
+ + + + + + + @@ -602,6 +738,8 @@ + +