Monday, 6 August 2018

RS-485 Modbus IoT Gateway using ESP8266 NodeMCU ESP-12E: TCP/IP Slave Part 2 of 3

Clockwise from top: USB3 externally-powered hub, RS-485 dongle, RS-485 to TTL serial PCB and ESP-12E ESP8266 NodeMCU module

Modbus TCP/IP to RS-485 passthrough gateway for RM25.

Part 1 describes an ESP-12E Modbus RTU Master using RS-485 interface. It can read and write RS-485 Modbus devices but it uses the ESP-12E debug serial port to do so. This works if the ESP-12E itself is the host controller, but Modbus masters usually have a lot more horsepower.

To be really useful, we can also make the ESP-12E a TCP/IP Modbus Slave. It still can work as a host, but this will make it a Modbus TCP/IP to RS485 "passthrough" gateway. A real Modbus host, say a desktop or Industrial PC can then orchestrate a whole bunch of Modbus devices to control a whole buildings' services.

A real TCP/IP to RS-485 Modbus gateway is some RM680

Of course the ESP-12E is not as powerful as a regular Modbus TCP/IP to RS485 passthrough but at RM25 it is an ideal way of retrofitting IoT functionality to Modbus devices.

If we replace the desktop with a cloud-based server we can scale  
As usual someone has already provided the required code. yaacov's ModbusSlaveTCP made an excellent template. I downloaded it as a zip file and copied yaacov's files from ArduinoModbusSlaveTCP-master/src into my Arduino IDE directory <your Linux account>/Arduino/libraries/ModbusSlaveTCP/

Then I expanded the sketch in Part 1:

#include <ESP8266WiFi.h>
#include <ModbusSlaveTCP.h>

const char* ssid = "YourAccessPoint";
const char* pass = "StrongPassword";
IPAddress staticIP(10,0,0,100);
IPAddress gateway(10,0,0,1);
IPAddress subnet(255,255,255,0);

// slave id = 1, rs485 control-pin = 8, baud = 9600
#define SLAVE_ID 1
// Modbus object declaration
ModbusTCP slave(SLAVE_ID);

#include <ModbusMaster232.h>
#include <SoftwareSerial.h>  // Modbus RTU pins   D7(13),D8(15)   RX,TX
// MAX485 half duplex control lines
#define not_RE 14 // D5. Enable receiver, active low
#define DE 12 // D6  Enable Transmitter, active high

// Instantiate ModbusMaster object as slave ID 1
  ModbusMaster232 node(1);

