Modbus Protocol Communication

This guide covers implementing Modbus protocol communication in INDI drivers using the nanomodbus library. Modbus is a widely-used industrial communication protocol for connecting PLCs, sensors, actuators, relays, and other automation equipment.

Table of Contents

  1. Modbus Protocol Communication
    1. Overview
      1. What is Modbus?
      2. When to Use Modbus
      3. Library Used
    2. Modbus Basics
      1. Data Model
      2. Common Function Codes
    3. Basic Setup
      1. Include Headers
      2. Driver Class Setup
    4. Modbus TCP Communication
      1. Initializing TCP Connection
      2. Creating Modbus Client
    5. Modbus RTU Communication
      1. Initializing Serial Connection
      2. Creating Modbus RTU Client
    6. Reading Data
      1. Reading Coils (Digital Outputs)
      2. Reading Discrete Inputs (Digital Inputs)
      3. Reading Holding Registers (Read/Write 16-bit)
      4. Reading Input Registers (Read-only 16-bit)
    7. Writing Data
      1. Writing Single Coil
      2. Writing Multiple Coils
      3. Writing Single Register
      4. Writing Multiple Registers
    8. Complete Working Example
      1. Initialization
      2. Handshake and Client Creation
      3. Reading Outputs (Coils)
      4. Writing Output (Coil)
      5. Reading Inputs (Discrete Inputs)
    9. Error Handling
      1. Checking for Errors
      2. Common Error Codes
    10. Best Practices
      1. 1. Set Appropriate Timeouts
      2. 2. Handle Device-Specific Addressing
      3. 3. Batch Operations When Possible
      4. 4. Validate Register Ranges
      5. 5. Implement Retry Logic
      6. 6. Use Polling for Continuous Monitoring
      7. 7. Handle Byte Order (Endianness)
    11. CMake Configuration
      1. Required Setup
    12. Debugging Tips
      1. Enable Modbus Logging
      2. Verify Device Communication
      3. Monitor Communication
    13. Modbus TCP vs RTU Comparison
    14. Additional Resources
    15. Support

Overview

What is Modbus?

Modbus is a serial communication protocol originally developed for industrial automation. It’s now available in several variants:

  • Modbus RTU: Serial communication (RS-232/RS-485)
  • Modbus TCP: Ethernet-based communication
  • Modbus ASCII: Human-readable serial format

When to Use Modbus

Use Modbus communication when your device:

  • Is an industrial control device (PLC, relay board, sensor)
  • Explicitly supports Modbus protocol
  • Uses standard Modbus registers (coils, discrete inputs, holding registers, input registers)
  • Requires reliable request/response communication
  • Needs to read/write multiple data points efficiently

Library Used

nanomodbus: A compact, portable Modbus library included with INDI

  • Supports both Modbus RTU and Modbus TCP
  • Small memory footprint
  • Platform-agnostic
  • Simple, straightforward API
  • Built into INDI library (no external dependencies)

Modbus Basics

Data Model

Modbus organizes data into four tables:

Data Type Access Address Range Function Codes Description
Coils Read/Write 0-65535 01, 05, 15 Digital outputs (ON/OFF)
Discrete Inputs Read-only 0-65535 02 Digital inputs (ON/OFF)
Holding Registers Read/Write 0-65535 03, 06, 16 16-bit registers (configuration, setpoints)
Input Registers Read-only 0-65535 04 16-bit registers (sensor readings)

Common Function Codes

  • 01: Read Coils
  • 02: Read Discrete Inputs
  • 03: Read Holding Registers
  • 04: Read Input Registers
  • 05: Write Single Coil
  • 06: Write Single Register
  • 15: Write Multiple Coils
  • 16: Write Multiple Registers

Basic Setup

Include Headers

#include "connectionplugins/connectiontcp.h"  // For Modbus TCP
// or
#include "connectionplugins/connectionserial.h"  // For Modbus RTU

#include "libs/modbus/platform.h"  // nanomodbus platform functions

