Software Serial Modbus Master over RS485 transceiver

The Problem

We like cheap, well-made temperature controllers. Sure, we can make our own, but for a modest sum we can get a nice NEMA-rated package capable of reasonably complex logic with a communications interface. The Love 4C and 16C controllers can both be had for <$100 fit this description and make dependable control and higher-level monitoring possible.

The downside is that the interface is modbus over RS485, which requires a driver chip such as the Maxim RS485 which will translate to serial for control over a UART or other serial interface. This is not a killer, as you can get a nicely laid out RS485 chip from Sparkfun for $10, or make your own.

The reason for this piece is that all RS485 code I could find was designed for use with the hardware serial UART on microcontrollers such as Arduino or clones such as the Moteino. Because I typically use the UART for communication to a Pi or to a debug console, and the 328P I typically use has only one, this was not an option. To make it work over a few digital pins, it is actually quite easy to use the SoftwareSerial library to make this happen.

The Hardware

Here’s the basic board from SparkFun, a bargain at $10:

SparkFun Adafruit board.
SparkFun Adafruit board.

 

Here’s our desktop prototyping setup:

Our desktop prototyping setup.
Our desktop prototyping setup.

 

We’ve mounted this a bunch of ways, but here are a couple of examples:

An RS485 board mounted on a CuPID RF module with MightyBoost. A pretty typical configuration.
An RS485 board mounted on a CuPID RF module with MightyBoost. A pretty typical configuration.
Another mounting configuration on a CuPID RF with RF and MightyBoost. This one is wired.
Another mounting configuration on a CuPID RF with RF and MightyBoost. This one is wired.

Talking Modbus

The modbus protocol is actually quite simple. A frame is created with a bit of metadata that describes the data and then either transmits or waits for it. Because we are using 2-wire modbus rather than 4-wire, communication is not duplex, and we need to instruct the RS485 driver to enter transmit mode. We do this by pulling a digital pin high. This pin is typically referred to as RTS (Ready To Send). When we are done, we set RTS low and the transceiver again waits to receive data.

The data frame contains where who we are, where we are sending/receiving the data to/from, how much there is, and a CRC data integrity check. For more information, SimplyModbus is a great source for bit-by-bit breakdown. Note that ASCII and RTU also differ in the CRC format: RTU uses a two-byte CRC (CRC16),  while ASCII uses a two-character LRC.

An RTU read modbus message:

byte 0 1 2 3 4 5 6 7
value node FC register high register low # registers high # registers low CRC high CRC low

A modbus RTU command modbus message:

byte 0 1 2 3 4 5 6 7
value node FC register high register low set value high set value low CRC high CRC low

There are two types of Modbus typically used: ASCII and RTU. RTU is the binary data that comprises a frame (typically 8 bytes for a request), and ASCII is the byte-by-byte equivalent of the message in ASCII characters. So each hex byte character is translated to an ASCII character, doubling the message size (and adding a byte for a ‘:’ delimiter). Clearly, RTU is much more efficient, but ASCII as usual offers human-readability.

Although the manual for the 4C and 16C list ASCII as the type, the units have an RTU option, accessible in the communications menu.

The Code

The code for the master is quite simple, and operates on a simple state machine. This example is written for a series of 5 controllers with sequential addresses, but the number and addresses of devices polled can be easily configured by the initialization arrays. Registers polled are hard-coded, but could easily be parameterized or customized.

The sequence below sends and receives three basic commands, and rotates through them for each device. It utilizes a helper function to calculate and append CRC on the fly, as well as our serial/radio transmit functions on message receive:

#include <SoftwareSerial.h>

SoftwareSerial mySerial(14, 15, 0); // RX, TX
byte RTSPIN = 16;
int xmitdelay = 0;
unsigned long rxstart;
unsigned long rxwait = 1000;
float sv;
float pv;
byte rtuaddress=0;

