Guía completa: Modbus RTU y TCP con Python y Node-RED
Para escribir y leer un coil por Modbus TCP desde un Raspberry PLC en Python, instala pymodbus en un entorno virtual, conéctate con ModbusTcpClient(host, port=502) y llama a write_coil(address, value, device_id=...) (código de función 05) y a read_coils(address, count=1, device_id=...) (código de función 01). Los ejemplos de este post se han probado con pymodbus 3.16.1. La primera versión de este post pasaba el device ID como argumento posicional, y eso falla con pymodbus 3.8 y posteriores.
Esta es la parte 4 de la serie Modbus y Raspberry PLC. Las partes 1 y 2 usan Node-RED, y las partes 3 y 4 usan Python. Todos los detalles, como timeouts, direccionamiento, valores de 32 bits y diagnóstico de fallos, están en la guía de Modbus para PLC con Raspberry Pi.
Qué necesitas
- Un Raspberry PLC. Es el cliente (maestro) Modbus TCP.
- Un servidor (esclavo) Modbus TCP: un contador, un variador, otro PLC o un gateway. Para probar sin equipo, ejecuta en el mismo PLC el servidor Modbus TCP en Python de la guía de Modbus y usa
127.0.0.1y el puerto5020en los scripts. - Una conexión Ethernet entre el PLC y el esclavo, los dos en la misma subred. Por defecto,
eth0del Raspberry PLC tiene la dirección 10.10.10.20. Para cambiarla, consulta cómo configurar una IP fija en un Raspberry PLC. - La dirección IP, el puerto TCP y el identificador de unidad del esclavo. Este post usa un esclavo en 10.10.10.4, puerto 502, device ID 1.
Instala pymodbus
Instala pymodbus con pip en un entorno virtual y fija la versión. Raspberry Pi OS Bookworm y posteriores bloquean pip install fuera de un entorno virtual, y la versión que instala apt depende de la versión del sistema. pymodbus cambia su API entre versiones, así que la versión decide si un ejemplo funciona. Es el mismo entorno que en la parte 3. La opción [serial] es para Modbus RTU, y Modbus TCP funciona sin ella.
python3 -m venv ~/modbus-venv
source ~/modbus-venv/bin/activate
pip install "pymodbus[serial]==3.16.1"
Escribe un coil con Python (FC05)
Guarda este código como tcp_write_coil.py:
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException
HOST = "10.10.10.4" # IP address of the Modbus TCP slave
PORT = 502 # Modbus TCP port of the slave
DEVICE_ID = 1 # unit identifier, see the device manual (often 1 or 255)
COIL = 0 # coil address (coil 00001 in the device manual is address 0)
client = ModbusTcpClient(HOST, port=PORT, timeout=2)
if not client.connect():
raise SystemExit(f"No connection to {HOST}:{PORT}")
try:
rr = client.write_coil(COIL, True, device_id=DEVICE_ID) # FC05
if rr.isError():
print("Exception response:", rr)
else:
print(f"Coil {COIL} set to ON")
except ModbusException as exc:
print("No valid answer:", exc) # timeout or closed connection
finally:
client.close()
- Importaciones.
ModbusTcpClientes el cliente Modbus TCP, yModbusExceptioncaptura los casos en que no vuelve ninguna respuesta válida. - Esclavo.
HOSTyPORTseleccionan el equipo.DEVICE_IDes el identificador de unidad: en Modbus TCP muchos equipos lo ignoran, otros solo responden a 1 o 255, y un gateway lo usa para seleccionar el esclavo RTU que tiene detrás. Usa el valor del manual del equipo. - Cliente.
timeout=2espera dos segundos cada respuesta.connect()devuelveFalsesi falla la conexión TCP. - Escritura.
write_coil(COIL, True, device_id=DEVICE_ID)envía el código de función 05. UsaFalsepara desactivar el coil. - Dos tipos de error.
rr.isError()es una respuesta de excepción: el esclavo ha respondido pero ha rechazado la petición.ModbusExceptionsignifica que no ha vuelto ninguna respuesta válida.
Ejecútalo con el entorno virtual activo:
python tcp_write_coil.py
Coil 0 set to ON

Escribe y lee un coil en bucle (FC05 y FC01)
Esta versión cambia el coil cada segundo y lo vuelve a leer para comprobar el valor:
import time
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException
HOST = "10.10.10.4"
PORT = 502
DEVICE_ID = 1
COIL = 0
client = ModbusTcpClient(HOST, port=PORT, timeout=2)
state = True
try:
while True:
if not client.connected and not client.connect():
print(f"No connection to {HOST}:{PORT}, new try in 5 s")
time.sleep(5)
continue
try:
wr = client.write_coil(COIL, state, device_id=DEVICE_ID) # FC05
rr = client.read_coils(COIL, count=1, device_id=DEVICE_ID) # FC01
if wr.isError() or rr.isError():
print("Exception response:", wr if wr.isError() else rr)
else:
print(f"Wrote {state}, read back {rr.bits[0]}")
state = not state
except ModbusException as exc:
print("No valid answer:", exc)
client.close() # reconnect in the next cycle
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
client.close()
read_coils(COIL, count=1, device_id=DEVICE_ID)envía el código de función 01. La respuesta empaqueta los coils en bytes, así querr.bitstiene 8 valores aunque leas un solo coil. El primero,rr.bits[0], es el coil que has pedido.- El script comprueba la respuesta de la escritura y la de la lectura. El coil solo cambia de estado después de un ciclo correcto.
- El cliente mantiene abierta la conexión TCP. Si el esclavo la cierra o deja de responder, el script cierra el cliente y vuelve a conectar en el ciclo siguiente, así que un reinicio del esclavo no detiene el bucle.
- Pulsa Ctrl+C para pararlo. La conexión se cierra en
finally.
Salida de nuestra prueba:
Wrote True, read back True
Wrote False, read back False
Wrote True, read back True
Wrote False, read back False
En la misma prueba paramos el esclavo simulado durante el bucle. El script mostró No valid answer: Modbus Error: [Connection] ..., después No connection to ..., new try in 5 s, y continuó con Wrote True, read back True cuando el esclavo volvió.

