Ir al contenido

Introducción a Modbus y Raspberry PLC. Parte 4

Escribe y lee un coil por Modbus TCP en Python con pymodbus 3.16
21 de febrero de 2024 por
Introducción a Modbus y Raspberry PLC. Parte 4
Boot & Work Corp. S.L, Bernat Brunet Pedra

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.

ParteProtocoloHerramienta
Parte 1Modbus RTUNode-RED
Parte 2Modbus TCPNode-RED
Parte 3Modbus RTUPython
Parte 4 (este post)Modbus TCPPython

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.1 y el puerto 5020 en los scripts.
  • Una conexión Ethernet entre el PLC y el esclavo, los dos en la misma subred. Por defecto, eth0 del 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()
  1. Importaciones. ModbusTcpClient es el cliente Modbus TCP, y ModbusException captura los casos en que no vuelve ninguna respuesta válida.
  2. Esclavo. HOST y PORT seleccionan el equipo. DEVICE_ID es 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.
  3. Cliente. timeout=2 espera dos segundos cada respuesta. connect() devuelve False si falla la conexión TCP.
  4. Escritura. write_coil(COIL, True, device_id=DEVICE_ID) envía el código de función 05. Usa False para desactivar el coil.
  5. Dos tipos de error. rr.isError() es una respuesta de excepción: el esclavo ha respondido pero ha rechazado la petición. ModbusException significa 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
Terminal de un Raspberry PLC: python tcp.py se ejecuta dos veces y escribe el valor 0 y después 1 en la dirección de coil 0
Salida de la primera versión del script en un Raspberry PLC: escribe 0 y después 1 en el coil 0.

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í que rr.bits tiene 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ó.

Terminal de un Raspberry PLC: el bucle de escritura y lectura escribe True, False y True en el coil 0 y lee los mismos valores
Captura de la primera versión en un Raspberry PLC: el coil alterna entre True y False.

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:

pymodbusResultado de la primera versión
2.5.3ImportError: cannot import name 'ModbusTcpClient' from 'pymodbus.client'
3.0.2 y 3.6.9La 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.4La escritura y la lectura funcionan
3.8.6, 3.9.2, 3.10.0 y 3.16.1TypeError: ModbusClientMixin.write_coil() takes 3 positional arguments but 4 were given
ErrorCausaSolución
takes 3 positional arguments but 4 were givenpymodbus 3.8 y posteriores solo aceptan como palabra clave los parámetros que van después del valorwrite_coil(0, True, device_id=1)
unexpected keyword argument 'slave'pymodbus 3.10 y posteriores lo llaman device_iddevice_id=1
cannot import name 'ModbusTcpClient' from 'pymodbus.client'pymodbus 2.xInstala pymodbus 3 en un entorno virtual
Connection refused o sin conexiónEl servidor no está en marcha, IP o puerto incorrectos, cortafuegos, otra subredHaz ping al esclavo y comprueba el puerto en su manual
Respuesta de excepción con código 02La dirección del coil no existe en el esclavoLa 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.

Buscar en nuestro blog

Introducción a Modbus y Raspberry PLC. Parte 4
Boot & Work Corp. S.L, Bernat Brunet Pedra 21 de febrero de 2024
Compartir esta publicación

¿Estás buscando tu Controlador Lógico Programable ideal?

Echa un vistazo a esta comparativa de producto de varios controladores industriales basados en Arduino.

Comparamos entradas, salidas, comunicaciones y otras especificaciones con las de los equipos de otras marcas destacadas.

Comparativa de PLCs