void setup() {
  pinMode(not_RE, OUTPUT);
  pinMode(DE, OUTPUT);
  // default to transmit mode to reduce noise
  digitalWrite(not_RE, HIGH); // disable receiver
  digitalWrite(DE, HIGH); // enable transmitter
  node.begin(9600);  // Modbus RTU
    /* Connect WiFi to the network
    Serial.print("Connecting to ");
    WiFi.begin(ssid, pass);
    WiFi.config(staticIP, gateway, subnet); // Static IP. Not required for dhcp

    int wifi_loop = 0;
    while (WiFi.status() != WL_CONNECTED) {
        if (wifi_loop++ == 10)
            Serial.println("Reconnecting ...");
            WiFi.begin(ssid, pass);
            wifi_loop = 0;
    /* register handler functions
     * into the modbus slave callback vector.
    slave.cbVector[CB_WRITE_COIL] = writeDigitlOut;
    slave.cbVector[CB_READ_DISCRETE_INPUT] = readDigitalIn; //    
    slave.cbVector[CB_READ_COILS] = readDigitalIn;
    slave.cbVector[CB_READ_REGISTERS] = readAnalogIn;
    slave.cbVector[CB_WRITE_MULTIPLE_REGISTERS] = writeAnalogOut; // cmheong
    /* start slave and listen to TCP port 502
    // log to serial port
    Serial.print("Modbus ready, listen on ");
    Serial.println(" : 502");

int loop_i = 0;
uint16_t readDiscreteInputs[10];
int Mdelay = 10; // from 5

void loop() {

  node.readDiscreteInputs(loop_i, 1);
  readDiscreteInputs[loop_i] = node.getResponseBuffer(0);
  Serial.print("] ");
  if (++loop_i >= 10)
    loop_i = 0;
  // delay(Mdelay); // no need for delay(5) since we print 5 char at 9600

    /* listen for modbus commands con serial port
     * on a request, handle the request.
     * if the request has a user handler function registered in cbVector
     * call the user handler function.

 * Handel Force Single Coil (FC=05)
 * set digital output pins (coils) on and off
void writeDigitlOut(uint8_t fc, uint16_t address, uint16_t status) {
    digitalWrite(address, status);

 * Handel Read Input Status (FC=02/01)
 * write back the values from digital in pins (input status).
 * handler functions must return void and take:
 *      uint8_t  fc - function code
 *      uint16_t address - first register/coil address
 *      uint16_t length/status - length of data / coil status
void readDigitalIn(uint8_t fc, uint16_t address, uint16_t length)
    int data = 0;
    // read digital input
    Serial.printf("digital input bytes fc %02x at address %04x length %d data ", fc, address, length);
    node.readDiscreteInputs(address, length);

    for (int i = 0; i <= (length-1)/8; i++) // cmheong 2018-08-06
      data = node.getResponseBuffer(i);
      slave.writeCoilsToBuffer(i, (uint8_t) data); // digitalRead(address + i));
      Serial.printf(" %x", node.getResponseBuffer(i));


 * Handel Read Input Registers (FC=04/03)
 * write back the values from analog in pins (input registers).
void readAnalogIn(uint8_t fc, uint16_t address, uint16_t length) {
    // read analog input
    for (int i = 0; i < length; i++) {
        slave.writeRegisterToBuffer(i, analogRead(address + i));

// cmheong 2018-07-31 write_registers()
void writeAnalogOut(uint8_t fc, uint16_t address, uint16_t length)
    Serial.printf("analog output bytes at address %04x length %d data ", address, length);
    for (int i = 0; i < length; i++)
        Serial.printf("%x ", slave.readRegisterFromBuffer(i));
        // node.writeSingleRegister(address, slave.readRegisterFromBuffer(i));
    node.writeMultipleRegisters(address, length);
I only tested 'Read Discrete Registers' (function code 2) and 'Write Multiple Registers' (function code 16) on a real Modbus RTU device, but you get the idea. yaakov's code did not process function code 2 properly, so I modifiled ModbusSlaveTCP.cpp of his library:

        case FC_READ_DISCRETE_INPUT: // read input state (digital in)
            address = word(bufIn[MLEN + 2], bufIn[MLEN + 3]); // coil to set.
            length = word(bufIn[MLEN + 4], bufIn[MLEN + 5]);

            // sanity check.
            if (length > MAX_BUFFER) return 0;

            // check command length.
            if (lengthIn != (MLEN + 6)) return 0;

            // build valid empty answer.
            lengthOut = MLEN + 3 + (length - 1) / 8 + 1; // cmheong 2018-08-06
            bufOut[MLEN + 2] = length;  // cmheong 2018-08-06

            // clear data out.
            memset(MLEN + bufOut + 2, 0, bufOut[2]);  // cmheong 2018-08-06

            if (cbVector[CB_READ_DISCRETE_INPUT]) // cmheong 2018-08-02
                cbVector[CB_READ_DISCRETE_INPUT](fc, address, length);

And added a new function:

void ModbusTCP::writeCoilsToBuffer(int offset, uint8_t state)
    int address = MLEN + 3 + offset;

    bufOut[address] = state;

 The ESP-12E will connect to your WiFi and use a fixed IP address (change it to suit your own address assignments) To test it, I used my laptop to connect to the same WiFi access point. I then modified pymodbus's excellent thus:

from pymodbus.client.sync import ModbusTcpClient as ModbusClient
client = ModbusClient('', method='rtu', port=502) # 2018-07-29

The test code is:
rr = client.read_discrete_inputs(1,1,unit=0x01)
if rr != None :
    print "\nread discrete inputs from", coils, rr.bits, '\n'

rq = client.write_registers(0x1001, [0x001f]*1, unit=0x01)
if rq != None :
    print "\write holding_registers from", 10, rq, '\n'

A sample working output is:

root@aspireF15:/home/heong/EMS/pymodbus/pymodbus-master/examples/current$python ./ 0x01
DEBUG:pymodbus.transaction:Current transaction state - IDLE
DEBUG:pymodbus.transaction:Running transaction 1
DEBUG:pymodbus.transaction:SEND: 0x0 0x1 0x0 0x0 0x0 0x6 0x1 0x2 0x0 0x1 0x0 0x1
DEBUG:pymodbus.client.sync:New Transaction state 'SENDING'
DEBUG:pymodbus.transaction:Changing transaction state from 'SENDING' to 'WAITING FOR REPLY'
DEBUG:pymodbus.transaction:Changing transaction state from 'WAITING FOR REPLY' to 'PROCESSING REPLY'
DEBUG:pymodbus.transaction:RECV: 0x0 0x1 0x0 0x0 0x0 0x4 0x1 0x2 0x1 0x7
DEBUG:pymodbus.framer.socket_framer:Processing: 0x0 0x1 0x0 0x0 0x0 0x4 0x1 0x2 0x1 0x7
DEBUG:pymodbus.factory:Factory Response[ReadDiscreteInputsResponse: 2]
DEBUG:pymodbus.transaction:Adding transaction 1
DEBUG:pymodbus.transaction:Getting transaction 1
DEBUG:pymodbus.transaction:Changing transaction state from 'PROCESSING REPLY' to 'TRANSACTION_COMPLETE'

read discrete inputs from 1 [True, True, True, False, False, False, False, False] 

DEBUG:pymodbus.transaction:Current transaction state - TRANSCATION_COMPLETE
DEBUG:pymodbus.transaction:Running transaction 2
DEBUG:pymodbus.transaction:SEND: 0x0 0x2 0x0 0x0 0x0 0x9 0x1 0x10 0x10 0x1 0x0 0x1 0x2 0x0 0x1f
DEBUG:pymodbus.client.sync:New Transaction state 'SENDING'
DEBUG:pymodbus.transaction:Changing transaction state from 'SENDING' to 'WAITING FOR REPLY'
DEBUG:pymodbus.transaction:Changing transaction state from 'WAITING FOR REPLY' to 'PROCESSING REPLY'
DEBUG:pymodbus.transaction:RECV: 0x0 0x2 0x0 0x0 0x0 0x6 0x1 0x10 0x10 0x1 0x0 0x1
DEBUG:pymodbus.framer.socket_framer:Processing: 0x0 0x2 0x0 0x0 0x0 0x6 0x1 0x10 0x10 0x1 0x0 0x1
DEBUG:pymodbus.factory:Factory Response[WriteMultipleRegistersResponse: 16]
DEBUG:pymodbus.transaction:Adding transaction 2
DEBUG:pymodbus.transaction:Getting transaction 2
DEBUG:pymodbus.transaction:Changing transaction state from 'PROCESSING REPLY' to 'TRANSACTION_COMPLETE'
Modbus device replied!

There you have it, a Modbus TCP/IP to RS485 passthrough gateway for less than RM25. Slap on an AWS or Google Cloud server and you are ready for a free docker microservice demon Modbus host.

Happy Trails!


  1. 'CB_READ_DISCRETE_INPUT' was not declared in this scope

    1. After you add in the section to ModbusSlaveTCP.cpp you also need to add in the callbacks in ModbusSlaveTCP.h

      void writeStringToBuffer(int offset, uint8_t *str, uint8_t length);