Guides

Building a pymodbus Plug for PLCs and Modbus Devices

Control fixtures, chambers and power meters over Modbus TCP or RTU from an OpenHTF plug with pymodbus — connect, read holding registers into measurements, write coils to actuate a fixture, decode 32-bit floats, and handle Modbus exceptions.

Last updated · Verified with OpenHTF 1.6.1

Fixtures and environmental equipment usually expose Modbus: a PLC that clamps the DUT, a thermal chamber, a programmable load, a power meter. pymodbus speaks Modbus TCP and RTU; an OpenHTF plug turns register reads into measurements and coil writes into fixture actions.

Terminal
pip install pymodbus          # add pyserial for Modbus RTU over a serial line

Examples use pymodbus 3.x's synchronous client.

The plug

fixture_plc.py
import struct

from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException
from openhtf.core import base_plugs


class FixturePlc(base_plugs.BasePlug):
    """Test fixture PLC over Modbus TCP: clamp control, pressure and temperature."""

    COIL_CLAMP = 0
    COIL_POWER = 1
    HR_PRESSURE_KPA = 100      # uint16, kPa
    HR_TEMP_C_X10 = 101        # int16, °C × 10
    HR_ENERGY_WH = 200         # float32 in two registers (big-endian word order)

    def __init__(self, host: str, port: int = 502, unit: int = 1):
        self._unit = unit
        self._client = ModbusTcpClient(host, port=port, timeout=2)
        if not self._client.connect():
            raise ConnectionError(f"Cannot reach PLC at {host}:{port}")
        self.logger.info("Connected to PLC %s:%d", host, port)

    # --- actuation ---

    def clamp(self, engaged: bool) -> None:
        self._write_coil(self.COIL_CLAMP, engaged)

    def dut_power(self, on: bool) -> None:
        self._write_coil(self.COIL_POWER, on)

    # --- readings ---

    def pressure_kpa(self) -> int:
        return self._read_registers(self.HR_PRESSURE_KPA, 1)[0]

    def temperature_c(self) -> float:
        raw = self._read_registers(self.HR_TEMP_C_X10, 1)[0]
        return struct.unpack(">h", struct.pack(">H", raw))[0] / 10   # signed int16

    def energy_wh(self) -> float:
        hi, lo = self._read_registers(self.HR_ENERGY_WH, 2)
        return struct.unpack(">f", struct.pack(">HH", hi, lo))[0]

    # --- helpers ---

    def _read_registers(self, address: int, count: int) -> list[int]:
        rr = self._client.read_holding_registers(address, count=count, slave=self._unit)
        if rr.isError():
            raise ModbusException(f"read_holding_registers({address}, {count}) -> {rr}")
        return rr.registers

    def _write_coil(self, address: int, value: bool) -> None:
        wr = self._client.write_coil(address, value, slave=self._unit)
        if wr.isError():
            raise ModbusException(f"write_coil({address}, {value}) -> {wr}")
        self.logger.info("coil %d <- %s", address, value)

    def tearDown(self) -> None:
        # Release the DUT whatever happened during the test.
        try:
            self.dut_power(False)
            self.clamp(False)
        finally:
            self._client.close()

pymodbus 3.9 renamed the slave= keyword to device_id=. Check pymodbus.__version__ and use the one your install expects.

Use it in phases

main.py
import time

import openhtf as htf
from openhtf.util import configuration, units

from fixture_plc import FixturePlc

CONF = configuration.CONF
PLC_HOST = CONF.declare("plc_host", default_value="192.168.10.5")
Plc = configuration.bind_init_args(FixturePlc, PLC_HOST)


@htf.plug(plc=Plc)
@htf.measures(htf.Measurement("clamp_pressure").in_range(400, 600).with_units(units.KILOPASCAL))
def clamp_dut(test, plc):
    plc.clamp(True)
    time.sleep(0.5)
    test.measurements.clamp_pressure = plc.pressure_kpa()


@htf.plug(plc=Plc)
@htf.measures(
    htf.Measurement("ambient_c").in_range(18, 28).with_units(units.DEGREE_CELSIUS),
    htf.Measurement("energy_wh").in_range(maximum=0.5).with_units(units.WATT_HOUR),
)
def power_and_measure(test, plc):
    plc.dut_power(True)
    time.sleep(5)
    test.measurements.ambient_c = plc.temperature_c()
    test.measurements.energy_wh = plc.energy_wh()


def main():
    test = htf.Test(
        htf.PhaseGroup(
            main=[clamp_dut, power_and_measure],
            # tearDown() also releases, but an explicit teardown phase is recorded.
            teardown=[release_dut],
        )
    )
    test.execute(lambda: "SN1234")


@htf.plug(plc=Plc)
def release_dut(test, plc):
    plc.dut_power(False)
    plc.clamp(False)


if __name__ == "__main__":
    main()

The phase group guarantees release_dut runs even when power_and_measure fails; the plug's tearDown() is a second safety net if the process dies mid-phase.

Modbus RTU

Swap the client; the rest of the plug is identical:

from pymodbus.client import ModbusSerialClient

self._client = ModbusSerialClient(port="/dev/ttyUSB1", baudrate=19200, parity="E", stopbits=1, timeout=1)

Practical notes

  • Addresses are zero-based in pymodbus. A device manual's "register 40101" is holding register address 100.
  • Data types. Modbus only knows 16-bit registers; ints, floats and word order (big/little endian) are conventions of each device. struct makes the conversion explicit and testable.
  • Check isError() on every response; a Modbus exception response is not a Python exception.
  • Timeouts. timeout=2 on the client plus phase timeout_s in PhaseOptions bounds a dead PLC.
  • Shared devices. If a chamber is shared by several stations, one plug per station is fine for reads; coordinate writes outside OpenHTF.
  • Environmental profiles. For a thermal cycle, combine write_register setpoints with a monitor sampling temperature_c() and consistent_end_dimension_pivot_validate (validators) to prove the chamber settled.

On this page

First-pass yield
0%4.1
Track with TofuPilot