Modularizacion y Agregado de NetCom

This commit is contained in:
Miguel 2025-05-23 10:15:04 +02:00
parent 163a7bacbc
commit e1c9199cb5
17 changed files with 2634 additions and 210 deletions

97
.doc/CAMBIOS.md Normal file
View File

@ -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.

167
.doc/README.md Normal file
View File

@ -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.

188
.gitignore vendored
View File

@ -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

View File

@ -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', '<CR>').replace('\n', '<LF>')
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', '<CR>').replace('\n', '<LF>')
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__":

133
config_manager.py Normal file
View File

@ -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

165
connection_manager.py Normal file
View File

@ -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

21
main.py Normal file
View File

@ -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()

354
maselli_app.py Normal file
View File

@ -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("<<ComboboxSelected>>", 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()

View File

@ -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"
}

121
protocol_handler.py Normal file
View File

@ -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', '<CR>').replace('\n', '<LF>').replace('\t', '<TAB>')

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
# Dependencias para Maselli Simulator/Trace/NetCom
pyserial==3.5
matplotlib==3.7.1

4
run.bat Normal file
View File

@ -0,0 +1,4 @@
@echo off
echo Iniciando Maselli Simulator/Trace/NetCom...
python main.py
pause

1
tabs/__init__.py Normal file
View File

@ -0,0 +1 @@
# Paquete para los tabs de la aplicación

453
tabs/netcom_tab.py Normal file
View File

@ -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'))

461
tabs/simulator_tab.py Normal file
View File

@ -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("<<ComboboxSelected>>", 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('<Return>', lambda e: self.update_slider_from_entry())
self.manual_brix_entry.bind('<FocusOut>', 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()

336
tabs/trace_tab.py Normal file
View File

@ -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

83
utils.py Normal file
View File

@ -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()