Simulador Modbus RTU em Python: tutorial passo a passo para integradores SCADA
Tutorial prático para construir um simulador Modbus RTU em Python e validar a integração SCADA com o AEM-60DC8 antes de receber o hardware físico.
Quando um cliente fecha o pedido do AEM-60DC8, o relógio do projeto começa a correr. A engenharia de SCADA precisa adiantar a parametrização, mapear tags, desenhar telas e rodar testes de borda — só que o equipamento físico ainda está em produção. A saída prática é simular o dispositivo Modbus RTU em Python e expor um servidor que responde como se fosse o multimedidor real. Este tutorial mostra como subir esse simulador do zero, organizar um mapa de 147 holding registers compatível com a v1.03 do firmware AEM-60DC8, validar com clientes de bancada e plugar no seu SCADA via porta COM virtual. Você vai sair daqui com código rodando, não com slides.
Por que simular antes de comprar
Adiar a integração até o hardware chegar é uma decisão cara. Cada dia parado na obra é diária de comissionador, hora de engenheiro de campo e janela de paralisação que não volta. Antecipar com um simulador resolve três problemas concretos:
- Economia de tempo. A parametrização de driver, criação de tags, alarmes e historiadores acontece em paralelo à fabricação. Quando o AEM-60DC8 chega, a engenharia já validou 90% do projeto SCADA.
- Deploy mais limpo. Erros de endereçamento, escala e byte order são encontrados em laboratório, não no painel energizado às 3h da manhã.
- Testes de borda. Simular um alarme de sobretensão, uma falha de comunicação ou um valor fora de faixa é trivial em software. No hardware, exige bancada, fonte programável e tempo.
Há ainda um ganho menos óbvio: documentação. O simulador vira referência viva do mapa de registradores. Quem entra no time depois consulta o código, não um PDF desatualizado.
Bibliotecas Python: comparativo rápido
Três bibliotecas dominam o ecossistema Python para Modbus. O comparativo abaixo é direto:
| Biblioteca | Pontos fortes | Limitações | Quando usar |
|---|---|---|---|
| pymodbus | Ativa, async-friendly, suporta RTU/TCP/ASCII, datastore plugável | API muda entre versões majors | Padrão para qualquer projeto novo |
| modbus-tk | Estável, simples, leve | Manutenção esporádica, sem async | Scripts curtos legados |
| libmodbus (binding) | C nativo, performance | Setup chato no Windows, menos pythônico | Integração com C/C++ existente |
Para este tutorial, pymodbus 3.6.x. Ela cobre RTU, TCP e ASCII, tem datastore customizável (importante para registradores dinâmicos) e roda em Windows, Linux e macOS. Fixe a versão menor — a API mudou entre 2.x e 3.x e mudará de novo em 4.x.
Instalação e ambiente
Crie um virtualenv. Misturar dependências de Modbus com o Python global do sistema é receita para conflito.
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"
O extra [serial] arrasta o pyserial, necessário para RTU. Confirme com python -c "import pymodbus, serial; print(pymodbus.__version__, serial.__version__)" — deve sair 3.6.9 e algo como 3.5. Se quebrar aqui, é Python errado no PATH; resolva antes de continuar.
No Windows, instale o com0com para criar pares de portas COM virtuais (COM10 <-> COM11). No Linux, use socat para criar dois pty conectados:
sudo apt install socat
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
Mantenha esse terminal aberto. ttyV0 será do simulador; ttyV1, do cliente ou do SCADA.
Hello world: servidor Modbus RTU mínimo
Salve como server_min.py. O servidor escuta em /tmp/ttyV0 (Linux) ou COM10 (Windows), responde como escravo 1, e mantém 10 holding registers com valores fixos.
# 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 em 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__":
# Pré-carrega alguns 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,
)
Rode em um terminal com python server_min.py. Você verá log do pymodbus indicando que o servidor abriu a porta. Se aparecer could not open port, é a porta serial errada ou o socat parou.
Mapa do AEM-60DC8: 147 holding registers
Exemplo simplificado. O mapa abaixo é didático e serve para validar fluxo de integração. O mapa oficial completo da v1.03 está no manual do AEM-60DC8 (LRI). Use este apenas como esqueleto.
Organize 147 holding registers em blocos lógicos. Endereços decimais, zero_mode=True:
| Faixa | Conteúdo | Tipo |
|---|---|---|
| 0–7 | Tensão CH1–CH8 (V × 100) | uint16 |
| 8–15 | Corrente CH1–CH8 (mA) | uint16 |
| 16–23 | Potência CH1–CH8 (W) | int16 |
| 24–39 | Energia acumulada CH1–CH8 | uint32 (pares) |
| 40–47 | Status digital CH1–CH8 | uint16 (bitmap) |
| 48–63 | Alarmes (sobretensão, subtensão etc.) | uint16 |
| 64–95 | Configuração (baudrate, slave ID, escalas) | uint16 |
| 96–146 | Reservados / diagnóstico | uint16 |
Salve 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, "tensao_ch"),
Block(8, 8, "corrente_ch"),
Block(16, 8, "potencia_ch"),
Block(24, 16, "energia_ch"),
Block(40, 8, "status_ch"),
Block(48, 16, "alarmes"),
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, obteve {TOTAL_REGISTERS}"
def initial_values() -> list[int]:
values = [0] * TOTAL_REGISTERS
# tensões nominais (220.0 V) -> 22000 com escala x100
for ch in range(8):
values[ch] = 22000
# baudrate default 19200 codificado como 3
values[64] = 3
# slave id
values[65] = 1
return values
Agora um servidor que carrega esse mapa. Salve 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 com um cliente Modbus
Duas abordagens. A primeira, via linha de comando com mbpoll (cross-platform, instalável via Chocolatey no Windows ou apt no Linux):
# Lê 8 holding registers a partir do endereço 0, slave 1, 19200 8E1
mbpoll -m rtu -a 1 -b 19200 -P even -t 4 -r 1 -c 8 /tmp/ttyV1
Observação: mbpoll usa endereçamento começando em 1 por padrão, daí -r 1 para ler o registro de endereço 0. Você deve ver oito valores 22000.
A segunda, cliente Python — útil para automatizar testes de regressão:
# 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("falha ao abrir a porta cliente")
rr = client.read_holding_registers(address=0, count=8, slave=1)
if rr.isError():
print("erro:", rr)
else:
for i, v in enumerate(rr.registers):
print(f"CH{i+1}: {v/100:.2f} V")
client.close()
Saída esperada: oito linhas CHn: 220.00 V. Se aparecer timeout, confira o par de portas e a paridade — 90% dos erros de RTU são paridade ou stop bit.
Em ferramentas gráficas, qModMaster (Linux/Windows) e Modbus Poll (Windows) cobrem o mesmo cenário e são úteis para mostrar o mapa para o time de operação.
Simulando comportamento dinâmico
Registradores fixos provam que a comunicação está de pé, mas não exercitam o SCADA. Faça os valores oscilarem em uma thread separada:
# 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 com 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])
# alarme de sobretensao a cada 30 s no 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,
)
Agora as tensões oscilam levemente e um alarme dispara periodicamente. Isso já é suficiente para validar trending, deadbands, alarmes e disparo de eventos no historiador.
Integrando com seu SCADA
A ponte é a porta serial virtual. O simulador segura uma ponta, o SCADA consome a outra.
Windows com Ignition, Elipse E3 ou AVEVA Edge. Instale com0com, crie o par COM10 <-> COM11. Aponte o servidor Python para COM10 e o driver Modbus RTU do SCADA para COM11. Parâmetros consistentes em ambos os lados: 19200 8E1, slave ID 1, timeout 1000 ms. As velocidades suportadas pelo AEM-60DC8 são 4800/9600/19200/38400/57600/115200 bps — use a mesma do projeto final.
Linux. O socat cria /tmp/ttyV0 e /tmp/ttyV1. Aponte o gateway Modbus do SCADA para /tmp/ttyV1. Funciona bem para Ignition rodando em container.
Configure tags apontando para o mapa do simulador, valide leituras, escreva nos registradores de configuração para garantir round-trip e force alarmes para testar a árvore de notificações. Esse ciclo, feito em laboratório, economiza dias de campo.
Cuidado com dois pontos: byte order (a v1.03 usa big-endian em uint32, padrão Modbus) e timeout — o AEM-60DC8 real responde em até 50 ms, então o simulador é mais rápido que o hardware. Não calibre timeout pelo simulador.
Próximos passos
Este tutorial cobre o esqueleto. Em produção, você vai querer simular falhas de CRC, latência variável, perda intermitente de pacotes e respostas com slave busy — todos eventos reais em barramentos RS-485 longos.
A LRI mantém o simulador oficial do AEM-60DC8 como projeto open-source. Ele já vem com o mapa completo da v1.03, perfis de falha, modo de gravação para regravar tráfego capturado em obra e exemplos de integração com Ignition e Elipse. Baixe em github.com/lri-engenharia/aem60dc8-simulator (Secure by Design por padrão: o simulador não expõe portas de rede sem configuração explícita).
FAQ
1. Posso rodar o simulador sem hardware serial nenhum?
Sim. No Linux, o socat cria pares de pty totalmente em software. No Windows, o com0com faz o mesmo com portas COM. Nenhum cabo, conversor USB-RS485 ou adaptador é necessário em laboratório.
2. Qual versão do pymodbus usar?
Fixe pymodbus[serial]==3.6.9. A API mudou entre 2.x e 3.x; misturar tutoriais antigos com a versão nova é a maior fonte de erro em projetos novos.
3. O simulador serve para certificação? Não. Use para integração, parametrização SCADA e testes funcionais. Certificação metrológica e de protocolo exige o hardware real e laboratório acreditado.
4. Como simular falha de CRC? Em pymodbus 3.x, isso exige um framer customizado que corrompe o último byte da resposta com probabilidade configurável. O simulador oficial da LRI traz esse modo pronto.
5. Funciona com Modbus TCP também?
Sim. Troque StartSerialServer por StartTcpServer e o ModbusRtuFramer pelo ModbusSocketFramer. O datastore e o mapa do AEM-60DC8 permanecem idênticos.