Driver Class Setup

class MyModbusDriver : public INDI::DefaultDevice
{
public:
    MyModbusDriver();
    virtual bool initProperties() override;
    virtual bool Handshake() override;
    
private:
    // Modbus client instance
    nmbs_t nmbs;
    
    // Connection (TCP or Serial)
    Connection::TCP *tcpConnection { nullptr };
    int PortFD { -1 };
};

Modbus TCP Communication

Initializing TCP Connection

bool MyModbusDriver::initProperties()
{
    INDI::DefaultDevice::initProperties();

    // Set up TCP connection for Modbus
    tcpConnection = new Connection::TCP(this);
    tcpConnection->setDefaultHost("192.168.1.100");
    tcpConnection->setDefaultPort(502);  // Standard Modbus TCP port
    tcpConnection->registerHandshake([&]()
    {
        return Handshake();
    });

    registerConnection(tcpConnection);

    return true;
}

Creating Modbus Client

bool MyModbusDriver::Handshake()
{
    PortFD = tcpConnection->getPortFD();

    // Configure platform for TCP
    nmbs_platform_conf platform_conf;
    platform_conf.transport = NMBS_TRANSPORT_TCP;
    platform_conf.read = read_fd_linux;
    platform_conf.write = write_fd_linux;
    platform_conf.arg = &PortFD;

    // Create the modbus client
    nmbs_error err = nmbs_client_create(&nmbs, &platform_conf);
    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error creating modbus client: %d", err);
        return false;
    }

    // Set response timeout (milliseconds)
    nmbs_set_read_timeout(&nmbs, 1000);

    // Verify connection with a test read
    uint16_t test_register;
    err = nmbs_read_holding_registers(&nmbs, 0, 1, &test_register);
    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Failed to communicate with device: %s", nmbs_strerror(err));
        return false;
    }

    LOG_INFO("Modbus connection established");
    return true;
}

Modbus RTU Communication

Initializing Serial Connection

bool MyModbusDriver::initProperties()
{
    INDI::DefaultDevice::initProperties();

    // Set up serial connection for Modbus RTU
    serialConnection = new Connection::Serial(this);
    serialConnection->setDefaultBaudRate(Connection::Serial::B_9600);
    serialConnection->registerHandshake([&]()
    {
        return Handshake();
    });

    registerConnection(serialConnection);

    return true;
}

Creating Modbus RTU Client

bool MyModbusDriver::Handshake()
{
    PortFD = serialConnection->getPortFD();

    // Configure platform for RTU
    nmbs_platform_conf platform_conf;
    platform_conf.transport = NMBS_TRANSPORT_RTU;
    platform_conf.read = read_fd_linux;
    platform_conf.write = write_fd_linux;
    platform_conf.arg = &PortFD;

    // Create the modbus client
    nmbs_error err = nmbs_client_create(&nmbs, &platform_conf);
    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error creating modbus client: %d", err);
        return false;
    }

    // Set read timeout and byte timeout for RTU
    nmbs_set_read_timeout(&nmbs, 1000);
    nmbs_set_byte_timeout(&nmbs, 100);

    // Set destination address (slave ID)
    nmbs_set_destination_rtu_address(&nmbs, 1);

    LOG_INFO("Modbus RTU connection established");
    return true;
}

Reading Data

Reading Coils (Digital Outputs)

Coils are 1-bit read/write values, typically used for relay outputs or digital control signals.

bool MyModbusDriver::ReadRelayStates()
{
    // Buffer to hold coil states (bits packed into bytes)
    nmbs_bitfield coils = {0};

    // Read 8 coils starting at address 0
    nmbs_error err = nmbs_read_coils(&nmbs, 0, 8, coils);
    
    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error reading coils: %s", nmbs_strerror(err));
        return false;
    }

    // Extract individual coil states
    for (int i = 0; i < 8; i++)
    {
        bool state = nmbs_bitfield_read(coils, i);
        LOGF_DEBUG("Coil %d: %s", i, state ? "ON" : "OFF");
        
        // Update INDI properties
        RelayStatesSP[i].reset();
        RelayStatesSP[i][state ? 0 : 1].setState(ISS_ON);
        RelayStatesSP[i].setState(IPS_OK);
        RelayStatesSP[i].apply();
    }

    return true;
}

