diff --git a/.doc/CAMBIOS.md b/.doc/CAMBIOS.md new file mode 100644 index 0000000..9e789e2 --- /dev/null +++ b/.doc/CAMBIOS.md @@ -0,0 +1,97 @@ +# Resumen de Cambios y Nueva Estructura + +## Estructura Modular Creada + +El proyecto ha sido dividido en los siguientes módulos: + +### Módulos Principales: +1. **main.py** - Punto de entrada de la aplicación +2. **maselli_app.py** - Aplicación principal y coordinación de la GUI +3. **protocol_handler.py** - Manejo del protocolo ADAM/Maselli +4. **connection_manager.py** - Gestión unificada de conexiones (Serial/TCP/UDP) +5. **config_manager.py** - Gestión de configuración y persistencia +6. **utils.py** - Utilidades comunes + +### Módulos de Tabs: +1. **tabs/simulator_tab.py** - Lógica del simulador +2. **tabs/trace_tab.py** - Lógica del trace +3. **tabs/netcom_tab.py** - Nueva funcionalidad de gateway + +## Nuevas Características Implementadas: + +### 1. Tiempo de Ciclo Completo (Simulador) +- Reemplaza el período entre muestras por un tiempo total de ciclo más intuitivo +- Campo "Tiempo Ciclo (s)" configura la duración total de un ciclo completo +- Campo "Muestras/ciclo" permite ajustar la resolución +- El período entre muestras se calcula automáticamente + +### 2. Gráfico de Trace Mejorado +- Ahora muestra tanto Brix (eje Y izquierdo, azul) como mA (eje Y derecho, rojo) +- Marcadores diferentes para cada línea (círculos para Brix, cuadrados para mA) +- Actualización en tiempo real de ambos valores + +### 3. Modo NetCom (Gateway) +- Nueva pestaña para funcionalidad de bridge/gateway +- Conecta un puerto COM físico con una conexión TCP/UDP +- Log tipo sniffer que muestra: + - Dirección del tráfico (COM→NET o NET→COM) + - Datos raw con caracteres de control visibles + - Parseo opcional de mensajes ADAM + - Estadísticas de transferencias +- Filtros configurables para el log +- Colores diferenciados por tipo de mensaje + +## Mejoras Adicionales: + +1. **Mejor Organización del Código** + - Separación clara de responsabilidades + - Código más mantenible y extensible + - Fácil agregar nuevas funcionalidades + +2. **Gestión de Configuración Mejorada** + - Validación de parámetros + - Migración automática de configuraciones antiguas + - Valores por defecto sensatos + +3. **Manejo de Conexiones Unificado** + - Clase ConnectionManager centraliza toda la lógica de comunicación + - Soporte consistente para Serial, TCP y UDP + - Mejor manejo de errores y timeouts + +4. **Interfaz de Usuario Mejorada** + - Configuración compartida visible en todo momento + - Estados visuales claros (colores en NetCom) + - Estadísticas en tiempo real + +## Archivos de Soporte: +- **README.md** - Documentación completa actualizada +- **requirements.txt** - Dependencias del proyecto +- **.gitignore** - Para control de versiones +- **run.bat** - Script de inicio fácil para Windows +- **maselli_simulator_config.json** - Configuración de ejemplo + +## Cómo Ejecutar: + +1. Navegar al directorio del proyecto: + ``` + cd D:\Proyectos\Scripts\Siemens\MaselliSimulatorApp + ``` + +2. Instalar dependencias (solo la primera vez): + ``` + pip install -r requirements.txt + ``` + +3. Ejecutar la aplicación: + ``` + python main.py + ``` + O simplemente doble clic en `run.bat` + +## Notas de Migración: + +- La configuración existente se migrará automáticamente +- El campo "period" se convierte a "cycle_time" +- Los valores de configuración de NetCom tienen valores por defecto + +La aplicación mantiene toda la funcionalidad original y agrega las nuevas características solicitadas de manera integrada. diff --git a/.doc/README.md b/.doc/README.md new file mode 100644 index 0000000..e538e1f --- /dev/null +++ b/.doc/README.md @@ -0,0 +1,167 @@ +# Maselli Protocol Simulator/Tracer/NetCom Gateway + +## Descripción General + +Aplicación de escritorio basada en Python para simular, monitorear y hacer bridge de dispositivos que utilizan el protocolo ADAM/Maselli. La aplicación soporta comunicación Serial (RS485/RS232), TCP y UDP, proporcionando una interfaz gráfica intuitiva construida con Tkinter. + +## Características Principales + +### 1. **Modo Simulador** +- Emula un dispositivo Maselli enviando paquetes de datos en protocolo ADAM +- Patrones de generación de datos: + - **Lineal**: Onda triangular entre valores mínimo y máximo + - **Sinusoidal**: Onda sinusoidal suave + - **Manual**: Envío de valores individuales mediante slider o entrada directa +- **Tiempo de ciclo configurable**: Define el tiempo total para completar un ciclo de simulación +- Muestras por ciclo ajustables para control fino de la resolución +- Visualización en tiempo real de valores Brix y mA +- Gráfico dual con ejes Y independientes para Brix (azul) y mA (rojo) + +### 2. **Modo Trace** +- Escucha y registra datos entrantes de dispositivos Maselli reales +- Parseo automático de mensajes del protocolo ADAM +- Conversión mA ↔ Brix basada en mapeo configurable +- Registro de datos en archivo CSV con campos: + - Timestamp + - Dirección ADAM + - Valor mA + - Valor Brix calculado + - Validez del checksum + - Mensaje raw +- **Gráfico mejorado**: Muestra tanto Brix como mA en tiempo real +- Estadísticas de mensajes recibidos y errores de checksum + +### 3. **Modo NetCom (Gateway)** +- Actúa como puente transparente entre: + - Un puerto COM físico (configurable) + - Una conexión de red (TCP/UDP usando la configuración compartida) +- **Función Sniffer**: + - Log detallado de todo el tráfico en ambas direcciones + - Identificación visual de la dirección del tráfico (COM→NET, NET→COM) + - Parseo opcional de mensajes ADAM para mostrar valores interpretados +- Filtros de visualización configurables +- Estadísticas de transferencias y errores + +## Estructura Modular + +``` +MaselliSimulatorApp/ +├── main.py # Punto de entrada +├── maselli_app.py # Aplicación principal y GUI +├── protocol_handler.py # Manejo del protocolo ADAM +├── connection_manager.py # Gestión de conexiones +├── config_manager.py # Gestión de configuración +├── utils.py # Utilidades comunes +└── tabs/ + ├── __init__.py + ├── simulator_tab.py # Lógica del simulador + ├── trace_tab.py # Lógica del trace + └── netcom_tab.py # Lógica del gateway +``` + +## Protocolo ADAM + +Formato de mensaje: +``` +#AA[valor_mA][checksum]\r +``` +- `#`: Carácter inicial (opcional en respuestas) +- `AA`: Dirección del dispositivo (2 caracteres) +- `valor_mA`: Valor en mA (6 caracteres, formato XX.XXX) +- `checksum`: Suma de verificación (2 caracteres hex) +- `\r`: Carácter de fin + +## Requisitos + +- Python 3.7+ +- Bibliotecas requeridas: + ```bash + pip install pyserial matplotlib tkinter + ``` + +## Instalación y Uso + +1. Clonar o descargar el proyecto +2. Instalar dependencias: + ```bash + pip install -r requirements.txt + ``` +3. Ejecutar la aplicación: + ```bash + python main.py + ``` + +## Configuración + +### Parámetros de Conexión +- **Serial**: Puerto COM y velocidad de baudios +- **TCP/UDP**: Dirección IP y puerto + +### Mapeo Brix ↔ mA +- **Min Brix [4mA]**: Valor Brix correspondiente a 4mA +- **Max Brix [20mA]**: Valor Brix correspondiente a 20mA +- Interpolación lineal para valores intermedios + +### Configuración del Simulador +- **Dirección ADAM**: 2 caracteres (ej: "01") +- **Tiempo de ciclo**: Duración total de un ciclo completo de simulación +- **Muestras/ciclo**: Número de puntos por ciclo (resolución) + +### Configuración NetCom +- **Puerto COM físico**: Puerto para el dispositivo real +- **Baud Rate**: Velocidad del puerto COM físico + +## Archivos Generados + +- `maselli_simulator_config.json`: Configuración guardada +- `maselli_trace_YYYYMMDD_HHMMSS.csv`: Datos capturados en modo Trace + +## Iconos + +La aplicación buscará automáticamente archivos de icono en el directorio raíz: +- `icon.png` (recomendado) +- `icon.ico` (Windows) +- `icon.gif` + +## Uso Típico + +### Como Simulador +1. Configurar tipo de conexión y parámetros +2. Seleccionar función de simulación (Lineal/Sinusoidal) +3. Ajustar tiempo de ciclo según necesidad +4. Iniciar simulación + +### Como Monitor (Trace) +1. Configurar conexión según el dispositivo a monitorear +2. Iniciar Trace +3. Los datos se mostrarán en tiempo real y se guardarán en CSV + +### Como Gateway (NetCom) +1. Configurar puerto COM del dispositivo físico +2. Configurar conexión de red destino +3. Iniciar Gateway +4. Monitorear el tráfico bidireccional en el log + +## Notas de Desarrollo + +- La aplicación usa threading para operaciones de comunicación sin bloquear la GUI +- Los gráficos se actualizan mediante matplotlib animation +- El protocolo ADAM es parseado con validación de checksum opcional +- Todos los módulos están diseñados para ser reutilizables + +## Mejoras Respecto a la Versión Original + +1. **Arquitectura modular**: Código dividido en módulos especializados +2. **Tiempo de ciclo configurable**: Control más intuitivo de la velocidad de simulación +3. **Gráfico de Trace mejorado**: Visualización dual de Brix y mA +4. **Modo NetCom**: Nueva funcionalidad de gateway/bridge con sniffer integrado +5. **Mejor manejo de errores**: Validación robusta y recuperación de errores +6. **Estadísticas detalladas**: Contadores de mensajes, errores y transferencias + +## Licencia + +Este proyecto es de código abierto. Úselo bajo su propia responsabilidad. + +## Autor + +Desarrollado para monitoreo y simulación de dispositivos Maselli con protocolo ADAM. diff --git a/.gitignore b/.gitignore index 651a6d1..9435984 100644 --- a/.gitignore +++ b/.gitignore @@ -1,181 +1,33 @@ -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging .Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv env/ venv/ ENV/ -env.bak/ -venv.bak/ +.venv -# Spyder project settings -.spyderproject -.spyproject +# IDEs +.vscode/ +.idea/ +*.swp +*.swo -# Rope project settings -.ropeproject +# Archivos generados +*.csv +*.log -# mkdocs documentation -/site +# Archivos de configuración local (opcional, quitar si quieres versionar la config) +# maselli_simulator_config.json -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +# Sistema +.DS_Store +Thumbs.db +desktop.ini -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore +# Iconos (si son específicos del usuario) +# icon.png +# icon.ico +# icon.gif diff --git a/MaselliSimulatorApp.py b/MaselliSimulatorApp.py index 021d3aa..cd8fad4 100644 --- a/MaselliSimulatorApp.py +++ b/MaselliSimulatorApp.py @@ -1,5 +1,5 @@ import tkinter as tk -from tkinter import ttk, scrolledtext, messagebox, filedialog +from tkinter import ttk, scrolledtext, messagebox import serial import socket import threading @@ -15,10 +15,45 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure import matplotlib.animation as animation +# Simulador y Trace para Protocolo Maselli (ADAM) +# Soporta conexiones Serial, TCP y UDP +# +# Para cambiar el icono, coloca uno de estos archivos en el mismo directorio: +# - icon.png (recomendado) +# - icon.ico (para Windows) +# - icon.gif +# +# Características: +# - Modo Simulador: Genera valores de prueba en protocolo ADAM +# - Modo Trace: Recibe y registra valores del medidor real +# - Conversión automática mA <-> Brix +# - Registro en CSV con timestamp +# - Gráficos en tiempo real +# - Respuestas del dispositivo mostradas en el log + class MaselliSimulatorApp: def __init__(self, root_window): self.root = root_window self.root.title("Simulador/Trace Protocolo Maselli") + self.root.geometry("900x700") # Tamaño inicial de ventana + + # Intentar cargar el icono + icon_loaded = False + for icon_file in ['icon.png', 'icon.ico', 'icon.gif']: + if os.path.exists(icon_file): + try: + if icon_file.endswith('.ico'): + self.root.iconbitmap(icon_file) + else: + icon = tk.PhotoImage(file=icon_file) + self.root.iconphoto(True, icon) + icon_loaded = True + break + except Exception as e: + print(f"No se pudo cargar {icon_file}: {e}") + + if not icon_loaded: + print("No se encontró ningún archivo de icono (icon.png, icon.ico, icon.gif)") self.connection = None self.connection_type = None @@ -338,39 +373,60 @@ class MaselliSimulatorApp: log_widget.see(tk.END) log_widget.configure(state=tk.DISABLED) - def parse_adam_message(self, data): - """Parsea un mensaje del protocolo ADAM y retorna el valor en mA""" + def parse_adam_message(self, data, log_widget=None): + """ + Parsea un mensaje del protocolo ADAM y retorna el valor en mA + Formato esperado: #AA[valor_mA][checksum]\r + Donde: + - # : Carácter inicial (opcional en algunas respuestas) + - AA : Dirección del dispositivo (2 caracteres) + - valor_mA : Valor en mA (6 caracteres, formato XX.XXX) + - checksum : Suma de verificación (2 caracteres hex) + - \r : Carácter de fin (opcional) + """ try: # Formato esperado: #AA[valor_mA][checksum]\r - if not data.startswith('#') or not data.endswith('\r'): - return None + # Pero también manejar respuestas sin # inicial o sin \r final + data = data.strip() - # Remover # y \r - data = data[1:-1] + # Si empieza con #, es un mensaje estándar + if data.startswith('#'): + data = data[1:] # Remover # - # Los primeros 2 caracteres son la dirección - if len(data) < 9: # 2 addr + 6 valor + 2 checksum + # Si termina con \r, removerlo + if data.endswith('\r'): + data = data[:-1] + + # Verificar longitud mínima + if len(data) < 8: # 2 addr + 6 valor mínimo return None address = data[:2] - value_str = data[2:9] # 6 caracteres para el valor - checksum = data[9:11] # 2 caracteres para checksum + value_str = data[2:8] # 6 caracteres para el valor (XX.XXX) - # Verificar checksum - message_part = f"#{address}{value_str}" - calculated_checksum = self.calculate_checksum(message_part) - - if checksum != calculated_checksum: - self._log_message(f"Checksum incorrecto: recibido={checksum}, calculado={calculated_checksum}", - self.trace_log_text) - return None + # Verificar si hay checksum + if len(data) >= 10: + checksum = data[8:10] # 2 caracteres para checksum + + # Verificar checksum + message_part = f"#{address}{value_str}" + calculated_checksum = self.calculate_checksum(message_part) + + if checksum != calculated_checksum and log_widget: + self._log_message(f"Checksum incorrecto: recibido={checksum}, calculado={calculated_checksum}", + log_widget) + # Continuar de todos modos si el valor parece válido # Convertir valor a float - ma_value = float(value_str) - return {'address': address, 'ma': ma_value} + try: + ma_value = float(value_str) + return {'address': address, 'ma': ma_value} + except ValueError: + return None except Exception as e: - self._log_message(f"Error parseando mensaje: {e}", self.trace_log_text) + if log_widget: + self._log_message(f"Error parseando mensaje: {e}", log_widget) return None def ma_to_brix(self, ma_value): @@ -419,7 +475,7 @@ class MaselliSimulatorApp: try: self.csv_file = open(csv_filename, 'w', newline='') self.csv_writer = csv.writer(self.csv_file) - self.csv_writer.writerow(['Timestamp', 'mA', 'Brix', 'Raw_Message']) + self.csv_writer.writerow(['Timestamp', 'Address', 'mA', 'Brix', 'Raw_Message']) self.csv_filename_var.set(csv_filename) except Exception as e: messagebox.showerror("Error", f"No se pudo crear archivo CSV: {e}") @@ -492,6 +548,9 @@ class MaselliSimulatorApp: self.connection.settimeout(0.1) try: data = self.connection.recv(1024).decode('ascii', errors='ignore') + if not data: # Conexión cerrada + self._log_message("Conexión TCP cerrada por el servidor.", self.trace_log_text) + break except socket.timeout: continue elif self.connection_type == "UDP": @@ -505,20 +564,42 @@ class MaselliSimulatorApp: if data: buffer += data - # Buscar mensajes completos (terminan con \r) - while '\r' in buffer: - end_idx = buffer.index('\r') + 1 - message = buffer[:end_idx] - buffer = buffer[end_idx:] + # Buscar mensajes completos (terminan con \r o \n) + while '\r' in buffer or '\n' in buffer: + # Encontrar el primer terminador + end_idx = len(buffer) + for term in ['\r', '\n']: + if term in buffer: + idx = buffer.index(term) + 1 + if idx < end_idx: + end_idx = idx - # Procesar mensaje - self._process_trace_message(message) + if end_idx > 0: + message = buffer[:end_idx] + buffer = buffer[end_idx:] + + # Procesar mensaje si tiene contenido + if message.strip(): + self._process_trace_message(message) + else: + break + + # Si el buffer tiene un mensaje completo sin terminador (>= 10 chars) + # y no han llegado más datos en un tiempo, procesarlo + if len(buffer) >= 10 and not ('\r' in buffer or '\n' in buffer): + # Verificar si parece un mensaje ADAM completo + if buffer.startswith('#') or len(buffer) == 10: + self._process_trace_message(buffer) + buffer = "" except Exception as e: - self._log_message(f"Error en trace: {e}", self.trace_log_text) - if not self.tracing: - break - time.sleep(0.1) + if self.tracing: # Solo loguear si todavía estamos en trace + self._log_message(f"Error en trace: {e}", self.trace_log_text) + break + + # Pequeña pausa para no consumir demasiado CPU + if not data: + time.sleep(0.01) def _process_trace_message(self, message): """Procesa un mensaje recibido en modo trace""" @@ -527,7 +608,7 @@ class MaselliSimulatorApp: self._log_message(f"Recibido: {display_msg}", self.trace_log_text) # Parsear mensaje - parsed = self.parse_adam_message(message) + parsed = self.parse_adam_message(message, self.trace_log_text) if parsed: ma_value = parsed['ma'] brix_value = self.ma_to_brix(ma_value) @@ -538,12 +619,17 @@ class MaselliSimulatorApp: self.trace_ma_var.set(f"{ma_value:.3f} mA") self.trace_brix_var.set(f"{brix_value:.3f} Brix") + # Log con detalles parseados + self._log_message(f" -> Addr: {parsed['address']}, mA: {ma_value:.3f}, Brix: {brix_value:.3f}", + self.trace_log_text) + # Guardar en CSV if self.csv_writer: self.csv_writer.writerow([ timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], - ma_value, - brix_value, + parsed['address'], + f"{ma_value:.3f}", + f"{brix_value:.3f}", display_msg ]) if self.csv_file: @@ -556,6 +642,9 @@ class MaselliSimulatorApp: # Actualizar gráfico self.root.after(0, self.trace_canvas.draw_idle) + else: + # Si no es un mensaje ADAM válido, podría ser otro tipo de respuesta + self._log_message(f"Mensaje no ADAM: {display_msg}", self.trace_log_text) def _set_trace_entries_state(self, state): """Habilita/deshabilita controles durante el trace""" @@ -746,6 +835,7 @@ class MaselliSimulatorApp: return mA_value def format_mA_value(self, mA_val): + # Formato: "XX.XXX" (6 caracteres incluyendo el punto) return f"{mA_val:06.3f}" def _get_common_params(self): @@ -822,6 +912,50 @@ class MaselliSimulatorApp: self.sim_ma_data.append(ma_value) self.sim_canvas.draw_idle() + def _read_response(self, connection, conn_type, timeout=0.5): + """Intenta leer una respuesta del dispositivo""" + try: + response = None + if conn_type == "Serial": + # Guardar timeout original + original_timeout = connection.timeout + connection.timeout = timeout + # Esperar un poco para que llegue la respuesta + time.sleep(0.05) + # Leer todos los bytes disponibles + response_bytes = b"" + start_time = time.time() + while (time.time() - start_time) < timeout: + if connection.in_waiting > 0: + response_bytes += connection.read(connection.in_waiting) + # Si encontramos un terminador, salir + if b'\r' in response_bytes or b'\n' in response_bytes: + break + else: + time.sleep(0.01) + + if response_bytes: + response = response_bytes.decode('ascii', errors='ignore') + connection.timeout = original_timeout + elif conn_type == "TCP": + connection.settimeout(timeout) + try: + response = connection.recv(1024).decode('ascii', errors='ignore') + except socket.timeout: + pass + elif conn_type == "UDP": + connection.settimeout(timeout) + try: + response, addr = connection.recvfrom(1024) + response = response.decode('ascii', errors='ignore') + except socket.timeout: + pass + + return response + except Exception as e: + self._log_message(f"Error al leer respuesta: {e}", self.sim_log_text) + return None + def send_manual_value(self): common_params = self._get_common_params() if not common_params: @@ -853,6 +987,20 @@ class MaselliSimulatorApp: self._log_message(f"Conexión {conn_type} abierta temporalmente para envío manual.", self.sim_log_text) self._log_message(f"Enviando Manual: {log_display_string}", self.sim_log_text) self._send_data(temp_connection, conn_type, full_string_to_send) + + # Intentar leer respuesta + response = self._read_response(temp_connection, conn_type) + if response and response.strip(): # Solo procesar si hay contenido + display_resp = response.replace('\r', '').replace('\n', '') + self._log_message(f"Respuesta: {display_resp}", self.sim_log_text) + + # Intentar parsear como mensaje ADAM + parsed = self.parse_adam_message(response, self.sim_log_text) + if parsed: + # Convertir mA a Brix para mostrar + brix_value = self.ma_to_brix(parsed['ma']) + self._log_message(f" -> Dirección: {parsed['address']}, Valor: {parsed['ma']:.3f} mA ({brix_value:.3f} Brix)", self.sim_log_text) + except Exception as e: self._log_message(f"Error al enviar manualmente: {e}", self.sim_log_text) messagebox.showerror("Error de Conexión", str(e)) @@ -998,6 +1146,20 @@ class MaselliSimulatorApp: if self.connection: try: self._send_data(self.connection, self.connection_type, full_string_to_send) + + # Intentar leer respuesta (timeout corto para no ralentizar simulación) + response = self._read_response(self.connection, self.connection_type, timeout=0.1) + if response and response.strip(): # Solo procesar si hay contenido + display_resp = response.replace('\r', '').replace('\n', '') + self._log_message(f"Respuesta: {display_resp}", self.sim_log_text) + + # Intentar parsear como mensaje ADAM + parsed = self.parse_adam_message(response, self.sim_log_text) + if parsed: + # Convertir mA a Brix para mostrar + brix_value = self.ma_to_brix(parsed['ma']) + self._log_message(f" -> Dirección: {parsed['address']}, Valor: {parsed['ma']:.3f} mA ({brix_value:.3f} Brix)", self.sim_log_text) + except Exception as e: self._log_message(f"Error al escribir en conexión (sim): {e}", self.sim_log_text) self.root.after(0, self.stop_simulation_from_thread_error) @@ -1026,12 +1188,20 @@ class MaselliSimulatorApp: self._log_message("Simulación continua terminada (hilo finalizado).", self.sim_log_text) def on_closing(self): + """Maneja el cierre de la aplicación""" + # Detener simulación si está activa if self.simulating: self.stop_simulation() + + # Detener trace si está activo if self.tracing: self.stop_trace() - elif self.connection: + + # Cerrar cualquier conexión abierta + if self.connection: self._close_connection(self.connection, self.connection_type) + + # Destruir ventana self.root.destroy() if __name__ == "__main__": diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..afca2c3 --- /dev/null +++ b/config_manager.py @@ -0,0 +1,133 @@ +""" +Gestor de configuración para guardar y cargar ajustes +""" + +import json +import os + +class ConfigManager: + def __init__(self, config_file="maselli_simulator_config.json"): + self.config_file = config_file + self.default_config = { + 'connection_type': 'TCP', + 'com_port': 'COM8', + 'baud_rate': '115200', + 'ip_address': '10.1.33.18', + 'port': '8899', + 'adam_address': '01', + 'function_type': 'Sinusoidal', + 'min_brix_map': '0', + 'max_brix_map': '80', + 'cycle_time': '0.5', # Cambiado de 'period' a 'cycle_time' para tiempo de ciclo completo + 'manual_brix': '10.0', + # Configuración para NetCom + 'netcom_com_port': 'COM3', + 'netcom_baud_rate': '115200' + } + + def save_config(self, config_data): + """Guarda la configuración en archivo JSON""" + try: + with open(self.config_file, 'w') as f: + json.dump(config_data, f, indent=4) + return True + except Exception as e: + print(f"Error al guardar configuración: {e}") + return False + + def load_config(self): + """Carga la configuración desde archivo JSON""" + if not os.path.exists(self.config_file): + return self.default_config.copy() + + try: + with open(self.config_file, 'r') as f: + config = json.load(f) + + # Asegurarse de que todas las claves necesarias estén presentes + for key, value in self.default_config.items(): + if key not in config: + config[key] = value + + # Migrar 'period' a 'cycle_time' si existe + if 'period' in config and 'cycle_time' not in config: + config['cycle_time'] = config['period'] + del config['period'] + + return config + + except Exception as e: + print(f"Error al cargar configuración: {e}") + return self.default_config.copy() + + def get_connection_params(self, config, use_netcom_port=False): + """Extrae los parámetros de conexión de la configuración""" + conn_type = config.get('connection_type', 'Serial') + + if conn_type == "Serial": + if use_netcom_port: + # Para NetCom, usar el puerto COM físico dedicado + return { + 'port': config.get('netcom_com_port', 'COM3'), + 'baud': int(config.get('netcom_baud_rate', '115200')) + } + else: + return { + 'port': config.get('com_port', 'COM3'), + 'baud': int(config.get('baud_rate', '115200')) + } + else: + return { + 'ip': config.get('ip_address', '192.168.1.100'), + 'port': int(config.get('port', '502')) + } + + def validate_config(self, config): + """Valida que la configuración tenga valores correctos""" + errors = [] + + # Validar dirección ADAM + adam_address = config.get('adam_address', '') + if len(adam_address) != 2: + errors.append("La dirección ADAM debe tener exactamente 2 caracteres") + + # Validar rango de Brix + try: + min_brix = float(config.get('min_brix_map', '0')) + max_brix = float(config.get('max_brix_map', '80')) + if min_brix >= max_brix: + errors.append("El valor mínimo de Brix debe ser menor que el máximo") + except ValueError: + errors.append("Los valores de Brix deben ser números válidos") + + # Validar tiempo de ciclo + try: + cycle_time = float(config.get('cycle_time', '1.0')) + if cycle_time <= 0: + errors.append("El tiempo de ciclo debe ser mayor que 0") + except ValueError: + errors.append("El tiempo de ciclo debe ser un número válido") + + # Validar puerto serie + if config.get('connection_type') == 'Serial': + com_port = config.get('com_port', '') + if not com_port.upper().startswith('COM'): + errors.append("El puerto COM debe tener formato 'COMx'") + + try: + baud_rate = int(config.get('baud_rate', '9600')) + if baud_rate <= 0: + errors.append("La velocidad de baudios debe ser mayor que 0") + except ValueError: + errors.append("La velocidad de baudios debe ser un número entero") + + # Validar configuración TCP/UDP + else: + try: + port = int(config.get('port', '502')) + if port <= 0 or port > 65535: + errors.append("El puerto debe estar entre 1 y 65535") + except ValueError: + errors.append("El puerto debe ser un número entero") + + return errors diff --git a/connection_manager.py b/connection_manager.py new file mode 100644 index 0000000..3f112d4 --- /dev/null +++ b/connection_manager.py @@ -0,0 +1,165 @@ +""" +Gestor de conexiones para Serial, TCP y UDP +""" + +import serial +import socket +import time + +class ConnectionManager: + def __init__(self): + self.connection = None + self.connection_type = None + self.dest_address = None # Para UDP + + def open_connection(self, conn_type, conn_params): + """Abre una conexión según el tipo especificado""" + try: + if conn_type == "Serial": + self.connection = serial.Serial( + conn_params['port'], + conn_params['baud'], + timeout=1 + ) + self.connection_type = "Serial" + + elif conn_type == "TCP": + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5.0) + sock.connect((conn_params['ip'], conn_params['port'])) + self.connection = sock + self.connection_type = "TCP" + + elif conn_type == "UDP": + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(1.0) + self.dest_address = (conn_params['ip'], conn_params['port']) + self.connection = sock + self.connection_type = "UDP" + + return self.connection + + except Exception as e: + raise Exception(f"Error al abrir conexión {conn_type}: {e}") + + def close_connection(self): + """Cierra la conexión actual""" + try: + if self.connection_type == "Serial": + if self.connection and self.connection.is_open: + self.connection.close() + elif self.connection_type in ["TCP", "UDP"]: + if self.connection: + self.connection.close() + except Exception as e: + print(f"Error al cerrar conexión: {e}") + finally: + self.connection = None + self.connection_type = None + self.dest_address = None + + def send_data(self, data): + """Envía datos por la conexión actual""" + if not self.connection: + raise Exception("No hay conexión activa") + + try: + if self.connection_type == "Serial": + self.connection.write(data.encode('ascii')) + elif self.connection_type == "TCP": + self.connection.send(data.encode('ascii')) + elif self.connection_type == "UDP": + self.connection.sendto(data.encode('ascii'), self.dest_address) + except Exception as e: + raise Exception(f"Error al enviar datos: {e}") + + def read_response(self, timeout=0.5): + """Intenta leer una respuesta del dispositivo""" + if not self.connection: + return None + + try: + response = None + if self.connection_type == "Serial": + # Guardar timeout original + original_timeout = self.connection.timeout + self.connection.timeout = timeout + # Esperar un poco para que llegue la respuesta + time.sleep(0.05) + # Leer todos los bytes disponibles + response_bytes = b"" + start_time = time.time() + while (time.time() - start_time) < timeout: + if self.connection.in_waiting > 0: + response_bytes += self.connection.read(self.connection.in_waiting) + # Si encontramos un terminador, salir + if b'\r' in response_bytes or b'\n' in response_bytes: + break + else: + time.sleep(0.01) + + if response_bytes: + response = response_bytes.decode('ascii', errors='ignore') + self.connection.timeout = original_timeout + + elif self.connection_type == "TCP": + self.connection.settimeout(timeout) + try: + response = self.connection.recv(1024).decode('ascii', errors='ignore') + except socket.timeout: + pass + + elif self.connection_type == "UDP": + self.connection.settimeout(timeout) + try: + response, addr = self.connection.recvfrom(1024) + response = response.decode('ascii', errors='ignore') + except socket.timeout: + pass + + return response + + except Exception as e: + print(f"Error al leer respuesta: {e}") + return None + + def read_data_non_blocking(self): + """Lee datos disponibles sin bloquear (para modo trace y netcom)""" + if not self.connection: + return None + + try: + data = None + if self.connection_type == "Serial": + if self.connection.in_waiting > 0: + data = self.connection.read(self.connection.in_waiting).decode('ascii', errors='ignore') + + elif self.connection_type == "TCP": + self.connection.settimeout(0.1) + try: + data = self.connection.recv(1024).decode('ascii', errors='ignore') + if not data: # Conexión cerrada + return None + except socket.timeout: + pass + + elif self.connection_type == "UDP": + self.connection.settimeout(0.1) + try: + data, addr = self.connection.recvfrom(1024) + data = data.decode('ascii', errors='ignore') + except socket.timeout: + pass + + return data + + except Exception as e: + print(f"Error al leer datos: {e}") + return None + + def is_connected(self): + """Verifica si hay una conexión activa""" + if self.connection_type == "Serial": + return self.connection and self.connection.is_open + else: + return self.connection is not None diff --git a/main.py b/main.py new file mode 100644 index 0000000..037a84f --- /dev/null +++ b/main.py @@ -0,0 +1,21 @@ +""" +Punto de entrada principal para la aplicación Maselli Simulator/Trace/NetCom +""" + +import tkinter as tk +import sys +import os + +# Agregar el directorio actual al path para importaciones +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from maselli_app import MaselliApp + +def main(): + """Función principal que inicia la aplicación""" + root = tk.Tk() + app = MaselliApp(root) + root.mainloop() + +if __name__ == "__main__": + main() diff --git a/maselli_app.py b/maselli_app.py new file mode 100644 index 0000000..e1abaa3 --- /dev/null +++ b/maselli_app.py @@ -0,0 +1,354 @@ +""" +Aplicación principal del Simulador/Trace Maselli +Une todos los módulos y maneja la interfaz principal +""" + +import tkinter as tk +from tkinter import ttk, messagebox +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure +import matplotlib.animation as animation + +from config_manager import ConfigManager +from utils import Utils +from tabs.simulator_tab import SimulatorTab +from tabs.trace_tab import TraceTab +from tabs.netcom_tab import NetComTab + +class MaselliApp: + def __init__(self, root): + self.root = root + self.root.title("Simulador/Trace/NetCom Protocolo Maselli") + self.root.geometry("1000x800") + + # Cargar icono + Utils.load_icon(self.root) + + # Gestor de configuración + self.config_manager = ConfigManager() + self.config = self.config_manager.load_config() + + # Diccionario para compartir configuración entre tabs + self.shared_config = { + 'config_manager': self.config_manager + } + + # Crear interfaz + self.create_widgets() + + # Cargar configuración inicial + self.load_config_to_gui() + + # Configurar eventos + self.root.protocol("WM_DELETE_WINDOW", self.on_closing) + + # Inicializar animaciones de gráficos + self.sim_ani = animation.FuncAnimation( + self.sim_fig, self.update_sim_graph, interval=100, blit=False + ) + self.trace_ani = animation.FuncAnimation( + self.trace_fig, self.update_trace_graph, interval=100, blit=False + ) + + def create_widgets(self): + """Crea todos los widgets de la aplicación""" + # Frame principal + main_frame = ttk.Frame(self.root) + main_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + # Configurar pesos + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) + main_frame.rowconfigure(1, weight=1) + + # Frame de configuración compartida + self.create_shared_config_frame(main_frame) + + # Notebook para tabs + self.notebook = ttk.Notebook(main_frame) + self.notebook.grid(row=1, column=0, sticky="nsew") + + # Tab Simulador + sim_frame = ttk.Frame(self.notebook) + self.notebook.add(sim_frame, text="Simulador") + self.simulator_tab = SimulatorTab(sim_frame, self.shared_config) + + # Tab Trace + trace_frame = ttk.Frame(self.notebook) + self.notebook.add(trace_frame, text="Trace") + self.trace_tab = TraceTab(trace_frame, self.shared_config) + + # Tab NetCom + netcom_frame = ttk.Frame(self.notebook) + self.notebook.add(netcom_frame, text="NetCom (Gateway)") + self.netcom_tab = NetComTab(netcom_frame, self.shared_config) + + # Crear gráficos + self.create_graphs() + + # Establecer callbacks para actualización de gráficos + self.simulator_tab.graph_update_callback = self.update_sim_graph + self.trace_tab.graph_update_callback = self.update_trace_graph + + def create_shared_config_frame(self, parent): + """Crea el frame de configuración compartida""" + config_frame = ttk.LabelFrame(parent, text="Configuración de Conexión") + config_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5)) + + # Tipo de conexión + ttk.Label(config_frame, text="Tipo:").grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.connection_type_var = tk.StringVar() + self.connection_type_combo = ttk.Combobox( + config_frame, textvariable=self.connection_type_var, + values=["Serial", "TCP", "UDP"], state="readonly", width=10 + ) + self.connection_type_combo.grid(row=0, column=1, padx=5, pady=5) + self.connection_type_combo.bind("<>", self.on_connection_type_change) + + # Frame para Serial + self.serial_frame = ttk.Frame(config_frame) + self.serial_frame.grid(row=0, column=2, columnspan=4, padx=5, pady=5, sticky="ew") + + ttk.Label(self.serial_frame, text="Puerto:").grid(row=0, column=0, padx=5, sticky="w") + self.com_port_var = tk.StringVar() + self.com_port_entry = ttk.Entry(self.serial_frame, textvariable=self.com_port_var, width=10) + self.com_port_entry.grid(row=0, column=1, padx=5) + + ttk.Label(self.serial_frame, text="Baud:").grid(row=0, column=2, padx=5, sticky="w") + self.baud_rate_var = tk.StringVar() + self.baud_rate_entry = ttk.Entry(self.serial_frame, textvariable=self.baud_rate_var, width=10) + self.baud_rate_entry.grid(row=0, column=3, padx=5) + + # Frame para Ethernet + self.ethernet_frame = ttk.Frame(config_frame) + self.ethernet_frame.grid(row=0, column=2, columnspan=4, padx=5, pady=5, sticky="ew") + self.ethernet_frame.grid_remove() + + ttk.Label(self.ethernet_frame, text="IP:").grid(row=0, column=0, padx=5, sticky="w") + self.ip_address_var = tk.StringVar() + self.ip_address_entry = ttk.Entry(self.ethernet_frame, textvariable=self.ip_address_var, width=15) + self.ip_address_entry.grid(row=0, column=1, padx=5) + + ttk.Label(self.ethernet_frame, text="Puerto:").grid(row=0, column=2, padx=5, sticky="w") + self.port_var = tk.StringVar() + self.port_entry = ttk.Entry(self.ethernet_frame, textvariable=self.port_var, width=8) + self.port_entry.grid(row=0, column=3, padx=5) + + # Parámetros de mapeo + ttk.Label(config_frame, text="Min Brix [4mA]:").grid(row=1, column=0, padx=5, pady=5, sticky="w") + self.min_brix_map_var = tk.StringVar() + self.min_brix_map_entry = ttk.Entry(config_frame, textvariable=self.min_brix_map_var, width=10) + self.min_brix_map_entry.grid(row=1, column=1, padx=5, pady=5) + + ttk.Label(config_frame, text="Max Brix [20mA]:").grid(row=1, column=2, padx=5, pady=5, sticky="w") + self.max_brix_map_var = tk.StringVar() + self.max_brix_map_entry = ttk.Entry(config_frame, textvariable=self.max_brix_map_var, width=10) + self.max_brix_map_entry.grid(row=1, column=3, padx=5, pady=5) + + # Botones + ttk.Button(config_frame, text="Guardar Config", + command=self.save_config).grid(row=1, column=4, padx=5, pady=5) + ttk.Button(config_frame, text="Cargar Config", + command=self.load_config).grid(row=1, column=5, padx=5, pady=5) + + # Guardar referencias para compartir + self.shared_config.update({ + 'connection_type_var': self.connection_type_var, + 'com_port_var': self.com_port_var, + 'baud_rate_var': self.baud_rate_var, + 'ip_address_var': self.ip_address_var, + 'port_var': self.port_var, + 'min_brix_map_var': self.min_brix_map_var, + 'max_brix_map_var': self.max_brix_map_var, + 'shared_widgets': [ + self.connection_type_combo, + self.com_port_entry, + self.baud_rate_entry, + self.ip_address_entry, + self.port_entry, + self.min_brix_map_entry, + self.max_brix_map_entry + ] + }) + + def create_graphs(self): + """Crea los gráficos para simulador y trace""" + # Gráfico del simulador + sim_graph_frame = self.simulator_tab.get_graph_frame() + + self.sim_fig = Figure(figsize=(8, 3.5), dpi=100) + self.sim_ax1 = self.sim_fig.add_subplot(111) + self.sim_ax2 = self.sim_ax1.twinx() + + self.sim_ax1.set_xlabel('Tiempo (s)') + self.sim_ax1.set_ylabel('Brix', color='b') + self.sim_ax2.set_ylabel('mA', color='r') + self.sim_ax1.tick_params(axis='y', labelcolor='b') + self.sim_ax2.tick_params(axis='y', labelcolor='r') + self.sim_ax1.grid(True, alpha=0.3) + + self.sim_line_brix, = self.sim_ax1.plot([], [], 'b-', label='Brix', linewidth=2) + self.sim_line_ma, = self.sim_ax2.plot([], [], 'r-', label='mA', linewidth=2) + + self.sim_canvas = FigureCanvasTkAgg(self.sim_fig, master=sim_graph_frame) + self.sim_canvas.draw() + self.sim_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Gráfico del trace (ahora con doble eje Y) + trace_graph_frame = self.trace_tab.get_graph_frame() + + self.trace_fig = Figure(figsize=(8, 4), dpi=100) + self.trace_ax1 = self.trace_fig.add_subplot(111) + self.trace_ax2 = self.trace_ax1.twinx() + + self.trace_ax1.set_xlabel('Tiempo (s)') + self.trace_ax1.set_ylabel('Brix', color='b') + self.trace_ax2.set_ylabel('mA', color='r') + self.trace_ax1.tick_params(axis='y', labelcolor='b') + self.trace_ax2.tick_params(axis='y', labelcolor='r') + self.trace_ax1.grid(True, alpha=0.3) + + self.trace_line_brix, = self.trace_ax1.plot([], [], 'b-', label='Brix', linewidth=2, marker='o', markersize=4) + self.trace_line_ma, = self.trace_ax2.plot([], [], 'r-', label='mA', linewidth=2, marker='s', markersize=3) + + self.trace_canvas = FigureCanvasTkAgg(self.trace_fig, master=trace_graph_frame) + self.trace_canvas.draw() + self.trace_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + def update_sim_graph(self, frame=None): + """Actualiza el gráfico del simulador""" + time_data = list(self.simulator_tab.time_data) + brix_data = list(self.simulator_tab.brix_data) + ma_data = list(self.simulator_tab.ma_data) + + if len(time_data) > 0: + self.sim_line_brix.set_data(time_data, brix_data) + self.sim_line_ma.set_data(time_data, ma_data) + + if len(time_data) > 1: + self.sim_ax1.set_xlim(min(time_data), max(time_data)) + + if brix_data: + brix_min = min(brix_data) - 1 + brix_max = max(brix_data) + 1 + self.sim_ax1.set_ylim(brix_min, brix_max) + + if ma_data: + ma_min = min(ma_data) - 0.5 + ma_max = max(ma_data) + 0.5 + self.sim_ax2.set_ylim(ma_min, ma_max) + + self.sim_canvas.draw_idle() + + return self.sim_line_brix, self.sim_line_ma + + def update_trace_graph(self, frame=None): + """Actualiza el gráfico del trace""" + time_data = list(self.trace_tab.time_data) + brix_data = list(self.trace_tab.brix_data) + ma_data = list(self.trace_tab.ma_data) + + if len(time_data) > 0: + self.trace_line_brix.set_data(time_data, brix_data) + self.trace_line_ma.set_data(time_data, ma_data) + + if len(time_data) > 1: + self.trace_ax1.set_xlim(min(time_data), max(time_data)) + + if brix_data: + brix_min = min(brix_data) - 1 + brix_max = max(brix_data) + 1 + self.trace_ax1.set_ylim(brix_min, brix_max) + + if ma_data: + ma_min = min(ma_data) - 0.5 + ma_max = max(ma_data) + 0.5 + self.trace_ax2.set_ylim(ma_min, ma_max) + + self.trace_canvas.draw_idle() + + return self.trace_line_brix, self.trace_line_ma + + def on_connection_type_change(self, event=None): + """Maneja el cambio de tipo de conexión""" + conn_type = self.connection_type_var.get() + if conn_type == "Serial": + self.ethernet_frame.grid_remove() + self.serial_frame.grid() + else: + self.serial_frame.grid_remove() + self.ethernet_frame.grid() + + # Actualizar info en NetCom + if hasattr(self, 'netcom_tab'): + self.netcom_tab.update_net_info() + + def save_config(self): + """Guarda la configuración actual""" + # Recopilar configuración de todos los componentes + config = { + 'connection_type': self.connection_type_var.get(), + 'com_port': self.com_port_var.get(), + 'baud_rate': self.baud_rate_var.get(), + 'ip_address': self.ip_address_var.get(), + 'port': self.port_var.get(), + 'min_brix_map': self.min_brix_map_var.get(), + 'max_brix_map': self.max_brix_map_var.get() + } + + # Agregar configuración de cada tab + config.update(self.simulator_tab.get_config()) + config.update(self.netcom_tab.get_config()) + + # Validar configuración + errors = self.config_manager.validate_config(config) + if errors: + messagebox.showerror("Error de Configuración", "\n".join(errors)) + return + + # Guardar + if self.config_manager.save_config(config): + messagebox.showinfo("Éxito", "Configuración guardada correctamente.") + else: + messagebox.showerror("Error", "No se pudo guardar la configuración.") + + def load_config(self): + """Carga la configuración desde archivo""" + self.config = self.config_manager.load_config() + self.load_config_to_gui() + messagebox.showinfo("Éxito", "Configuración cargada correctamente.") + + def load_config_to_gui(self): + """Carga la configuración en los widgets de la GUI""" + # Configuración compartida + self.connection_type_var.set(self.config.get('connection_type', 'Serial')) + self.com_port_var.set(self.config.get('com_port', 'COM3')) + self.baud_rate_var.set(self.config.get('baud_rate', '115200')) + self.ip_address_var.set(self.config.get('ip_address', '192.168.1.100')) + self.port_var.set(self.config.get('port', '502')) + self.min_brix_map_var.set(self.config.get('min_brix_map', '0')) + self.max_brix_map_var.set(self.config.get('max_brix_map', '80')) + + # Configuración específica de cada tab + self.simulator_tab.set_config(self.config) + self.netcom_tab.set_config(self.config) + + # Actualizar vista + self.on_connection_type_change() + + def on_closing(self): + """Maneja el cierre de la aplicación""" + # Detener cualquier operación activa + if hasattr(self.simulator_tab, 'simulating') and self.simulator_tab.simulating: + self.simulator_tab.stop_simulation() + + if hasattr(self.trace_tab, 'tracing') and self.trace_tab.tracing: + self.trace_tab.stop_trace() + + if hasattr(self.netcom_tab, 'bridging') and self.netcom_tab.bridging: + self.netcom_tab.stop_bridge() + + # Cerrar ventana + self.root.destroy() diff --git a/maselli_simulator_config.json b/maselli_simulator_config.json index 39d73fd..51834f5 100644 --- a/maselli_simulator_config.json +++ b/maselli_simulator_config.json @@ -4,10 +4,13 @@ "baud_rate": "115200", "ip_address": "10.1.33.18", "port": "8899", - "adam_address": "01", - "function_type": "Sinusoidal", "min_brix_map": "0", "max_brix_map": "80", - "period": "0.5", - "manual_brix": "10.0" + "adam_address": "01", + "function_type": "Manual", + "cycle_time": "10.0", + "samples_per_cycle": "100", + "manual_brix": "10.0", + "netcom_com_port": "COM3", + "netcom_baud_rate": "115200" } \ No newline at end of file diff --git a/protocol_handler.py b/protocol_handler.py new file mode 100644 index 0000000..ca9a53b --- /dev/null +++ b/protocol_handler.py @@ -0,0 +1,121 @@ +""" +Manejador del protocolo ADAM/Maselli +Contiene las funciones para formatear mensajes, calcular checksums y parsear respuestas +""" + +class ProtocolHandler: + @staticmethod + def calculate_checksum(message_part): + """Calcula el checksum de un mensaje ADAM""" + s = sum(ord(c) for c in message_part) + checksum_byte = s % 256 + return f"{checksum_byte:02X}" + + @staticmethod + def format_ma_value(ma_val): + """Formatea un valor mA al formato ADAM: XX.XXX (6 caracteres)""" + return f"{ma_val:06.3f}" + + @staticmethod + def scale_to_ma(brix_value, min_brix_map, max_brix_map): + """Convierte valor Brix a mA usando el mapeo configurado""" + if max_brix_map == min_brix_map: + return 4.0 + + percentage = (brix_value - min_brix_map) / (max_brix_map - min_brix_map) + percentage = max(0.0, min(1.0, percentage)) + + ma_value = 4.0 + percentage * 16.0 + return ma_value + + @staticmethod + def ma_to_brix(ma_value, min_brix_map, max_brix_map): + """Convierte valor mA a Brix usando el mapeo configurado""" + try: + if ma_value <= 4.0: + return min_brix_map + elif ma_value >= 20.0: + return max_brix_map + else: + # Interpolación lineal + percentage = (ma_value - 4.0) / 16.0 + return min_brix_map + percentage * (max_brix_map - min_brix_map) + except: + return 0.0 + + @staticmethod + def create_adam_message(adam_address, brix_value, min_brix_map, max_brix_map): + """Crea un mensaje completo ADAM a partir de un valor Brix""" + ma_val = ProtocolHandler.scale_to_ma(brix_value, min_brix_map, max_brix_map) + ma_str = ProtocolHandler.format_ma_value(ma_val) + + message_part = f"#{adam_address}{ma_str}" + checksum = ProtocolHandler.calculate_checksum(message_part) + full_message = f"{message_part}{checksum}\r" + + return full_message, ma_val + + @staticmethod + def parse_adam_message(data): + """ + Parsea un mensaje del protocolo ADAM y retorna el valor en mA + Formato esperado: #AA[valor_mA][checksum]\r + Donde: + - # : Carácter inicial (opcional en algunas respuestas) + - AA : Dirección del dispositivo (2 caracteres) + - valor_mA : Valor en mA (6 caracteres, formato XX.XXX) + - checksum : Suma de verificación (2 caracteres hex) + - \r : Carácter de fin (opcional) + + Retorna: dict con 'address' y 'ma', o None si no es válido + """ + try: + # Formato esperado: #AA[valor_mA][checksum]\r + # Pero también manejar respuestas sin # inicial o sin \r final + data = data.strip() + + # Si empieza con #, es un mensaje estándar + if data.startswith('#'): + data = data[1:] # Remover # + + # Si termina con \r, removerlo + if data.endswith('\r'): + data = data[:-1] + + # Verificar longitud mínima + if len(data) < 8: # 2 addr + 6 valor mínimo + return None + + address = data[:2] + value_str = data[2:8] # 6 caracteres para el valor (XX.XXX) + + # Verificar si hay checksum + checksum_valid = True + if len(data) >= 10: + checksum = data[8:10] # 2 caracteres para checksum + + # Verificar checksum + message_part = f"#{address}{value_str}" + calculated_checksum = ProtocolHandler.calculate_checksum(message_part) + + if checksum != calculated_checksum: + checksum_valid = False + + # Convertir valor a float + try: + ma_value = float(value_str) + return { + 'address': address, + 'ma': ma_value, + 'checksum_valid': checksum_valid + } + except ValueError: + return None + + except Exception: + return None + + @staticmethod + def format_for_display(message): + """Formatea un mensaje para mostrar en el log (reemplaza caracteres no imprimibles)""" + return message.replace('\r', '').replace('\n', '').replace('\t', '') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1cc866e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# Dependencias para Maselli Simulator/Trace/NetCom +pyserial==3.5 +matplotlib==3.7.1 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..7c04bc7 --- /dev/null +++ b/run.bat @@ -0,0 +1,4 @@ +@echo off +echo Iniciando Maselli Simulator/Trace/NetCom... +python main.py +pause diff --git a/tabs/__init__.py b/tabs/__init__.py new file mode 100644 index 0000000..5954923 --- /dev/null +++ b/tabs/__init__.py @@ -0,0 +1 @@ +# Paquete para los tabs de la aplicación \ No newline at end of file diff --git a/tabs/netcom_tab.py b/tabs/netcom_tab.py new file mode 100644 index 0000000..a005dc8 --- /dev/null +++ b/tabs/netcom_tab.py @@ -0,0 +1,453 @@ +""" +Tab NetCom - Gateway/Bridge entre puerto COM físico y conexión TCP/UDP +""" + +import tkinter as tk +from tkinter import ttk, scrolledtext, messagebox +import threading +import time +from datetime import datetime + +from connection_manager import ConnectionManager +from protocol_handler import ProtocolHandler +from utils import Utils + +class NetComTab: + def __init__(self, parent_frame, shared_config): + self.frame = parent_frame + self.shared_config = shared_config + + # Estado del gateway + self.bridging = False + self.bridge_thread = None + + # Conexiones + self.com_connection = ConnectionManager() + self.net_connection = ConnectionManager() + + # Estadísticas + self.com_to_net_count = 0 + self.net_to_com_count = 0 + self.error_count = 0 + + self.create_widgets() + + def create_widgets(self): + """Crea los widgets del tab NetCom""" + # Frame de configuración COM física + com_config_frame = ttk.LabelFrame(self.frame, text="Configuración Puerto COM Físico") + com_config_frame.grid(row=0, column=0, columnspan=2, padx=10, pady=5, sticky="ew") + + ttk.Label(com_config_frame, text="Puerto COM:").grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.com_port_var = tk.StringVar(value=self.shared_config.get('netcom_com_port', 'COM3')) + self.com_port_entry = ttk.Entry(com_config_frame, textvariable=self.com_port_var, width=10) + self.com_port_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + ttk.Label(com_config_frame, text="Baud Rate:").grid(row=0, column=2, padx=5, pady=5, sticky="w") + self.baud_rate_var = tk.StringVar(value=self.shared_config.get('netcom_baud_rate', '115200')) + self.baud_rate_entry = ttk.Entry(com_config_frame, textvariable=self.baud_rate_var, width=10) + self.baud_rate_entry.grid(row=0, column=3, padx=5, pady=5, sticky="ew") + + # Info frame + info_frame = ttk.LabelFrame(self.frame, text="Información de Conexión") + info_frame.grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky="ew") + + ttk.Label(info_frame, text="Conexión de Red:").grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.net_info_var = tk.StringVar(value="No configurada") + ttk.Label(info_frame, textvariable=self.net_info_var, font=("Courier", 10)).grid(row=0, column=1, padx=5, pady=5, sticky="w") + + ttk.Label(info_frame, text="Estado:").grid(row=1, column=0, padx=5, pady=5, sticky="w") + self.status_var = tk.StringVar(value="Desconectado") + self.status_label = ttk.Label(info_frame, textvariable=self.status_var, font=("Courier", 10, "bold")) + self.status_label.grid(row=1, column=1, padx=5, pady=5, sticky="w") + + # Control Frame + control_frame = ttk.LabelFrame(self.frame, text="Control Gateway") + control_frame.grid(row=2, column=0, padx=10, pady=5, sticky="ew") + + self.start_button = ttk.Button(control_frame, text="Iniciar Gateway", command=self.start_bridge) + self.start_button.pack(side=tk.LEFT, padx=5) + + self.stop_button = ttk.Button(control_frame, text="Detener Gateway", command=self.stop_bridge, state=tk.DISABLED) + self.stop_button.pack(side=tk.LEFT, padx=5) + + self.clear_log_button = ttk.Button(control_frame, text="Limpiar Log", command=self.clear_log) + self.clear_log_button.pack(side=tk.LEFT, padx=5) + + # Statistics Frame + stats_frame = ttk.LabelFrame(self.frame, text="Estadísticas") + stats_frame.grid(row=2, column=1, padx=10, pady=5, sticky="ew") + + ttk.Label(stats_frame, text="COM → NET:").grid(row=0, column=0, padx=5, pady=2, sticky="w") + self.com_to_net_var = tk.StringVar(value="0") + ttk.Label(stats_frame, textvariable=self.com_to_net_var).grid(row=0, column=1, padx=5, pady=2, sticky="w") + + ttk.Label(stats_frame, text="NET → COM:").grid(row=0, column=2, padx=5, pady=2, sticky="w") + self.net_to_com_var = tk.StringVar(value="0") + ttk.Label(stats_frame, textvariable=self.net_to_com_var).grid(row=0, column=3, padx=5, pady=2, sticky="w") + + ttk.Label(stats_frame, text="Errores:").grid(row=1, column=0, padx=5, pady=2, sticky="w") + self.errors_var = tk.StringVar(value="0") + ttk.Label(stats_frame, textvariable=self.errors_var, foreground="red").grid(row=1, column=1, padx=5, pady=2, sticky="w") + + # Log Frame con filtros + log_control_frame = ttk.Frame(self.frame) + log_control_frame.grid(row=3, column=0, columnspan=2, padx=10, pady=(10,0), sticky="ew") + + ttk.Label(log_control_frame, text="Filtros:").pack(side=tk.LEFT, padx=5) + + self.show_com_to_net_var = tk.BooleanVar(value=True) + ttk.Checkbutton(log_control_frame, text="COM→NET", + variable=self.show_com_to_net_var).pack(side=tk.LEFT, padx=5) + + self.show_net_to_com_var = tk.BooleanVar(value=True) + ttk.Checkbutton(log_control_frame, text="NET→COM", + variable=self.show_net_to_com_var).pack(side=tk.LEFT, padx=5) + + self.show_parsed_var = tk.BooleanVar(value=True) + ttk.Checkbutton(log_control_frame, text="Mostrar datos parseados", + variable=self.show_parsed_var).pack(side=tk.LEFT, padx=5) + + # Log Frame + log_frame = ttk.LabelFrame(self.frame, text="Log de Gateway (Sniffer)") + log_frame.grid(row=4, column=0, columnspan=2, padx=10, pady=5, sticky="nsew") + + self.log_text = scrolledtext.ScrolledText(log_frame, height=20, width=80, wrap=tk.WORD, state=tk.DISABLED) + self.log_text.pack(padx=5, pady=5, fill=tk.BOTH, expand=True) + + # Configurar tags para colores + self.log_text.tag_config("com_to_net", foreground="blue") + self.log_text.tag_config("net_to_com", foreground="green") + self.log_text.tag_config("error", foreground="red") + self.log_text.tag_config("info", foreground="black") + self.log_text.tag_config("parsed", foreground="purple") + + # Configurar pesos + self.frame.columnconfigure(0, weight=1) + self.frame.columnconfigure(1, weight=1) + self.frame.rowconfigure(4, weight=1) + + # Actualizar info de red + self.update_net_info() + + def update_net_info(self): + """Actualiza la información de la conexión de red configurada""" + conn_type = self.shared_config['connection_type_var'].get() + if conn_type == "Serial": + port = self.shared_config['com_port_var'].get() + baud = self.shared_config['baud_rate_var'].get() + self.net_info_var.set(f"Serial: {port} @ {baud} bps") + elif conn_type == "TCP": + ip = self.shared_config['ip_address_var'].get() + port = self.shared_config['port_var'].get() + self.net_info_var.set(f"TCP: {ip}:{port}") + elif conn_type == "UDP": + ip = self.shared_config['ip_address_var'].get() + port = self.shared_config['port_var'].get() + self.net_info_var.set(f"UDP: {ip}:{port}") + + def log_message(self, message, tag="info", force=False): + """Log con formato especial para el sniffer""" + # Verificar filtros + if not force: + if tag == "com_to_net" and not self.show_com_to_net_var.get(): + return + if tag == "net_to_com" and not self.show_net_to_com_var.get(): + return + + self.log_text.configure(state=tk.NORMAL) + timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3] + + # Agregar prefijo según la dirección + if tag == "com_to_net": + prefix = "[COM→NET]" + elif tag == "net_to_com": + prefix = "[NET→COM]" + elif tag == "error": + prefix = "[ERROR] " + elif tag == "parsed": + prefix = "[PARSED] " + else: + prefix = "[INFO] " + + full_message = f"[{timestamp}] {prefix} {message}\n" + + # Insertar con color + start_index = self.log_text.index(tk.END) + self.log_text.insert(tk.END, full_message) + end_index = self.log_text.index(tk.END) + self.log_text.tag_add(tag, start_index, end_index) + + self.log_text.see(tk.END) + self.log_text.configure(state=tk.DISABLED) + + def start_bridge(self): + """Inicia el gateway/bridge""" + if self.bridging: + messagebox.showwarning("Advertencia", "El gateway ya está activo.") + return + + # Actualizar info de red + self.update_net_info() + + # Validar configuración + try: + com_port = self.com_port_var.get() + baud_rate = int(self.baud_rate_var.get()) + + if not com_port.upper().startswith('COM'): + raise ValueError("Puerto COM inválido") + if baud_rate <= 0: + raise ValueError("Baud rate debe ser mayor que 0") + + except ValueError as e: + messagebox.showerror("Error", f"Configuración inválida: {e}") + return + + # Abrir conexión COM física + try: + self.com_connection.open_connection("Serial", { + 'port': com_port, + 'baud': baud_rate + }) + self.log_message(f"Puerto COM abierto: {com_port} @ {baud_rate} bps") + except Exception as e: + messagebox.showerror("Error", f"No se pudo abrir puerto COM: {e}") + return + + # Abrir conexión de red + try: + # For the network side of the bridge, use shared connection settings + net_conn_type_actual = self.shared_config['connection_type_var'].get() + + current_shared_config_values = { + 'connection_type': self.shared_config['connection_type_var'].get(), + 'com_port': self.shared_config['com_port_var'].get(), + 'baud_rate': self.shared_config['baud_rate_var'].get(), + 'ip_address': self.shared_config['ip_address_var'].get(), + 'port': self.shared_config['port_var'].get(), + } + # The first argument to get_connection_params is the dictionary it will read from. + net_conn_params = self.shared_config['config_manager'].get_connection_params(current_shared_config_values) + + self.net_connection.open_connection(net_conn_type_actual, net_conn_params) + self.log_message(f"Conexión {net_conn_type_actual} abierta: {self.net_info_var.get()}") + except Exception as e: + self.com_connection.close_connection() + messagebox.showerror("Error", f"No se pudo abrir conexión de red: {e}") + return + + # Resetear estadísticas + self.com_to_net_count = 0 + self.net_to_com_count = 0 + self.error_count = 0 + self.update_stats() + + # Iniciar bridge + self.bridging = True + self.status_var.set("Conectado") + self.status_label.config(foreground="green") + self.start_button.config(state=tk.DISABLED) + self.stop_button.config(state=tk.NORMAL) + self._set_entries_state(tk.DISABLED) + + self.bridge_thread = threading.Thread(target=self.run_bridge, daemon=True) + self.bridge_thread.start() + + self.log_message("Gateway iniciado - Modo bridge activo") + + def stop_bridge(self): + """Detiene el gateway/bridge""" + if not self.bridging: + return + + self.bridging = False + + # Esperar a que termine el thread + if self.bridge_thread and self.bridge_thread.is_alive(): + self.bridge_thread.join(timeout=2.0) + + # Cerrar conexiones + self.com_connection.close_connection() + self.net_connection.close_connection() + + self.status_var.set("Desconectado") + self.status_label.config(foreground="black") + self.start_button.config(state=tk.NORMAL) + self.stop_button.config(state=tk.DISABLED) + self._set_entries_state(tk.NORMAL) + + self.log_message("Gateway detenido") + self.log_message(f"Total transferencias - COM→NET: {self.com_to_net_count}, NET→COM: {self.net_to_com_count}, Errores: {self.error_count}") + + def run_bridge(self): + """Thread principal del bridge""" + com_buffer = "" + net_buffer = "" + + while self.bridging: + try: + # Leer del COM físico + com_data = self.com_connection.read_data_non_blocking() + if com_data: + com_buffer += com_data + + # Buscar mensajes completos para logging + while '\r' in com_buffer or '\n' in com_buffer or len(com_buffer) >= 10: + end_idx = self._find_message_end(com_buffer) + if end_idx > 0: + message = com_buffer[:end_idx] + com_buffer = com_buffer[end_idx:] + + # Log y parseo + display_msg = ProtocolHandler.format_for_display(message) + self.log_message(f"Data: {display_msg}", "com_to_net") + + # Intentar parsear si está habilitado + if self.show_parsed_var.get(): + parsed = ProtocolHandler.parse_adam_message(message) + if parsed: + # Obtener valores de mapeo + min_brix = float(self.shared_config['min_brix_map_var'].get()) + max_brix = float(self.shared_config['max_brix_map_var'].get()) + brix_value = ProtocolHandler.ma_to_brix(parsed['ma'], min_brix, max_brix) + + self.log_message( + f"ADAM - Addr: {parsed['address']}, " + f"mA: {parsed['ma']:.3f}, " + f"Brix: {brix_value:.3f}, " + f"Checksum: {'OK' if parsed.get('checksum_valid', True) else 'ERROR'}", + "parsed" + ) + + # Reenviar a la red + try: + self.net_connection.send_data(message) + self.com_to_net_count += 1 + self.update_stats() + except Exception as e: + self.log_message(f"Error enviando a red: {e}", "error") + self.error_count += 1 + self.update_stats() + else: + break + + # Leer de la red + net_data = self.net_connection.read_data_non_blocking() + if net_data: + net_buffer += net_data + + # Buscar mensajes completos para logging + while '\r' in net_buffer or '\n' in net_buffer or len(net_buffer) >= 10: + end_idx = self._find_message_end(net_buffer) + if end_idx > 0: + message = net_buffer[:end_idx] + net_buffer = net_buffer[end_idx:] + + # Log y parseo + display_msg = ProtocolHandler.format_for_display(message) + self.log_message(f"Data: {display_msg}", "net_to_com") + + # Intentar parsear si está habilitado + if self.show_parsed_var.get(): + parsed = ProtocolHandler.parse_adam_message(message) + if parsed: + # Obtener valores de mapeo + min_brix = float(self.shared_config['min_brix_map_var'].get()) + max_brix = float(self.shared_config['max_brix_map_var'].get()) + brix_value = ProtocolHandler.ma_to_brix(parsed['ma'], min_brix, max_brix) + + self.log_message( + f"ADAM - Addr: {parsed['address']}, " + f"mA: {parsed['ma']:.3f}, " + f"Brix: {brix_value:.3f}, " + f"Checksum: {'OK' if parsed.get('checksum_valid', True) else 'ERROR'}", + "parsed" + ) + + # Reenviar al COM + try: + self.com_connection.send_data(message) + self.net_to_com_count += 1 + self.update_stats() + except Exception as e: + self.log_message(f"Error enviando a COM: {e}", "error") + self.error_count += 1 + self.update_stats() + else: + break + + except Exception as e: + if self.bridging: + self.log_message(f"Error en bridge: {e}", "error") + self.error_count += 1 + self.update_stats() + break + + # Pequeña pausa para no consumir demasiado CPU + if not com_data and not net_data: + time.sleep(0.001) + + # Asegurar que el estado se actualice + if not self.bridging: + self.frame.after(0, self._ensure_stopped_state) + + def _find_message_end(self, buffer): + """Encuentra el final de un mensaje en el buffer""" + # Buscar terminadores + for i, char in enumerate(buffer): + if char in ['\r', '\n']: + return i + 1 + + # Si no hay terminador pero el buffer es largo, buscar mensaje ADAM completo + if len(buffer) >= 10: + if buffer[0] == '#' or (buffer[2:8].replace('.', '').replace(' ', '').replace('-', '').isdigit()): + # Parece un mensaje ADAM + if len(buffer) > 10 and buffer[10] in ['\r', '\n']: + return 11 + else: + return 10 + + return -1 + + def update_stats(self): + """Actualiza las estadísticas en la GUI""" + self.com_to_net_var.set(str(self.com_to_net_count)) + self.net_to_com_var.set(str(self.net_to_com_count)) + self.errors_var.set(str(self.error_count)) + + def clear_log(self): + """Limpia el log""" + self.log_text.configure(state=tk.NORMAL) + self.log_text.delete(1.0, tk.END) + self.log_text.configure(state=tk.DISABLED) + self.log_message("Log limpiado", force=True) + + def _set_entries_state(self, state): + """Habilita/deshabilita los controles durante el bridge""" + self.com_port_entry.config(state=state) + self.baud_rate_entry.config(state=state) + + # También deshabilitar controles compartidos + if 'shared_widgets' in self.shared_config: + Utils.set_widgets_state(self.shared_config['shared_widgets'], state) + + def _ensure_stopped_state(self): + """Asegura que la GUI refleje el estado detenido""" + self.status_var.set("Desconectado") + self.status_label.config(foreground="black") + self.start_button.config(state=tk.NORMAL) + self.stop_button.config(state=tk.DISABLED) + self._set_entries_state(tk.NORMAL) + + def get_config(self): + """Obtiene la configuración actual del NetCom""" + return { + 'netcom_com_port': self.com_port_var.get(), + 'netcom_baud_rate': self.baud_rate_var.get() + } + + def set_config(self, config): + """Establece la configuración del NetCom""" + self.com_port_var.set(config.get('netcom_com_port', 'COM3')) + self.baud_rate_var.set(config.get('netcom_baud_rate', '115200')) diff --git a/tabs/simulator_tab.py b/tabs/simulator_tab.py new file mode 100644 index 0000000..bdee397 --- /dev/null +++ b/tabs/simulator_tab.py @@ -0,0 +1,461 @@ +""" +Tab del Simulador - Genera valores de prueba en protocolo ADAM +""" + +import tkinter as tk +from tkinter import ttk, scrolledtext, messagebox +import threading +import time +import math +from collections import deque + +from protocol_handler import ProtocolHandler +from connection_manager import ConnectionManager +from utils import Utils + +class SimulatorTab: + def __init__(self, parent_frame, shared_config): + self.frame = parent_frame + self.shared_config = shared_config + + # Estado del simulador + self.simulating = False + self.simulation_thread = None + self.simulation_step = 0 + self.connection_manager = ConnectionManager() + + # Datos para el gráfico + self.max_points = 100 + self.time_data = deque(maxlen=self.max_points) + self.brix_data = deque(maxlen=self.max_points) + self.ma_data = deque(maxlen=self.max_points) + self.start_time = time.time() + + self.create_widgets() + + def create_widgets(self): + """Crea los widgets del tab simulador""" + # Frame de configuración del simulador + config_frame = ttk.LabelFrame(self.frame, text="Configuración Simulador") + config_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew", columnspan=2) + + # Dirección ADAM + ttk.Label(config_frame, text="ADAM Address (2c):").grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.adam_address_var = tk.StringVar(value=self.shared_config.get('adam_address', '01')) + self.adam_address_entry = ttk.Entry(config_frame, textvariable=self.adam_address_var, width=5) + self.adam_address_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + # Función + ttk.Label(config_frame, text="Función:").grid(row=0, column=2, padx=5, pady=5, sticky="w") + self.function_type_var = tk.StringVar(value=self.shared_config.get('function_type', 'Lineal')) + self.function_type_combo = ttk.Combobox(config_frame, textvariable=self.function_type_var, + values=["Lineal", "Sinusoidal", "Manual"], + state="readonly", width=10) + self.function_type_combo.grid(row=0, column=3, padx=5, pady=5, sticky="ew") + self.function_type_combo.bind("<>", self.on_function_type_change) + + # Tiempo de ciclo completo (nueva característica) + ttk.Label(config_frame, text="Tiempo Ciclo (s):").grid(row=0, column=4, padx=5, pady=5, sticky="w") + self.cycle_time_var = tk.StringVar(value=self.shared_config.get('cycle_time', '10.0')) + self.cycle_time_entry = ttk.Entry(config_frame, textvariable=self.cycle_time_var, width=8) + self.cycle_time_entry.grid(row=0, column=5, padx=5, pady=5, sticky="ew") + + # Velocidad de muestreo (calculada automáticamente) + ttk.Label(config_frame, text="Muestras/ciclo:").grid(row=0, column=6, padx=5, pady=5, sticky="w") + self.samples_per_cycle_var = tk.StringVar(value="100") + self.samples_per_cycle_entry = ttk.Entry(config_frame, textvariable=self.samples_per_cycle_var, width=8) + self.samples_per_cycle_entry.grid(row=0, column=7, padx=5, pady=5, sticky="ew") + + # Frame para modo Manual + manual_frame = ttk.LabelFrame(config_frame, text="Modo Manual") + manual_frame.grid(row=1, column=0, columnspan=8, padx=5, pady=5, sticky="ew") + + ttk.Label(manual_frame, text="Valor Brix:").grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.manual_brix_var = tk.StringVar(value=self.shared_config.get('manual_brix', '10.0')) + self.manual_brix_entry = ttk.Entry(manual_frame, textvariable=self.manual_brix_var, width=10, state=tk.DISABLED) + self.manual_brix_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + self.manual_brix_entry.bind('', lambda e: self.update_slider_from_entry()) + self.manual_brix_entry.bind('', lambda e: self.update_slider_from_entry()) + + # Slider + self.manual_slider_var = tk.DoubleVar(value=float(self.shared_config.get('manual_brix', '10.0'))) + self.manual_slider = ttk.Scale(manual_frame, from_=0, to=100, orient=tk.HORIZONTAL, + variable=self.manual_slider_var, command=self.on_slider_change, + state=tk.DISABLED, length=200) + self.manual_slider.grid(row=0, column=2, padx=5, pady=5, sticky="ew") + + self.manual_send_button = ttk.Button(manual_frame, text="Enviar Manual", + command=self.send_manual_value, state=tk.DISABLED) + self.manual_send_button.grid(row=0, column=3, padx=5, pady=5, sticky="ew") + + manual_frame.columnconfigure(2, weight=1) + + # Controls Frame + controls_frame = ttk.LabelFrame(self.frame, text="Control Simulación") + controls_frame.grid(row=1, column=0, padx=10, pady=5, sticky="ew") + + self.start_button = ttk.Button(controls_frame, text="Iniciar", command=self.start_simulation) + self.start_button.pack(side=tk.LEFT, padx=5) + + self.stop_button = ttk.Button(controls_frame, text="Detener", command=self.stop_simulation, state=tk.DISABLED) + self.stop_button.pack(side=tk.LEFT, padx=5) + + self.clear_graph_button = ttk.Button(controls_frame, text="Limpiar Gráfico", command=self.clear_graph) + self.clear_graph_button.pack(side=tk.LEFT, padx=5) + + # Display Frame + display_frame = ttk.LabelFrame(self.frame, text="Valores Actuales") + display_frame.grid(row=1, column=1, padx=10, pady=5, sticky="ew") + + ttk.Label(display_frame, text="Brix:").grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.current_brix_var = tk.StringVar(value="---") + ttk.Label(display_frame, textvariable=self.current_brix_var, + font=("Courier", 14, "bold")).grid(row=0, column=1, padx=5, pady=5, sticky="w") + + ttk.Label(display_frame, text="mA:").grid(row=1, column=0, padx=5, pady=5, sticky="w") + self.current_ma_var = tk.StringVar(value="--.-- mA") + ttk.Label(display_frame, textvariable=self.current_ma_var, + font=("Courier", 14, "bold")).grid(row=1, column=1, padx=5, pady=5, sticky="w") + + # Log Frame + log_frame = ttk.LabelFrame(self.frame, text="Log de Comunicación") + log_frame.grid(row=2, column=0, columnspan=2, padx=10, pady=5, sticky="nsew") + + self.log_text = scrolledtext.ScrolledText(log_frame, height=8, width=70, wrap=tk.WORD, state=tk.DISABLED) + self.log_text.pack(padx=5, pady=5, fill=tk.BOTH, expand=True) + + # Configurar pesos + self.frame.columnconfigure(0, weight=1) + self.frame.columnconfigure(1, weight=1) + self.frame.rowconfigure(2, weight=1) + + # Inicializar estado + self.on_function_type_change() + + def get_graph_frame(self): + """Crea y retorna el frame para el gráfico""" + graph_frame = ttk.LabelFrame(self.frame, text="Gráfico Simulador") + graph_frame.grid(row=3, column=0, columnspan=2, padx=10, pady=5, sticky="nsew") + self.frame.rowconfigure(3, weight=1) + return graph_frame + + def on_function_type_change(self, event=None): + """Maneja el cambio de tipo de función""" + func_type = self.function_type_var.get() + if func_type == "Manual": + if self.simulating: + self.stop_simulation() + + self.manual_brix_entry.config(state=tk.NORMAL) + self.manual_send_button.config(state=tk.NORMAL) + self.manual_slider.config(state=tk.NORMAL) + + self.cycle_time_entry.config(state=tk.DISABLED) + self.samples_per_cycle_entry.config(state=tk.DISABLED) + self.start_button.config(state=tk.DISABLED) + self.stop_button.config(state=tk.DISABLED) + else: + self.manual_brix_entry.config(state=tk.DISABLED) + self.manual_send_button.config(state=tk.DISABLED) + self.manual_slider.config(state=tk.DISABLED) + + self.cycle_time_entry.config(state=tk.NORMAL) + self.samples_per_cycle_entry.config(state=tk.NORMAL) + if not self.simulating: + self.start_button.config(state=tk.NORMAL) + self.stop_button.config(state=tk.DISABLED) + + def on_slider_change(self, value): + """Actualiza el valor del entry cuando cambia el slider""" + self.manual_brix_var.set(f"{float(value):.1f}") + + def update_slider_from_entry(self): + """Actualiza el slider cuando cambia el entry""" + try: + value = float(self.manual_brix_var.get()) + value = max(0, min(100, value)) + self.manual_slider_var.set(value) + self.manual_brix_var.set(f"{value:.1f}") + except ValueError: + pass + + def send_manual_value(self): + """Envía un valor manual único""" + try: + # Obtener valores de mapeo + min_brix = float(self.shared_config['min_brix_map_var'].get()) + max_brix = float(self.shared_config['max_brix_map_var'].get()) + adam_address = self.adam_address_var.get() + + if len(adam_address) != 2: + messagebox.showerror("Error", "La dirección ADAM debe tener 2 caracteres.") + return + + manual_brix = float(self.manual_brix_var.get()) + + # Crear mensaje + message, ma_value = ProtocolHandler.create_adam_message(adam_address, manual_brix, min_brix, max_brix) + + # Actualizar display + self.current_brix_var.set(Utils.format_brix_display(manual_brix)) + self.current_ma_var.set(Utils.format_ma_display(ma_value)) + + # Agregar al gráfico + self.add_data_point(manual_brix, ma_value) + + # Enviar por conexión temporal + # Construct a dictionary of current config values for get_connection_params + current_config_values = { + 'connection_type': self.shared_config['connection_type_var'].get(), + 'com_port': self.shared_config['com_port_var'].get(), + 'baud_rate': self.shared_config['baud_rate_var'].get(), + 'ip_address': self.shared_config['ip_address_var'].get(), + 'port': self.shared_config['port_var'].get(), + } + conn_type = current_config_values['connection_type'] + conn_params = self.shared_config['config_manager'].get_connection_params(current_config_values) + + temp_conn = ConnectionManager() + try: + temp_conn.open_connection(conn_type, conn_params) + Utils.log_message(self.log_text, f"Conexión {conn_type} abierta temporalmente.") + Utils.log_message(self.log_text, f"Enviando Manual: {ProtocolHandler.format_for_display(message)}") + + temp_conn.send_data(message) + + # Intentar leer respuesta + response = temp_conn.read_response(timeout=0.5) + if response and response.strip(): + Utils.log_message(self.log_text, f"Respuesta: {ProtocolHandler.format_for_display(response)}") + + parsed = ProtocolHandler.parse_adam_message(response) + if parsed: + brix_resp = ProtocolHandler.ma_to_brix(parsed['ma'], min_brix, max_brix) + Utils.log_message(self.log_text, + f" -> Addr: {parsed['address']}, " + f"mA: {parsed['ma']:.3f}, " + f"Brix: {brix_resp:.3f}") + + except Exception as e: + Utils.log_message(self.log_text, f"Error al enviar: {e}") + messagebox.showerror("Error", str(e)) + finally: + temp_conn.close_connection() + Utils.log_message(self.log_text, "Conexión cerrada.") + + except ValueError as e: + messagebox.showerror("Error", "Valores inválidos en la configuración.") + + def start_simulation(self): + """Inicia la simulación continua""" + if self.simulating: + messagebox.showwarning("Advertencia", "La simulación ya está en curso.") + return + + # Validar configuración + try: + adam_address = self.adam_address_var.get() + if len(adam_address) != 2: + messagebox.showerror("Error", "La dirección ADAM debe tener 2 caracteres.") + return + + cycle_time = float(self.cycle_time_var.get()) + if cycle_time <= 0: + messagebox.showerror("Error", "El tiempo de ciclo debe ser mayor que 0.") + return + + samples_per_cycle = int(self.samples_per_cycle_var.get()) + if samples_per_cycle <= 0: + messagebox.showerror("Error", "Las muestras por ciclo deben ser mayor que 0.") + return + + except ValueError: + messagebox.showerror("Error", "Valores inválidos en la configuración.") + return + + # Abrir conexión + try: + # Construct a dictionary of current config values for get_connection_params + current_config_values = { + 'connection_type': self.shared_config['connection_type_var'].get(), + 'com_port': self.shared_config['com_port_var'].get(), + 'baud_rate': self.shared_config['baud_rate_var'].get(), + 'ip_address': self.shared_config['ip_address_var'].get(), + 'port': self.shared_config['port_var'].get(), + } + conn_type = current_config_values['connection_type'] + conn_params = self.shared_config['config_manager'].get_connection_params(current_config_values) + + self.connection_manager.open_connection(conn_type, conn_params) + Utils.log_message(self.log_text, f"Conexión {conn_type} abierta para simulación.") + except Exception as e: + messagebox.showerror("Error de Conexión", str(e)) + return + + self.simulating = True + self.simulation_step = 0 + self.start_button.config(state=tk.DISABLED) + self.stop_button.config(state=tk.NORMAL) + self._set_entries_state(tk.DISABLED) + + self.simulation_thread = threading.Thread(target=self.run_simulation, daemon=True) + self.simulation_thread.start() + Utils.log_message(self.log_text, "Simulación iniciada.") + + def stop_simulation(self): + """Detiene la simulación""" + if not self.simulating: + return + + self.simulating = False + + if self.simulation_thread and self.simulation_thread.is_alive(): + self.simulation_thread.join(timeout=2.0) + + self.connection_manager.close_connection() + Utils.log_message(self.log_text, "Conexión cerrada.") + + self.start_button.config(state=tk.NORMAL) + self.stop_button.config(state=tk.DISABLED) + self._set_entries_state(tk.NORMAL) + self.on_function_type_change() + + Utils.log_message(self.log_text, "Simulación detenida.") + self.current_brix_var.set("---") + self.current_ma_var.set("--.-- mA") + + def run_simulation(self): + """Thread principal de simulación""" + try: + # Obtener parámetros + adam_address = self.adam_address_var.get() + min_brix = float(self.shared_config['min_brix_map_var'].get()) + max_brix = float(self.shared_config['max_brix_map_var'].get()) + function_type = self.function_type_var.get() + cycle_time = float(self.cycle_time_var.get()) + samples_per_cycle = int(self.samples_per_cycle_var.get()) + + # Calcular período entre muestras + sample_period = cycle_time / samples_per_cycle + + while self.simulating: + # Calcular valor actual según la función + progress = (self.simulation_step % samples_per_cycle) / samples_per_cycle + + if function_type == "Lineal": + # Onda triangular + cycle_progress = (self.simulation_step % (2 * samples_per_cycle)) / samples_per_cycle + if cycle_progress > 1.0: + cycle_progress = 2.0 - cycle_progress + current_brix = min_brix + (max_brix - min_brix) * cycle_progress + + elif function_type == "Sinusoidal": + phase = progress * 2 * math.pi + sin_val = (math.sin(phase) + 1) / 2 + current_brix = min_brix + (max_brix - min_brix) * sin_val + + # Crear y enviar mensaje + message, ma_value = ProtocolHandler.create_adam_message(adam_address, current_brix, min_brix, max_brix) + + # Actualizar display + self.current_brix_var.set(Utils.format_brix_display(current_brix)) + self.current_ma_var.set(Utils.format_ma_display(ma_value)) + + # Agregar al gráfico + self.frame.after(0, lambda b=current_brix, m=ma_value: self.add_data_point(b, m)) + + # Log y envío + Utils.log_message(self.log_text, f"Enviando: {ProtocolHandler.format_for_display(message)}") + + try: + self.connection_manager.send_data(message) + + # Leer respuesta sin bloquear demasiado + response = self.connection_manager.read_response(timeout=0.1) + if response and response.strip(): + Utils.log_message(self.log_text, f"Respuesta: {ProtocolHandler.format_for_display(response)}") + + parsed = ProtocolHandler.parse_adam_message(response) + if parsed: + brix_resp = ProtocolHandler.ma_to_brix(parsed['ma'], min_brix, max_brix) + Utils.log_message(self.log_text, + f" -> Addr: {parsed['address']}, " + f"mA: {parsed['ma']:.3f}, " + f"Brix: {brix_resp:.3f}") + + except Exception as e: + Utils.log_message(self.log_text, f"Error en comunicación: {e}") + self.frame.after(0, self.stop_simulation_error) + break + + self.simulation_step += 1 + time.sleep(sample_period) + + except Exception as e: + Utils.log_message(self.log_text, f"Error en simulación: {e}") + self.frame.after(0, self.stop_simulation_error) + + def stop_simulation_error(self): + """Detiene la simulación debido a un error""" + if self.simulating: + messagebox.showerror("Error", "Error durante la simulación. Simulación detenida.") + self.stop_simulation() + + def add_data_point(self, brix_value, ma_value): + """Agrega un punto de datos al gráfico""" + current_time = time.time() - self.start_time + self.time_data.append(current_time) + self.brix_data.append(brix_value) + self.ma_data.append(ma_value) + + # Notificar a la aplicación principal para actualizar el gráfico + if hasattr(self, 'graph_update_callback'): + self.graph_update_callback() + + def clear_graph(self): + """Limpia los datos del gráfico""" + Utils.clear_graph_data(self.time_data, self.brix_data, self.ma_data) + self.start_time = time.time() + Utils.log_message(self.log_text, "Gráfico limpiado.") + + if hasattr(self, 'graph_update_callback'): + self.graph_update_callback() + + def _set_entries_state(self, state): + """Habilita/deshabilita los controles durante la simulación""" + widgets = [ + self.adam_address_entry, + self.function_type_combo, + self.cycle_time_entry, + self.samples_per_cycle_entry + ] + Utils.set_widgets_state(widgets, state) + + # También deshabilitar controles compartidos + if 'shared_widgets' in self.shared_config: + Utils.set_widgets_state(self.shared_config['shared_widgets'], state) + + def get_config(self): + """Obtiene la configuración actual del simulador""" + return { + 'adam_address': self.adam_address_var.get(), + 'function_type': self.function_type_var.get(), + 'cycle_time': self.cycle_time_var.get(), + 'samples_per_cycle': self.samples_per_cycle_var.get(), + 'manual_brix': self.manual_brix_var.get() + } + + def set_config(self, config): + """Establece la configuración del simulador""" + self.adam_address_var.set(config.get('adam_address', '01')) + self.function_type_var.set(config.get('function_type', 'Lineal')) + self.cycle_time_var.set(config.get('cycle_time', '10.0')) + self.samples_per_cycle_var.set(config.get('samples_per_cycle', '100')) + self.manual_brix_var.set(config.get('manual_brix', '10.0')) + + try: + self.manual_slider_var.set(float(config.get('manual_brix', '10.0'))) + except: + pass + + self.on_function_type_change() diff --git a/tabs/trace_tab.py b/tabs/trace_tab.py new file mode 100644 index 0000000..094b09c --- /dev/null +++ b/tabs/trace_tab.py @@ -0,0 +1,336 @@ +""" +Tab del Trace - Escucha datos de un dispositivo Maselli real +""" + +import tkinter as tk +from tkinter import ttk, scrolledtext, messagebox +import threading +import time +import csv +from collections import deque +from datetime import datetime + +from protocol_handler import ProtocolHandler +from connection_manager import ConnectionManager +from utils import Utils + +class TraceTab: + def __init__(self, parent_frame, shared_config): + self.frame = parent_frame + self.shared_config = shared_config + + # Estado del trace + self.tracing = False + self.trace_thread = None + self.connection_manager = ConnectionManager() + + # Archivo CSV + self.csv_file = None + self.csv_writer = None + + # Datos para el gráfico (ahora con mA también) + self.max_points = 100 + self.time_data = deque(maxlen=self.max_points) + self.brix_data = deque(maxlen=self.max_points) + self.ma_data = deque(maxlen=self.max_points) # Nueva línea para mA + self.start_time = time.time() + + self.create_widgets() + + def create_widgets(self): + """Crea los widgets del tab trace""" + # Control Frame + control_frame = ttk.LabelFrame(self.frame, text="Control Trace") + control_frame.grid(row=0, column=0, columnspan=2, padx=10, pady=5, sticky="ew") + + self.start_button = ttk.Button(control_frame, text="Iniciar Trace", command=self.start_trace) + self.start_button.pack(side=tk.LEFT, padx=5) + + self.stop_button = ttk.Button(control_frame, text="Detener Trace", command=self.stop_trace, state=tk.DISABLED) + self.stop_button.pack(side=tk.LEFT, padx=5) + + self.clear_graph_button = ttk.Button(control_frame, text="Limpiar Gráfico", command=self.clear_graph) + self.clear_graph_button.pack(side=tk.LEFT, padx=5) + + ttk.Label(control_frame, text="Archivo CSV:").pack(side=tk.LEFT, padx=(20, 5)) + self.csv_filename_var = tk.StringVar(value="Sin archivo") + ttk.Label(control_frame, textvariable=self.csv_filename_var).pack(side=tk.LEFT, padx=5) + + # Display Frame + display_frame = ttk.LabelFrame(self.frame, text="Último Valor Recibido") + display_frame.grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky="ew") + + ttk.Label(display_frame, text="Timestamp:").grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.timestamp_var = tk.StringVar(value="---") + ttk.Label(display_frame, textvariable=self.timestamp_var, + font=("Courier", 12)).grid(row=0, column=1, padx=5, pady=5, sticky="w") + + ttk.Label(display_frame, text="Dirección:").grid(row=0, column=2, padx=5, pady=5, sticky="w") + self.address_var = tk.StringVar(value="--") + ttk.Label(display_frame, textvariable=self.address_var, + font=("Courier", 12)).grid(row=0, column=3, padx=5, pady=5, sticky="w") + + ttk.Label(display_frame, text="Valor mA:").grid(row=1, column=0, padx=5, pady=5, sticky="w") + self.ma_var = tk.StringVar(value="---") + ttk.Label(display_frame, textvariable=self.ma_var, + font=("Courier", 12, "bold"), foreground="red").grid(row=1, column=1, padx=5, pady=5, sticky="w") + + ttk.Label(display_frame, text="Valor Brix:").grid(row=1, column=2, padx=5, pady=5, sticky="w") + self.brix_var = tk.StringVar(value="---") + ttk.Label(display_frame, textvariable=self.brix_var, + font=("Courier", 12, "bold"), foreground="blue").grid(row=1, column=3, padx=5, pady=5, sticky="w") + + # Statistics Frame + stats_frame = ttk.LabelFrame(self.frame, text="Estadísticas") + stats_frame.grid(row=2, column=0, columnspan=2, padx=10, pady=5, sticky="ew") + + ttk.Label(stats_frame, text="Mensajes recibidos:").grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.msg_count_var = tk.StringVar(value="0") + ttk.Label(stats_frame, textvariable=self.msg_count_var).grid(row=0, column=1, padx=5, pady=5, sticky="w") + + ttk.Label(stats_frame, text="Errores checksum:").grid(row=0, column=2, padx=5, pady=5, sticky="w") + self.checksum_errors_var = tk.StringVar(value="0") + ttk.Label(stats_frame, textvariable=self.checksum_errors_var).grid(row=0, column=3, padx=5, pady=5, sticky="w") + + # Log Frame + log_frame = ttk.LabelFrame(self.frame, text="Log de Recepción") + log_frame.grid(row=3, column=0, columnspan=2, padx=10, pady=5, sticky="nsew") + + self.log_text = scrolledtext.ScrolledText(log_frame, height=10, width=70, wrap=tk.WORD, state=tk.DISABLED) + self.log_text.pack(padx=5, pady=5, fill=tk.BOTH, expand=True) + + # Configurar pesos + self.frame.columnconfigure(0, weight=1) + self.frame.columnconfigure(1, weight=1) + self.frame.rowconfigure(3, weight=1) + + # Contadores + self.message_count = 0 + self.checksum_error_count = 0 + + def get_graph_frame(self): + """Crea y retorna el frame para el gráfico""" + graph_frame = ttk.LabelFrame(self.frame, text="Gráfico Trace (Brix y mA)") + graph_frame.grid(row=4, column=0, columnspan=2, padx=10, pady=5, sticky="nsew") + self.frame.rowconfigure(4, weight=1) + return graph_frame + + def start_trace(self): + """Inicia el modo trace""" + if self.tracing: + messagebox.showwarning("Advertencia", "El trace ya está en curso.") + return + + # Crear archivo CSV + csv_filename = Utils.create_csv_filename("maselli_trace") + try: + self.csv_file = open(csv_filename, 'w', newline='', encoding='utf-8') + self.csv_writer = csv.writer(self.csv_file) + self.csv_writer.writerow(['Timestamp', 'Address', 'mA', 'Brix', 'Checksum_Valid', 'Raw_Message']) + self.csv_filename_var.set(csv_filename) + except Exception as e: + messagebox.showerror("Error", f"No se pudo crear archivo CSV: {e}") + return + + # Abrir conexión + try: + # Construct a dictionary of current config values for get_connection_params + current_config_values = { + 'connection_type': self.shared_config['connection_type_var'].get(), + 'com_port': self.shared_config['com_port_var'].get(), + 'baud_rate': self.shared_config['baud_rate_var'].get(), + 'ip_address': self.shared_config['ip_address_var'].get(), + 'port': self.shared_config['port_var'].get(), + } + conn_type = current_config_values['connection_type'] + conn_params = self.shared_config['config_manager'].get_connection_params(current_config_values) + + self.connection_manager.open_connection(conn_type, conn_params) + Utils.log_message(self.log_text, f"Conexión {conn_type} abierta para trace.") + except Exception as e: + messagebox.showerror("Error de Conexión", str(e)) + if self.csv_file: + self.csv_file.close() + return + + # Resetear contadores + self.message_count = 0 + self.checksum_error_count = 0 + self.msg_count_var.set("0") + self.checksum_errors_var.set("0") + + self.tracing = True + self.start_time = time.time() + self.start_button.config(state=tk.DISABLED) + self.stop_button.config(state=tk.NORMAL) + self._set_entries_state(tk.DISABLED) + + # Iniciar thread de recepción + self.trace_thread = threading.Thread(target=self.run_trace, daemon=True) + self.trace_thread.start() + Utils.log_message(self.log_text, "Trace iniciado.") + + def stop_trace(self): + """Detiene el modo trace""" + if not self.tracing: + return + + self.tracing = False + + # Esperar a que termine el thread + if self.trace_thread and self.trace_thread.is_alive(): + self.trace_thread.join(timeout=2.0) + + # Cerrar conexión + self.connection_manager.close_connection() + Utils.log_message(self.log_text, "Conexión cerrada.") + + # Cerrar archivo CSV + if self.csv_file: + self.csv_file.close() + self.csv_file = None + self.csv_writer = None + Utils.log_message(self.log_text, f"Archivo CSV guardado: {self.csv_filename_var.get()}") + + self.start_button.config(state=tk.NORMAL) + self.stop_button.config(state=tk.DISABLED) + self._set_entries_state(tk.NORMAL) + + Utils.log_message(self.log_text, "Trace detenido.") + Utils.log_message(self.log_text, f"Total mensajes: {self.message_count}, Errores checksum: {self.checksum_error_count}") + + def run_trace(self): + """Thread principal para recepción de datos""" + buffer = "" + + while self.tracing: + try: + # Leer datos disponibles + data = self.connection_manager.read_data_non_blocking() + + if data: + buffer += data + + # Buscar mensajes completos + while '\r' in buffer or '\n' in buffer or len(buffer) >= 10: + # Encontrar el primer terminador + end_idx = -1 + for i, char in enumerate(buffer): + if char in ['\r', '\n']: + end_idx = i + 1 + break + + # Si no hay terminador pero el buffer es largo, buscar mensaje completo + if end_idx == -1 and len(buffer) >= 10: + # Verificar si hay un mensaje ADAM completo + if buffer[0] == '#' or (len(buffer) >= 10 and buffer[2:8].replace('.', '').isdigit()): + end_idx = 10 # Longitud mínima de un mensaje ADAM + if len(buffer) > 10 and buffer[10] in ['\r', '\n']: + end_idx = 11 + + if end_idx > 0: + message = buffer[:end_idx] + buffer = buffer[end_idx:] + + # Procesar mensaje si tiene contenido + if message.strip(): + self._process_message(message) + else: + break + + except Exception as e: + if self.tracing: + Utils.log_message(self.log_text, f"Error en trace: {e}") + break + + # Pequeña pausa para no consumir demasiado CPU + if not data: + time.sleep(0.01) + + def _process_message(self, message): + """Procesa un mensaje recibido""" + # Log del mensaje raw + display_msg = ProtocolHandler.format_for_display(message) + Utils.log_message(self.log_text, f"Recibido: {display_msg}") + + # Parsear mensaje + parsed = ProtocolHandler.parse_adam_message(message) + if parsed: + # Obtener valores de mapeo + min_brix = float(self.shared_config['min_brix_map_var'].get()) + max_brix = float(self.shared_config['max_brix_map_var'].get()) + + ma_value = parsed['ma'] + brix_value = ProtocolHandler.ma_to_brix(ma_value, min_brix, max_brix) + timestamp = datetime.now() + + # Actualizar contadores + self.message_count += 1 + self.msg_count_var.set(str(self.message_count)) + + if not parsed.get('checksum_valid', True): + self.checksum_error_count += 1 + self.checksum_errors_var.set(str(self.checksum_error_count)) + + # Actualizar display + self.timestamp_var.set(timestamp.strftime("%H:%M:%S.%f")[:-3]) + self.address_var.set(parsed['address']) + self.ma_var.set(Utils.format_ma_display(ma_value)) + self.brix_var.set(Utils.format_brix_display(brix_value)) + + # Log con detalles + checksum_status = "OK" if parsed.get('checksum_valid', True) else "ERROR" + Utils.log_message(self.log_text, + f" -> Addr: {parsed['address']}, " + f"mA: {ma_value:.3f}, " + f"Brix: {brix_value:.3f}, " + f"Checksum: {checksum_status}") + + # Guardar en CSV + if self.csv_writer: + self.csv_writer.writerow([ + timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], + parsed['address'], + f"{ma_value:.3f}", + f"{brix_value:.3f}", + parsed.get('checksum_valid', True), + display_msg + ]) + if self.csv_file: + self.csv_file.flush() + + # Agregar al gráfico + current_time = time.time() - self.start_time + self.time_data.append(current_time) + self.brix_data.append(brix_value) + self.ma_data.append(ma_value) # Agregar también mA + + # Actualizar gráfico + if hasattr(self, 'graph_update_callback'): + self.frame.after(0, self.graph_update_callback) + else: + # Mensaje no válido + Utils.log_message(self.log_text, f" -> Mensaje no válido ADAM") + + def clear_graph(self): + """Limpia los datos del gráfico""" + Utils.clear_graph_data(self.time_data, self.brix_data, self.ma_data) + self.start_time = time.time() + Utils.log_message(self.log_text, "Gráfico limpiado.") + + if hasattr(self, 'graph_update_callback'): + self.graph_update_callback() + + def _set_entries_state(self, state): + """Habilita/deshabilita los controles durante el trace""" + # Deshabilitar controles compartidos + if 'shared_widgets' in self.shared_config: + Utils.set_widgets_state(self.shared_config['shared_widgets'], state) + + def get_config(self): + """Obtiene la configuración actual (no hay configuración específica para trace)""" + return {} + + def set_config(self, config): + """Establece la configuración (no hay configuración específica para trace)""" + pass diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..10294b5 --- /dev/null +++ b/utils.py @@ -0,0 +1,83 @@ +""" +Utilidades comunes para el proyecto +""" + +import tkinter as tk +from datetime import datetime +import os + +class Utils: + @staticmethod + def log_message(log_widget, message): + """Escribe un mensaje con timestamp en el widget de log especificado""" + if log_widget: + log_widget.configure(state=tk.NORMAL) + timestamp = datetime.now().strftime('%H:%M:%S') + log_widget.insert(tk.END, f"[{timestamp}] {message}\n") + log_widget.see(tk.END) + log_widget.configure(state=tk.DISABLED) + + @staticmethod + def load_icon(root): + """Intenta cargar un icono para la ventana""" + icon_loaded = False + for icon_file in ['icon.png', 'icon.ico', 'icon.gif']: + if os.path.exists(icon_file): + try: + if icon_file.endswith('.ico'): + root.iconbitmap(icon_file) + else: + icon = tk.PhotoImage(file=icon_file) + root.iconphoto(True, icon) + icon_loaded = True + break + except Exception as e: + print(f"No se pudo cargar {icon_file}: {e}") + + if not icon_loaded: + print("No se encontró ningún archivo de icono (icon.png, icon.ico, icon.gif)") + + @staticmethod + def create_csv_filename(prefix="maselli"): + """Crea un nombre de archivo CSV con timestamp""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return f"{prefix}_{timestamp}.csv" + + @staticmethod + def validate_number_entry(value, min_val=None, max_val=None, is_float=True): + """Valida que una entrada sea un número válido dentro del rango especificado""" + try: + num = float(value) if is_float else int(value) + if min_val is not None and num < min_val: + return False + if max_val is not None and num > max_val: + return False + return True + except ValueError: + return False + + @staticmethod + def set_widgets_state(widgets, state): + """Establece el estado de múltiples widgets""" + for widget in widgets: + try: + widget.config(state=state) + except: + pass # Algunos widgets pueden no tener la propiedad state + + @staticmethod + def format_brix_display(brix_value): + """Formatea un valor Brix para mostrar""" + return f"{brix_value:.3f} Brix" + + @staticmethod + def format_ma_display(ma_value): + """Formatea un valor mA para mostrar""" + return f"{ma_value:.3f} mA" + + @staticmethod + def clear_graph_data(*data_containers): + """Limpia los contenedores de datos del gráfico""" + for container in data_containers: + if hasattr(container, 'clear'): + container.clear()