// message : node, FC, register start high byte, register start low byte, number of registers, CRC, CRC
byte pvsvmessage[] = {0x01, 0x03, 0x47, 0x00, 0x00, 0x02, 0x00, 0x00 };
byte outputmessage[] = {0x01, 0x03, 0x47, 0x14, 0x00, 0x02, 0x00, 0x00 };
byte modemessage[] = {0x01, 0x03, 0x47, 0x18, 0x00, 0x02, 0x00, 0x00 };

switch ( mbstate ) {
case 0: // This is the transmit stage
  message[0] = rtuaddresses[rtuindex];
  for (byte i=1; i<6; i++) {
    switch (mbmessagetype) {
    case 0:
      message[i] = pvsvmessage[i];
      break;
    case 1:
      message[i] = outputmessage[i];
      break;
    case 2:
      message[i] = modemessage[i];
      break; 
    }
  }
  addcrc(message,6);
  if (DEBUG) {
    Serial.print("sending to controller: ");
    Serial.println(rtuaddresses[rtuindex]);
    Serial.print(message[0],HEX);
    Serial.print(" ");
    Serial.print(message[1],HEX);
    Serial.print(" ");
    Serial.print(message[2],HEX);
    Serial.print(" ");
    Serial.print(message[3],HEX);
    Serial.print(" ");
    Serial.print(message[4],HEX);
    Serial.print(" ");
    Serial.print(message[5],HEX);
    Serial.print(" ");
    Serial.print(message[6],HEX);
    Serial.print(" ");
    Serial.println(message[7],HEX);
  }
 
  pinMode(RTSPIN, OUTPUT);
  digitalWrite(RTSPIN,HIGH);
  delay(xmitdelay);

  mySerial.write(message, sizeof(message));
  delay(xmitdelay);
  digitalWrite(RTSPIN,LOW);
  mbstate = 1;
  rxstart = millis();
  break; 
case 1: // wait for response
  if (mySerial.available() > 0)
  {
    Blink(LED,5);
    cmdlength = mySerial.readBytes(buff, 60);
    if (DEBUG) {
      Serial.print("Received message of length ");
      Serial.println(cmdlength);

      for (i=0; i<cmdlength;i++){
        Serial.print(buff[i], HEX);
        Serial.print(" ");
      }
      Serial.println();
    }
 
    if (buff[1]==3) {
      if (mbmessagetype == 0){
        pv = (float(buff[3] & 255) * 256 + float(buff[4] & 255))/10;
        sv = (float(buff[5] & 255) * 256 + float(buff[6] & 255))/10;
        if (DEBUG) {
          Serial.println("values");
 
          Serial.print("nodeid:");
          Serial.print(NODEID);
          Serial.print(",controller:"); 
          Serial.print(rtuindex);
          Serial.print(",pv:"); 
          Serial.print(pv);
          Serial.print(",sv:");
          Serial.println(sv);
        }
      }
      else if (mbmessagetype == 1) {
        pv = (float(buff[3] & 255) * 256 + float(buff[4] & 255));
        sv = (float(buff[5] & 255) * 256 + float(buff[6] & 255));
        if (DEBUG) {
          Serial.print("Proportional offset: ");
          Serial.println(pv);
          Serial.print("Regulation value");
          Serial.println(sv);
        }
      }
      else if (mbmessagetype == 2) {
        pv = (float(buff[3] & 255) * 256 + float(buff[4] & 255));
        sv = (float(buff[5] & 255) * 256 + float(buff[6] & 255));
        if (DEBUG) {
          Serial.print("Heating/cooling");
          Serial.println(pv);
          Serial.print("Run/Stop");
          Serial.println(sv);
        }
      }
      Blink(SENDLED,5);
      sendControllerMessage(rtuaddresses[rtuindex], pv, sv, mbmessagetype);
    }
    else {
      if (DEBUG) {
        Serial.println("bad response");
      }
    } 
    if (millis() - rxstart > rxwait) {
      if (mbmessagetype >= 2) {
        mbmessagetype = 0;
        if (rtuindex >= sizeof(rtuaddresses)-1) {
          rtuindex = 0;
        }
        else {
          rtuindex ++;
        }
      }
      else {
        mbmessagetype ++;
      }
      mbstate=0;
    }
    break;
  } // switch
}

