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
 
  Serial.begin(9600);
  delay(100);
  node.begin(9600);  // Modbus RTU
  delay(100);
 
   
    /* Connect WiFi to the network
     */
    Serial.print("Connecting to ");
    Serial.println(ssid);
    WiFi.disconnect();
    WiFi.hostname("ModbusTCPslaveRS485master");
    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid, pass);
    WiFi.config(staticIP, gateway, subnet); // Static IP. Not required for dhcp

    int wifi_loop = 0;
    while (WiFi.status() != WL_CONNECTED) {
        delay(1000);
        Serial.print(".");
        /*
        if (wifi_loop++ == 10)
        {
            Serial.println("Reconnecting ...");
            WiFi.disconnect();
            WiFi.mode(WIFI_STA);
            WiFi.begin(ssid, pass);
            delay(1000);
            wifi_loop = 0;
        }
        */
    }
    Serial.println(WiFi.localIP());
    /* 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
     */
    slave.begin();
   
    // log to serial port
    Serial.println("");
    Serial.print("Modbus ready, listen on ");
    Serial.print(WiFi.localIP());
    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);
  node.clearResponseBuffer();
  Serial.print("[");
  Serial.print(loop_i);
  Serial.print("] ");
  Serial.print(readDiscreteInputs[loop_i]);
  if (++loop_i >= 10)
  {
    Serial.println("");
    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.
     */
  slave.poll();
}

/**
 * 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));
    }

    delay(Mdelay);
   
    Serial.println("");
    node.clearResponseBuffer();
}

/**
 * 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));
        Serial.println("");
        node.send(slave.readRegisterFromBuffer(i));
        // node.writeSingleRegister(address, slave.readRegisterFromBuffer(i));
        delay(Mdelay);  
    }
    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);
            }
            break;

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) 10.0.0.100. To test it, I used my laptop to connect to the same WiFi access point. I then modified pymodbus's excellent synchronous-client.py thus:

from pymodbus.client.sync import ModbusTcpClient as ModbusClient
client = ModbusClient('10.0.0.100', 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 ./esp8266-tcpipclient.py 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!