Reading Discrete Inputs (Digital Inputs)

Discrete inputs are 1-bit read-only values, typically used for switches, sensors, or status signals.

bool MyModbusDriver::ReadDigitalInputs()
{
    nmbs_bitfield inputs_buffer = {0};

    // Read 8 discrete inputs starting at address 0
    nmbs_error err = nmbs_read_discrete_inputs(&nmbs, 0, 8, inputs_buffer);

    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error reading discrete inputs: %s", nmbs_strerror(err));
        return false;
    }

    // Process input states
    for (int i = 0; i < 8; i++)
    {
        bool state = nmbs_bitfield_read(inputs_buffer, i);
        
        // Update INDI properties
        if (DigitalInputsSP[i].findOnSwitchIndex() != state)
        {
            DigitalInputsSP[i].reset();
            DigitalInputsSP[i][state].setState(ISS_ON);
            DigitalInputsSP[i].setState(IPS_OK);
            DigitalInputsSP[i].apply();
        }
    }

    return true;
}

Reading Holding Registers (Read/Write 16-bit)

Holding registers store 16-bit values for configuration, setpoints, or control values.

bool MyModbusDriver::ReadConfiguration()
{
    // Buffer for register values
    uint16_t registers[4];

    // Read 4 holding registers starting at address 0x1000
    nmbs_error err = nmbs_read_holding_registers(&nmbs, 0x1000, 4, registers);

    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error reading holding registers: %s", nmbs_strerror(err));
        return false;
    }

    // Process register values
    int setpoint = registers[0];
    int timeout = registers[1];
    int mode = registers[2];
    int status = registers[3];

    LOGF_INFO("Setpoint: %d, Timeout: %d, Mode: %d, Status: %d",
              setpoint, timeout, mode, status);

    return true;
}

Reading Input Registers (Read-only 16-bit)

Input registers are read-only 16-bit values, typically used for sensor readings.

bool MyModbusDriver::ReadSensors()
{
    uint16_t sensor_data[3];

    // Read 3 input registers starting at address 0
    nmbs_error err = nmbs_read_input_registers(&nmbs, 0, 3, sensor_data);

    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error reading input registers: %s", nmbs_strerror(err));
        return false;
    }

    // Convert to actual values (depends on device scaling)
    double temperature = sensor_data[0] / 10.0;  // e.g., 235 = 23.5°C
    double humidity = sensor_data[1] / 10.0;
    double pressure = sensor_data[2];

    LOGF_INFO("Temperature: %.1f°C, Humidity: %.1f%%, Pressure: %d hPa",
              temperature, humidity, pressure);

    return true;
}

Writing Data

Writing Single Coil

bool MyModbusDriver::SetRelay(uint8_t relay_index, bool state)
{
    // Value: 0xFF00 for ON, 0x0000 for OFF
    uint16_t value = state ? 0xFF00 : 0x0000;

    nmbs_error err = nmbs_write_single_coil(&nmbs, relay_index, value);

    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error writing coil %d: %s", relay_index, nmbs_strerror(err));
        return false;
    }

    LOGF_INFO("Relay %d set to %s", relay_index, state ? "ON" : "OFF");
    return true;
}

Writing Multiple Coils

bool MyModbusDriver::SetMultipleRelays(uint8_t start_address, uint8_t count, bool *states)
{
    // Pack boolean states into bitfield
    nmbs_bitfield coils = {0};
    for (int i = 0; i < count; i++)
    {
        nmbs_bitfield_write(coils, i, states[i]);
    }

    nmbs_error err = nmbs_write_multiple_coils(&nmbs, start_address, count, coils);

    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error writing multiple coils: %s", nmbs_strerror(err));
        return false;
    }

    return true;
}