We use a controller message function and CRC as here:

void sendControllerMessage(byte controller, float pv, float sv, byte messagetype) {
 
  // Initialize send string

  int sendlength = 61; // default
  int wholepv = pv;
  int fractpv = (pv - wholepv) * 1000;
  int wholesv = sv;
  int fractsv = (sv - wholesv) * 1000;
 
  if (messagetype == 0) {
    if (NODEID == 1) {
      sendlength = 39; 
      sprintf(buff, "nodeid:1,chan:%02d,sv:%03d.%03d,pv:%03d.%03d", controller, wholesv, fractsv, wholepv, fractpv);
      Serial.println(buff);
    }
    else {
      sendlength = 30; 
      sprintf(buff, "chan:%02d,sv:%03d.%03d,pv:%03d.%03d", controller, wholesv, fractsv, wholepv, fractpv);
      sendWithSerialNotify(GATEWAYID, buff, sendlength, 1); 
    }
 }
 else if (messagetype == 1) {
   if (NODEID == 1) {
     sendlength = 37; 
     sprintf(buff, "nodeid:1,chan:%02d,prop:%03d,treg:%03d.%01d", controller,wholepv, wholesv, fractpv);
     Serial.println(buff);
   }
   else {
     sendlength = 28; 
     sprintf(buff, "chan:%02d,prop:%03d,treg:%03d.%01d", controller,wholepv, wholesv, fractsv);
 sendWithSerialNotify(GATEWAYID, buff, sendlength, 1); 
     }
   }
   else if (messagetype == 2) {
     if (NODEID == 1) {
     sendlength = 31; 
     sprintf(buff, "nodeid:1,chan:%02d,htcool:%01d,run:%01d", controller,wholepv,wholesv);
     Serial.println(buff);
   }
   else {
     sendlength = 22; 
     sprintf(buff, "chan:%02d,htcool:%01d,run:%01d", controller,wholepv, wholesv);
     sendWithSerialNotify(GATEWAYID, buff, sendlength, 1); 
   }
 }
}
void addcrc(byte* message, int len) {
  mycrc = ModRTU_CRC(message, len);

  long byte1 = mycrc & 255;
  long byte2 = (mycrc & long(255*256))>>8;
  if (DEBUG) {
    Serial.print(byte1,HEX);
    Serial.print(",");
    Serial.println(byte2,HEX);
  }
  message[len] = byte1;
  message[len + 1] = byte2;
}

In debug mode, we get something like what’s shown below:

sending to controller: 1
1 3 47 0 0 2 D0 BF
Received message of length 9
1 3 4 2 FFFFFFCE 3 FFFFFFE8 FFFFFF9A FFFFFFCA 
values
nodeid:2,controller:0,pv:71.80,sv:100.00

SENDING TO 1
chan:01,sv:100.000,pv:071.800
SEND COMPLETE

sending to controller: 1
1 3 47 14 0 2 90 BB
Received message of length 9
1 3 4 0 0 0 0 FFFFFFFA 33 
Proportional offset: 0.00
Regulation value0.00

SENDING TO 1
chan:01,prop:000,treg:000.0
SEND COMPLETE

sending to controller: 1
1 3 47 18 0 2 50 B8
Received message of length 9
1 3 4 0 0 0 1 3B FFFFFFF3 
Heating/cooling0.00
Run/Stop1.00 

SENDING TO 1
chan:01,htcool:0,run:1
SEND COMPLETE

When we’re not in debug, only the messages between the “SENDING” and “SEND COMPLETE” are sent, which our CuPID gateway picks up and logs.

Writing Values