Errores con ejemplos antiguos
La primera versión de este post usaba client.write_coil(address, value, 1), con el device ID como argumento posicional, y client.read_coils(address, quantity) sin device ID. La hemos ejecutado contra un esclavo simulado con varias versiones de pymodbus:
| pymodbus | Resultado de la primera versión |
|---|---|
| 2.5.3 | ImportError: cannot import name 'ModbusTcpClient' from 'pymodbus.client' |
| 3.0.2 y 3.6.9 | La escritura funciona. La lectura va al device ID 0, el valor por defecto de estas versiones, y nuestro esclavo con device ID 1 responde con una excepción |
| 3.7.4 | La escritura y la lectura funcionan |
| 3.8.6, 3.9.2, 3.10.0 y 3.16.1 | TypeError: ModbusClientMixin.write_coil() takes 3 positional arguments but 4 were given |
| Error | Causa | Solución |
|---|---|---|
takes 3 positional arguments but 4 were given | pymodbus 3.8 y posteriores solo aceptan como palabra clave los parámetros que van después del valor | write_coil(0, True, device_id=1) |
unexpected keyword argument 'slave' | pymodbus 3.10 y posteriores lo llaman device_id | device_id=1 |
cannot import name 'ModbusTcpClient' from 'pymodbus.client' | pymodbus 2.x | Instala pymodbus 3 en un entorno virtual |
Connection refused o sin conexión | El servidor no está en marcha, IP o puerto incorrectos, cortafuegos, otra subred | Haz ping al esclavo y comprueba el puerto en su manual |
| Respuesta de excepción con código 02 | La dirección del coil no existe en el esclavo | La tabla de coils del manual del esclavo |
Qué hemos probado. Python 3.13 y pymodbus 3.16.1 en Linux, con un esclavo Modbus TCP simulado (pymodbus 3.16.1, device ID 1) en 127.0.0.1. Los dos scripts se ejecutaron cambiando solo el host y el puerto, y dieron la salida que se muestra arriba. También probamos el bucle con el esclavo parado y arrancado de nuevo, un device ID incorrecto (respuesta de excepción) y sin servidor (sin conexión). La primera versión se ejecutó de la misma forma con pymodbus 2.5.3, 3.0.2, 3.6.9, 3.7.4, 3.8.6, 3.9.2, 3.10.0 y 3.16.1. No hemos ejecutado esta actualización en un Raspberry PLC real: las dos capturas son de la primera versión del post.
Modbus TCP desde Python con un equipo real. En el ejemplo del Schneider Altivar 320, un programa en Python en una Raspberry Pi arranca el variador por Modbus TCP, fija la consigna de velocidad y lee estado, corriente y presión. También explica por qué el variador responde en el unit ID 255.
Ver el ejemplo del Altivar 320Abre la guía completa de Modbus
Preguntas frecuentes sobre Modbus TCP con Python
¿Qué device ID uso con Modbus TCP?
El valor del manual del equipo. La dirección IP selecciona el equipo, y muchos equipos ignoran el device ID. Otros solo responden a 1 o 255, y un gateway lo usa para seleccionar el esclavo RTU que tiene detrás. pymodbus 3.7 y posteriores envían 1 si no indicas ningún device ID.
¿Por qué read_coils devuelve 8 valores?
Porque la respuesta lleva los coils en bytes completos. pymodbus devuelve todos los bits de esos bytes. Usa solo los count primeros valores.
¿Puedo instalar pymodbus con apt?
Puedes, pero obtienes la versión de tu sistema operativo, que puede tener una API distinta de la de los ejemplos. Un entorno virtual con una versión fija da el mismo resultado en todos los PLC.
¿Tengo que abrir y cerrar la conexión en cada ciclo?
No. Mantén abierta la conexión TCP y ciérrala solo después de un error, como en el bucle de este post. Una conexión nueva cada segundo añade tráfico y carga al esclavo, y algunos equipos solo aceptan unas pocas conexiones.
¿Cómo hago lo mismo por RS-485?
Con ModbusSerialClient y framer=FramerType.RTU. Consulta la parte 3: Modbus RTU y Python.
Más sobre Modbus en el Raspberry PLC
- Guía de Modbus TCP y RTU para PLC con Raspberry Pi: ejemplos con Python y Node-RED
- Cómo usar pymodbus con un PLC Raspberry Pi: ejemplos asíncronos
- Parte 1: Modbus RTU y Node-RED
- Parte 2: Modbus TCP y Node-RED
- Parte 3: Modbus RTU y Python
- Inversor Fronius por SunSpec Modbus TCP con una Raspberry Pi
- Cómo elegir un controlador industrial: PLC, controlador embebido, IPC o gateway
Elige el modelo y la combinación de E/S en la familia Raspberry PLC.