Writing Single Register

bool MyModbusDriver::SetSetpoint(uint16_t value)
{
    nmbs_error err = nmbs_write_single_register(&nmbs, 0x1000, value);

    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error writing register: %s", nmbs_strerror(err));
        return false;
    }

    LOGF_INFO("Setpoint set to %d", value);
    return true;
}

Writing Multiple Registers

bool MyModbusDriver::WriteConfiguration(uint16_t *values, uint8_t count)
{
    nmbs_error err = nmbs_write_multiple_registers(&nmbs, 0x1000, count, values);

    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error writing multiple registers: %s", nmbs_strerror(err));
        return false;
    }

    LOGF_INFO("Written %d configuration registers", count);
    return true;
}

Complete Working Example

From waveshare_modbus_relay.cpp - Waveshare Modbus relay board driver:

Initialization

bool WaveshareRelay::initProperties()
{
    INDI::DefaultDevice::initProperties();

    // Initialize interfaces
    INDI::OutputInterface::initProperties("Relays", 8, "Output");
    INDI::InputInterface::initProperties("Digital Inputs", 8, 0, "Input");

    setDriverInterface(AUX_INTERFACE | OUTPUT_INTERFACE);

    // Set up TCP connection
    tcpConnection = new Connection::TCP(this);
    tcpConnection->setDefaultHost("192.168.1.1");
    tcpConnection->setDefaultPort(502);
    tcpConnection->registerHandshake([&]() { return Handshake(); });
    registerConnection(tcpConnection);

    return true;
}

Handshake and Client Creation

bool WaveshareRelay::Handshake()
{
    PortFD = tcpConnection->getPortFD();

    // Configure platform for TCP
    nmbs_platform_conf platform_conf;
    platform_conf.transport = NMBS_TRANSPORT_TCP;
    platform_conf.read = read_fd_linux;
    platform_conf.write = write_fd_linux;
    platform_conf.arg = &PortFD;

    // Create modbus client
    nmbs_error err = nmbs_client_create(&nmbs, &platform_conf);
    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error creating modbus client: %d", err);
        return false;
    }

    nmbs_set_read_timeout(&nmbs, 1000);

    // Check for input support
    nmbs_bitfield inputs_buffer = {0};
    m_HaveInput = nmbs_read_discrete_inputs(&nmbs, 0, 8, inputs_buffer) == NMBS_ERROR_NONE;
    
    if (m_HaveInput)
    {
        setDriverInterface(AUX_INTERFACE | OUTPUT_INTERFACE | INPUT_INTERFACE);
        syncDriverInfo();
    }

    // Read firmware version
    uint16_t output;
    err = nmbs_read_holding_registers(&nmbs, 0x8000, 1, &output);
    if (err == NMBS_ERROR_NONE)
    {
        std::stringstream ss;
        ss << std::fixed << std::setprecision(2) << output / 100.0;
        FirmwareVersionTP[0].setText(ss.str().c_str());
        FirmwareVersionTP.setState(IPS_OK);
        return true;
    }

    return false;
}

Reading Outputs (Coils)

bool WaveshareRelay::UpdateDigitalOutputs()
{
    nmbs_bitfield coils = {0};
    auto err = nmbs_read_coils(&nmbs, 0, 8, coils);
    
    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error reading coils: %s", nmbs_strerror(err));
        return false;
    }

    for (size_t i = 0; i < DigitalOutputsSP.size(); i++)
    {
        auto oldState = DigitalOutputsSP[i].findOnSwitchIndex();
        auto newState = nmbs_bitfield_read(coils, i);
        
        if (oldState != newState)
        {
            DigitalOutputsSP[i].reset();
            DigitalOutputsSP[i][newState].setState(ISS_ON);
            DigitalOutputsSP[i].setState(IPS_OK);
            DigitalOutputsSP[i].apply();
        }
    }
    
    return true;
}