In MODBUS, writing values is as simple as changing the function code. The message format is as above. We need function code 6, and to format our values. We borrow some command processing code, so we can issue setpoint value commands over serial.

The other piece is value conversion, which we glossed over earlier. We have to convert back and forth between our float values. The values in this particular case are stored in tenths of. i.e. a setpoint or process value of 123.4 will be stored as 1234. In hex, this is 04 D2.

So we use the following to go from bytes to value:

pv = (float(buff[3] & 255) * 256 + float(buff[4] & 255))/10;

And this to go from value to bytes:

int highbyte = (sv * 10) / 256;
int lowbyte = int(sv * 10) & 255;

And what we end up with is a routine to send our messages nicely:

void sendsvmessage(int node, float sv) {
  message[0] = node;
  for (byte i=1; i<6; i++) {
    message[i] = setmessage[i];
  }
  Serial.print("received setpoint:");
  Serial.println(sv);
  int highbyte = (sv * 10) / 256;
  int lowbyte = int(sv * 10) & 255;

  message[4] = highbyte;
  message[5] = lowbyte;
  
  addcrc(message,6);
 
  pinMode(RTSPIN, OUTPUT);
  digitalWrite(RTSPIN,HIGH);
  delay(xmitdelay);

  mySerial.write(message, sizeof(message));

  delay(xmitdelay);
  digitalWrite(RTSPIN,LOW);
 
  rxstart = millis();
  mbstate = 1;
}

Finally, it would be nice to confirm that the command was received, and return a command to the serial or radio interface so that the gateway or user can confirm that it was accepted (without looking at the device. So we write in a handler for the message reader:

else if (buff[1]==6) {
 
  sv = (float(buff[4] & 255) * 256 + float(buff[5] & 255))/10;
  if (DEBUG) {
    Serial.print("Command acknowledged for node:");
    Serial.println(buff[0]);
    Serial.print("Setpoint: ");
    Serial.println(sv);
  }
  sendCmdResponseMessage(buff[0], sv);
}
void sendCmdResponseMessage(byte controller, float sv) {
  // Initialize send string

  int sendlength = 61; // default
  int wholesv = sv;
  long fractsv = ((long)(sv*1000))%1000;
 
  if (NODEID == 1) {
    sendlength = 31; 
    sprintf(buff, "nodeid:1,chan:%02d,svcmd:%03d.%03d", controller, wholesv, fractsv);
 Serial.println(buff);
  }
  else {
    sendlength = 23; 
    sprintf(buff, "chan:%02d,svcmd:%03d.%03d", controller, wholesv, fractsv);
    sendWithSerialNotify(GATEWAYID, buff, sendlength, 1); 
  }
}

And we get what we expect on the way out:

SENDING SV CMD
sending to controller: 1
1 6 47 1 1 C9 D 78
Received message of length 8
1 6 47 1 1 FFFFFFC9 D 78 
Command acknowledged for node:
Setpoint: 45.70

SENDING TO 1
chan:01,svcmd:045.700
SEND COMPLETE

This code can be located on the git repo here:

https://github.com/iinnovations/iicontrollibs/tree/master/mote/brew/brewmotewrite

3 thoughts on “Software Serial Modbus Master over RS485 transceiver”

  1. Modbus looks interesting..

    I use a similar protocol called Firmata for my arduino and rs-422 shield (full duplex).

    Would there be any advantages to switching to Modbus?

    1. I’m not familiar with Firmata (I’ll give it a google), but the advantage of Modbus is that it’s everywhere. It’s the most commonly used communication protocol for industrial devices. It’s very basic – it contains zero metadata and requires you have a register map to know where to look for things and how to interpret them – but the simplicity is an advantage in many cases.

      Check here or SimplyModbus for more information: http://www.cupidcontrols.com/2014/08/your-cupid-as-a-plc-modbus-tcp-client/

      C

Leave a Reply

Your email address will not be published. Required fields are marked *