Skip to content

Example: NominalModbus loopback test against a LabJack T4.ยค

"""Example: NominalModbus loopback test against a LabJack T4.

Exercises read/write against a T4 over Modbus TCP using register addresses
from the LabJack T-series Modbus map
(https://support.labjack.com/docs/3-modbus-map-t-series-datasheet).

Loopback wiring required on the T4:
    - DAC0  -> AIN0  (analog loopback)
    - FIO4  -> FIO5  (digital loopback; FIO4 = output, FIO5 = input)

Covers:
    - Reading device identity (PRODUCT_ID, FIRMWARE_VERSION, SERIAL_NUMBER)
    - Analog loopback: write DAC0, read AIN0, verify within tolerance
    - Digital loopback: drive FIO4, read FIO5, verify state matches

Set HOST below to the T4's IP address or hostname before running.
"""

import time

from nominal_instro.protocols.modbus import NominalModbus

HOST = "<device_ip>"
PORT = 502

# Acceptable error for the DAC->AIN loopback. The T4 DAC has ~10 mV typical
# accuracy and the AIN has its own noise/offset, so 50 mV is comfortable.
ANALOG_TOLERANCE_V = 0.05

CONFIG = {
    "version": 1,
    "protocol": "modbus",
    "device": {
        "name": "labjack_t4",
        "manufacturer": "LabJack",
        "model": "T4",
    },
    "connection": {
        "transport": "tcp",
        "host": HOST,
        "port": PORT,
        "unit_id": 1,
        "timeout": 3.0,
    },
    "registers": [
        # Device identity
        {"name": "product_id", "starting_address": 60000, "register_type": "holding", "data_type": "float32"},
        {"name": "firmware_version", "starting_address": 60004, "register_type": "holding", "data_type": "float32"},
        {"name": "serial_number", "starting_address": 60028, "register_type": "holding", "data_type": "uint32"},
        # Analog loopback pair
        {"name": "ain0", "starting_address": 0, "register_type": "holding", "data_type": "float32"},
        {"name": "dac0", "starting_address": 1000, "register_type": "holding", "data_type": "float32"},
        # Digital loopback pair. On the T4, DIO#_STATE is a single register
        # per line at address 2000+#: writing sets the line as output and drives
        # the given state; reading leaves it as an input and samples it.
        {"name": "fio4_state", "starting_address": 2004, "register_type": "holding", "data_type": "uint16"},
        {"name": "fio5_state", "starting_address": 2005, "register_type": "holding", "data_type": "uint16"},
    ],
}


def _value(measurement, channel: str) -> float:
    """Unwrap the first sample for a given channel from a Measurement."""
    return measurement.channel_data[channel][0]


def main() -> None:
    lj = NominalModbus(config=CONFIG)
    lj.open()

    failures: list[str] = []

    try:
        print(f"Connected to LabJack T4 at {HOST}:{PORT}")

        # --- Device identity ---
        product_id = _value(lj.read("product_id"), f"{lj.name}.product_id")
        firmware = _value(lj.read("firmware_version"), f"{lj.name}.firmware_version")
        serial = _value(lj.read("serial_number"), f"{lj.name}.serial_number")
        print(f"  product_id      = {product_id}")
        print(f"  firmware        = {firmware}")
        print(f"  serial_number   = {serial}")

        # --- Analog loopback: DAC0 -> AIN0 ---
        print("\nAnalog loopback (DAC0 -> AIN0):")
        for target_v in (0.0, 0.5, 1.25, 2.5, 3.3):
            lj.write("dac0", target_v)
            time.sleep(0.05)  # let the DAC settle
            measured_v = _value(lj.read("ain0"), f"{lj.name}.ain0")
            error = measured_v - target_v
            status = "OK" if abs(error) <= ANALOG_TOLERANCE_V else "FAIL"
            print(f"  dac0 -> {target_v:.3f} V | ain0 = {measured_v:.4f} V | err = {error:+.4f} V [{status}]")
            if status == "FAIL":
                failures.append(f"analog: dac0={target_v} V, ain0={measured_v:.4f} V, err={error:+.4f} V")

        # Park DAC0 at 0 V when done.
        lj.write("dac0", 0.0)

        # --- Digital loopback: FIO4 (out) -> FIO5 (in) ---
        # On the T4, writing fio4_state implicitly makes FIO4 an output;
        # reading fio5_state implicitly makes FIO5 an input.
        print("\nDigital loopback (FIO4 out -> FIO5 in):")
        for driven_state in (0, 1, 0, 1, 0):
            lj.write("fio4_state", driven_state)
            time.sleep(0.05)
            read_state = int(_value(lj.read("fio5_state"), f"{lj.name}.fio5_state"))
            status = "OK" if read_state == driven_state else "FAIL"
            print(f"  fio4 <- {driven_state} | fio5 = {read_state} [{status}]")
            if status == "FAIL":
                failures.append(f"digital: fio4={driven_state}, fio5={read_state}")

        # Read fio4_state to revert the line from output back to input.
        lj.read("fio4_state")

        print()
        if failures:
            print(f"LOOPBACK FAILED ({len(failures)} issue(s)):")
            for f in failures:
                print(f"  - {f}")
        else:
            print("LOOPBACK PASSED")

    finally:
        print("\nClosing connection")
        lj.close()


if __name__ == "__main__":
    main()