Writing Output (Coil)

bool WaveshareRelay::CommandOutput(uint32_t index, OutputState command)
{
    uint16_t value = (command == OutputState::On) ? 0xFF00 : 0;

    auto err = nmbs_write_single_coil(&nmbs, index, value);
    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error writing coil %d: %s", index, nmbs_strerror(err));
        return false;
    }

    return true;
}

Reading Inputs (Discrete Inputs)

bool WaveshareRelay::UpdateDigitalInputs()
{
    nmbs_bitfield inputs_buffer = {0};

    nmbs_error err = nmbs_read_discrete_inputs(&nmbs, 0, 8, inputs_buffer);

    if (err != NMBS_ERROR_NONE)
    {
        LOGF_ERROR("Error reading discrete inputs: %s", nmbs_strerror(err));
        return false;
    }

    for (int i = 0; i < 8; i++)
    {
        auto oldStateIndex = DigitalInputsSP[i].findOnSwitchIndex();
        auto newState = nmbs_bitfield_read(inputs_buffer, i);

        if (oldStateIndex != newState)
        {
            DigitalInputsSP[i].reset();
            DigitalInputsSP[i][newState].setState(ISS_ON);
            DigitalInputsSP[i].setState(IPS_OK);
            DigitalInputsSP[i].apply();
        }
    }

    return true;
}

Error Handling

Checking for Errors

Always check return values and use nmbs_strerror() for meaningful error messages:

nmbs_error err = nmbs_read_coils(&nmbs, 0, 8, coils);

if (err != NMBS_ERROR_NONE)
{
    LOGF_ERROR("Modbus error: %s", nmbs_strerror(err));
    
    // Check if it's a Modbus exception
    if (nmbs_error_is_exception(err))
    {
        LOGF_ERROR("Modbus exception code: %d", err);
    }
    
    return false;
}

Common Error Codes

// Timeout errors
if (err == NMBS_ERROR_TIMEOUT)
{
    LOG_ERROR("Communication timeout - check connection");
}

// Invalid parameters
if (err == NMBS_ERROR_INVALID_ARGUMENT)
{
    LOG_ERROR("Invalid address or quantity");
}

// Invalid response
if (err == NMBS_ERROR_INVALID_RESPONSE)
{
    LOG_ERROR("Device sent invalid response");
}

Best Practices

1. Set Appropriate Timeouts

// TCP: typically 1-3 seconds
nmbs_set_read_timeout(&nmbs, 1000);

// RTU: add byte timeout for serial
nmbs_set_read_timeout(&nmbs, 1000);
nmbs_set_byte_timeout(&nmbs, 100);

2. Handle Device-Specific Addressing

Many Modbus devices use non-standard address offsets:

// Some devices start coils at address 1, not 0
const int COIL_OFFSET = 1;

// Adjust addresses when reading/writing
nmbs_write_single_coil(&nmbs, relay_index + COIL_OFFSET, value);

3. Batch Operations When Possible

Reading/writing multiple registers in one operation is more efficient:

// Less efficient: Multiple single reads
for (int i = 0; i < 10; i++)
{
    uint16_t value;
    nmbs_read_holding_registers(&nmbs, i, 1, &value);
}

// More efficient: Single batch read
uint16_t values[10];
nmbs_read_holding_registers(&nmbs, 0, 10, values);

4. Validate Register Ranges

bool MyDriver::ValidateAddress(uint16_t address, uint16_t quantity)
{
    // Check device-specific limits
    const uint16_t MAX_COILS = 64;
    
    if (address + quantity > MAX_COILS)
    {
        LOGF_ERROR("Address range exceeds device limits: %d-%d",
                   address, address + quantity - 1);
        return false;
    }
    
    return true;
}

5. Implement Retry Logic

