Simulador Modbus RTU en Python: tutorial paso a paso para integradores SCADA
Tutorial practico para construir un simulador Modbus RTU en Python y validar la integracion SCADA con el AEM-60DC8 antes de recibir el hardware fisico.
Cuando un cliente firma la orden de compra del AEM-60DC8, el reloj del proyecto empieza a correr. La ingenieria SCADA necesita adelantar la parametrizacion del driver, mapear tags, dibujar pantallas y correr pruebas de borde, pero el equipo fisico aun esta en produccion. La salida practica es simular el dispositivo Modbus RTU en Python y exponer un servidor que responde como si fuera el multimedidor real. Este tutorial muestra como levantar ese simulador desde cero, organizar un mapa de 147 holding registers compatible con el firmware AEM-60DC8 v1.03, validarlo con clientes de banco y enchufarlo a tu SCADA por un puerto COM virtual. Vas a salir de aqui con codigo corriendo, no con diapositivas.
Por que simular antes de comprar
Esperar el hardware para iniciar la integracion es una decision cara. Cada dia parado en obra es un dia de comisionador, una hora de ingeniero de campo y una ventana de paro que no vuelve. Adelantar con un simulador resuelve tres problemas concretos:
- Ahorro de tiempo. La configuracion del driver, la creacion de tags, alarmas e historizadores ocurren en paralelo a la fabricacion. Cuando el AEM-60DC8 llega, la ingenieria ya valido el 90% del proyecto SCADA.
- Despliegue mas limpio. Errores de direccionamiento, de escala y de orden de bytes se detectan en el laboratorio, no en un tablero energizado a las 3 de la madrugada.
- Pruebas de borde. Disparar una alarma de sobretension, una falla de comunicacion o un valor fuera de rango es trivial en software. En el hardware exige banco, fuente programable y tiempo.
Hay una ganancia menos obvia: documentacion. El simulador se vuelve referencia viva del mapa de registros. Quien llega despues al equipo lee codigo, no PDFs vencidos.
Bibliotecas Python: comparativo corto
Tres bibliotecas dominan el ecosistema Python para Modbus. El comparativo es directo:
| Biblioteca | Fortalezas | Limites | Cuando usar |
|---|---|---|---|
| pymodbus | Activa, async-friendly, soporta RTU/TCP/ASCII, datastore conectable | API cambia entre versiones mayores | Predeterminado para cualquier proyecto nuevo |
| modbus-tk | Estable, simple, liviana | Mantenimiento esporadico, sin async | Scripts cortos heredados |
| libmodbus (binding) | C nativo, rapido | Setup molesto en Windows, menos pythonico | Puente con codigo C/C++ existente |
Para este tutorial, pymodbus 3.6.x. Cubre RTU, TCP y ASCII, tiene datastore personalizable (clave para registros dinamicos) y corre en Windows, Linux y macOS. Fija la version menor: la API cambio entre 2.x y 3.x y volvera a cambiar en 4.x.
Instalacion y entorno
Crea un virtualenv. Mezclar dependencias de Modbus con el Python global del sistema es receta para conflicto.
python -m venv .venv
# Windows
.venv\Scripts\activate
# Linux/macOS
source .venv/bin/activate
pip install --upgrade pip
pip install "pymodbus[serial]==3.6.9"
El extra [serial] arrastra pyserial, necesario para RTU. Confirma con python -c "import pymodbus, serial; print(pymodbus.__version__, serial.__version__)" — deberias ver 3.6.9 y algo como 3.5. Si rompe aqui, hay un Python equivocado en el PATH; resuelve eso antes de continuar.
En Windows, instala com0com para crear pares de puertos COM virtuales (COM10 <-> COM11). En Linux, usa socat para crear dos pty enlazados:
sudo apt install socat
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
Mantenga esa terminal abierta. ttyV0 sera del simulador; ttyV1, del cliente o del SCADA.
Hello world: servidor Modbus RTU minimo
Guarda como server_min.py. El servidor escucha en /tmp/ttyV0 (Linux) o COM10 (Windows), responde como esclavo 1 y mantiene 10 holding registers con valores fijos.
# server_min.py
import logging
from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext
from pymodbus.server import StartSerialServer
from pymodbus.framer import ModbusRtuFramer
logging.basicConfig(level=logging.INFO)
PORT = "/tmp/ttyV0" # Windows: "COM10"
# 10 holding registers, todos iniciando en 0
hr_block = ModbusSequentialDataBlock(0, [0] * 10)
slave_ctx = ModbusSlaveContext(hr=hr_block, zero_mode=True)
server_ctx = ModbusServerContext(slaves={1: slave_ctx}, single=False)
if __name__ == "__main__":
# Precarga algunos valores
hr_block.setValues(0, [2200, 2201, 2199, 2202, 2198, 2200, 2203, 2197, 0, 0])
StartSerialServer(
context=server_ctx,
framer=ModbusRtuFramer,
port=PORT,
baudrate=19200,
bytesize=8,
parity="E",
stopbits=1,
timeout=1,
)
Ejecutalo en una terminal con python server_min.py. Veras el log de pymodbus indicando que el servidor abrio el puerto. Si aparece could not open port, es nombre de puerto serial equivocado o el socat se cayo.
Mapa del AEM-60DC8: 147 holding registers
Ejemplo simplificado. El mapa de abajo es didactico y sirve para validar el flujo de integracion. El mapa oficial completo de la v1.03 esta en el manual del AEM-60DC8 (LRI). Usa este solo como esqueleto.
Organiza 147 holding registers en bloques logicos. Direcciones decimales, zero_mode=True:
| Rango | Contenido | Tipo |
|---|---|---|
| 0–7 | Tension CH1–CH8 (V x 100) | uint16 |
| 8–15 | Corriente CH1–CH8 (mA) | uint16 |
| 16–23 | Potencia CH1–CH8 (W) | int16 |
| 24–39 | Energia acumulada CH1–CH8 | uint32 (pares) |
| 40–47 | Estado digital CH1–CH8 | uint16 (bitmap) |
| 48–63 | Alarmas (sobretension, subtension, etc.) | uint16 |
| 64–95 | Configuracion (baudrate, slave ID, escalas) | uint16 |
| 96–146 | Reservados / diagnostico | uint16 |
Guarda como aem_map.py:
# aem_map.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Block:
start: int
length: int
label: str
BLOCKS = (
Block(0, 8, "tension_ch"),
Block(8, 8, "corriente_ch"),
Block(16, 8, "potencia_ch"),
Block(24, 16, "energia_ch"),
Block(40, 8, "estado_ch"),
Block(48, 16, "alarmas"),
Block(64, 32, "config"),
Block(96, 51, "diag"),
)
TOTAL_REGISTERS = sum(b.length for b in BLOCKS) # = 147
assert TOTAL_REGISTERS == 147, f"esperado 147, obtenido {TOTAL_REGISTERS}"
def initial_values() -> list[int]:
values = [0] * TOTAL_REGISTERS
# tensiones nominales (220.0 V) -> 22000 con escala x100
for ch in range(8):
values[ch] = 22000
# baudrate por defecto 19200 codificado como 3
values[64] = 3
# slave id
values[65] = 1
return values
Ahora un servidor que carga ese mapa. Guarda como server_aem.py:
# server_aem.py
import logging
from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext
from pymodbus.server import StartSerialServer
from pymodbus.framer import ModbusRtuFramer
from aem_map import initial_values, TOTAL_REGISTERS
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
PORT = "/tmp/ttyV0" # Windows: "COM10"
SLAVE_ID = 1
hr_block = ModbusSequentialDataBlock(0, initial_values())
slave_ctx = ModbusSlaveContext(hr=hr_block, zero_mode=True)
server_ctx = ModbusServerContext(slaves={SLAVE_ID: slave_ctx}, single=False)
if __name__ == "__main__":
logging.info("Simulador AEM-60DC8 v1.03 | %d registros | %s", TOTAL_REGISTERS, PORT)
StartSerialServer(
context=server_ctx,
framer=ModbusRtuFramer,
port=PORT,
baudrate=19200,
bytesize=8,
parity="E",
stopbits=1,
timeout=1,
)
Validando con un cliente Modbus
Dos enfoques. El primero, por linea de comandos con mbpoll (multiplataforma, instalable via Chocolatey en Windows o apt en Linux):
# Lee 8 holding registers desde la direccion 0, esclavo 1, 19200 8E1
mbpoll -m rtu -a 1 -b 19200 -P even -t 4 -r 1 -c 8 /tmp/ttyV1
Observacion: mbpoll usa direccionamiento basado en 1 por defecto, por eso -r 1 lee el registro en la direccion 0. Deberias ver ocho valores 22000.
El segundo, cliente Python, util para automatizar pruebas de regresion:
# client_check.py
from pymodbus.client import ModbusSerialClient
from pymodbus.framer import ModbusRtuFramer
client = ModbusSerialClient(
port="/tmp/ttyV1", # Windows: "COM11"
framer=ModbusRtuFramer,
baudrate=19200,
bytesize=8,
parity="E",
stopbits=1,
timeout=1,
)
if not client.connect():
raise SystemExit("falla al abrir el puerto cliente")
rr = client.read_holding_registers(address=0, count=8, slave=1)
if rr.isError():
print("error:", rr)
else:
for i, v in enumerate(rr.registers):
print(f"CH{i+1}: {v/100:.2f} V")
client.close()
Salida esperada: ocho lineas CHn: 220.00 V. Si aparece timeout, revisa el par de puertos y la paridad: el 90% de los errores de RTU son paridad o bit de parada.
En herramientas graficas, qModMaster (Linux/Windows) y Modbus Poll (Windows) cubren el mismo escenario y sirven para mostrar el mapa al equipo de operacion.
Simulando comportamiento dinamico
Los registros fijos prueban que la comunicacion esta arriba, pero no ejercitan el SCADA. Haz que los valores se muevan en un hilo separado:
# server_dynamic.py
import logging
import threading
import time
import math
import random
from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext
from pymodbus.server import StartSerialServer
from pymodbus.framer import ModbusRtuFramer
from aem_map import initial_values, TOTAL_REGISTERS
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
PORT = "/tmp/ttyV0"
SLAVE_ID = 1
hr_block = ModbusSequentialDataBlock(0, initial_values())
slave_ctx = ModbusSlaveContext(hr=hr_block, zero_mode=True)
server_ctx = ModbusServerContext(slaves={SLAVE_ID: slave_ctx}, single=False)
def simulate():
t0 = time.time()
while True:
t = time.time() - t0
for ch in range(8):
# 220 V +/- 2 V senoidal con ruido pequeno
v = 22000 + int(200 * math.sin(2 * math.pi * 0.1 * t + ch))
v += random.randint(-20, 20)
hr_block.setValues(ch, [v])
# alarma de sobretension cada 30 s en CH1
alarm = 1 if int(t) % 30 < 2 else 0
hr_block.setValues(48, [alarm])
time.sleep(0.5)
if __name__ == "__main__":
threading.Thread(target=simulate, daemon=True).start()
logging.info("Simulador dinamico AEM-60DC8 | %d regs", TOTAL_REGISTERS)
StartSerialServer(
context=server_ctx,
framer=ModbusRtuFramer,
port=PORT,
baudrate=19200,
bytesize=8,
parity="E",
stopbits=1,
timeout=1,
)
Ahora las tensiones oscilan levemente y una alarma se dispara periodicamente. Eso ya es suficiente para validar tendencias, deadbands, alarmas y captura de eventos en el historizador.
Integrando con tu SCADA real
El puente es el puerto serial virtual. El simulador sujeta un extremo, el SCADA consume el otro.
Windows con Ignition, Elipse E3 o AVEVA Edge. Instala com0com, crea el par COM10 <-> COM11. Apunta el servidor Python a COM10 y el driver Modbus RTU del SCADA a COM11. Parametros consistentes en ambos lados: 19200 8E1, slave ID 1, timeout 1000 ms. Las velocidades soportadas por el AEM-60DC8 son 4800/9600/19200/38400/57600/115200 bps; usa la misma del proyecto final.
Linux. socat crea /tmp/ttyV0 y /tmp/ttyV1. Apunta el gateway Modbus del SCADA a /tmp/ttyV1. Funciona bien para Ignition corriendo en contenedor.
Configura tags apuntando al mapa del simulador, valida lecturas, escribe en los registros de configuracion para garantizar round-trip y fuerza alarmas para probar el arbol de notificaciones. Ese ciclo, hecho en laboratorio, ahorra dias de campo.
Cuidado con dos puntos: orden de bytes (la v1.03 usa big-endian en uint32, estandar Modbus) y timeout. El AEM-60DC8 real responde en hasta 50 ms, asi que el simulador es mas rapido que el hardware. No calibres el timeout contra el simulador.
Proximos pasos
Este tutorial cubre el esqueleto. En produccion vas a querer simular fallas de CRC, latencia variable, perdida intermitente de paquetes y respuestas con slave busy: todos eventos reales en buses RS-485 largos.
LRI mantiene el simulador oficial del AEM-60DC8 como proyecto open-source. Ya viene con el mapa completo de la v1.03, perfiles de falla, modo de grabacion para reproducir trafico capturado en obra y ejemplos de integracion con Ignition y Elipse. Descargalo en github.com/lri-engenharia/aem60dc8-simulator (Secure by Design por defecto: el simulador no expone puertos de red sin configuracion explicita).
FAQ
1. Puedo correr el simulador sin ningun hardware serial?
Si. En Linux, socat crea pares de pty totalmente en software. En Windows, com0com hace lo mismo con puertos COM. No es necesario ningun cable, convertidor USB-RS485 ni adaptador en el laboratorio.
2. Que version de pymodbus usar?
Fija pymodbus[serial]==3.6.9. La API cambio entre 2.x y 3.x; mezclar tutoriales antiguos con la nueva version es la mayor fuente de error en proyectos nuevos.
3. El simulador sirve para certificacion? No. Usalo para integracion, parametrizacion SCADA y pruebas funcionales. La certificacion metrologica y de protocolo exige el hardware real y un laboratorio acreditado.
4. Como simular falla de CRC? En pymodbus 3.x se necesita un framer personalizado que corrompe el ultimo byte de la respuesta con probabilidad configurable. El simulador oficial de LRI trae ese modo listo.
5. Funciona tambien con Modbus TCP?
Si. Cambia StartSerialServer por StartTcpServer y ModbusRtuFramer por ModbusSocketFramer. El datastore y el mapa del AEM-60DC8 quedan identicos.