bool MyDriver::ReadWithRetry(uint16_t address, uint16_t count, uint16_t *buffer)
{
    const int MAX_RETRIES = 3;
    
    for (int retry = 0; retry < MAX_RETRIES; retry++)
    {
        nmbs_error err = nmbs_read_holding_registers(&nmbs, address, count, buffer);
        
        if (err == NMBS_ERROR_NONE)
            return true;
            
        if (retry < MAX_RETRIES - 1)
        {
            LOGF_WARN("Read failed, retrying (%d/%d)...", retry + 1, MAX_RETRIES);
            usleep(100000);  // 100ms delay
        }
    }
    
    LOG_ERROR("Read failed after maximum retries");
    return false;
}

6. Use Polling for Continuous Monitoring

void MyDriver::TimerHit()
{
    if (!isConnected())
        return;

    // Update all data points
    UpdateDigitalInputs();
    UpdateDigitalOutputs();
    UpdateSensorReadings();

    // Schedule next update
    SetTimer(getCurrentPollingPeriod());
}

7. Handle Byte Order (Endianness)

Some devices may require byte swapping:

// Swap bytes if needed (depends on device)
uint16_t SwapBytes(uint16_t value)
{
    return ((value & 0xFF00) >> 8) | ((value & 0x00FF) << 8);
}

// For 32-bit values from two 16-bit registers
uint32_t CombineRegisters(uint16_t high, uint16_t low)
{
    return ((uint32_t)high << 16) | low;
}

CMake Configuration

Required Setup

Modbus support is built into INDI, so no external dependencies are needed:

cmake_minimum_required(VERSION 3.16)
PROJECT(indi_modbus_device CXX)

find_package(INDI REQUIRED)

include_directories(${CMAKE_CURRENT_BINARY_DIR})
include_directories(${CMAKE_CURRENT_SOURCE_DIR})
include_directories(${INDI_INCLUDE_DIR})

add_executable(indi_modbus_device
    modbus_device.cpp
    modbus_device.h
)

target_link_libraries(indi_modbus_device
    ${INDI_LIBRARIES}
)

install(TARGETS indi_modbus_device RUNTIME DESTINATION bin)

Debugging Tips

Enable Modbus Logging

// Log all Modbus communication (if supported by library version)
#ifdef NMBS_DEBUG
nmbs_set_debug_level(&nmbs, NMBS_DEBUG_ALL);
#endif

Verify Device Communication

Test basic connectivity before complex operations:

bool MyDriver::TestConnection()
{
    // Try reading a known register
    uint16_t test_value;
    nmbs_error err = nmbs_read_holding_registers(&nmbs, 0, 1, &test_value);
    
    if (err == NMBS_ERROR_NONE)
    {
        LOGF_INFO("Connection OK, test register value: 0x%04X", test_value);
        return true;
    }
    
    LOGF_ERROR("Connection test failed: %s", nmbs_strerror(err));
    return false;
}

Monitor Communication

Use INDI logging to track Modbus operations:

// Before operation
LOGF_DEBUG("Reading %d coils from address %d", count, address);

// After operation
if (err == NMBS_ERROR_NONE)
{
    LOGF_DEBUG("Successfully read coils");
}
else
{
    LOGF_DEBUG("Failed to read coils: %s", nmbs_strerror(err));
}

Modbus TCP vs RTU Comparison

Feature Modbus TCP Modbus RTU
Transport Ethernet RS-232/RS-485
Connection Connection::TCP Connection::Serial
Address IP:Port Slave ID (1-247)
CRC Not needed Automatic
Speed Fast (100Mbps+) Slower (9600-115200 baud)
Distance 100m+ (with switches) Up to 1200m (RS-485)
Setup NMBS_TRANSPORT_TCP NMBS_TRANSPORT_RTU
Byte Timeout Not used Required

Additional Resources

  • nanomodbus Documentation: Included in INDI source tree at libs/modbus/
  • Modbus Specification: modbus.org
  • INDI API Documentation: https://www.indilib.org/api/
  • Modbus Calculator: Online tools for address conversion and register mapping

Support

For questions and support: