Using Modbus RTU and RS485 with Arduino and ESP32 (Part 1/3)

This article will demonstrate how to use an Arduino Mega and ESP32 to read Modbus485 sensors data using a MAX485 and MAX13487E module. The first example is an Arduino Mega with MAX485 to read a ten-in-one sensor. And the second example is using a ESP32 and MAX13487E module to read a temperature and humidity sensor.

Arduino Mega and a ten-in-one environmental sensor

This MAX 485 module is cheap and has a simple circuit diagram. However the MAX485 does not offer an automatic direction control and hot swap protection. The need of controlling the flow direction pin makes the module a bit unstable and less easy to use. This module operates at 5V.

Let’s take a look at the sensor’s manual. And we will use function 0x03-Read Holding Registers, to read all the 11 type of data.

Sensor’s user manual

The command in hexadecimal is as follow:

Device addressFunction CodeStarting Address HighStarting Address LowQuantity HighQuantity lowCRC HighCRC low
0x010x030x000x000x000x0B0x040x0D
Master Read Command (from arduino)
Device address Function Code Number of ByteseCO2_HeCO2_LTVOC_HTVOC_L
0x01 0x03 0x16 0x02 0x4A 0x00 0xC4
Slave Reply (from sensor) Part1
…………MCU_TEMP_HMCU_TEMP_LdB_HdB_LCRC_HCRC_L
………… 0x00 0xC8 0x00 0x42 0x53 0x25
Slave Reply (from sensor) Part2
Read holding register waveform

There are one logic zero start bit and one logic one stop bit, the 8-bit data are in between. This communication has a baud rate of 9600.

/*

  RS485_HalfDuplex.pde - example using ModbusMaster library to communicate
  with EPSolar LS2024B controller using a half-duplex RS485 transceiver.

  This example is tested against an EPSolar LS2024B solar charge controller.
  See here for protocol specs:
  http://www.solar-elektro.cz/data/dokumenty/1733_modbus_protocol.pdf

  Library:: ModbusMaster
  Author:: Marius Kintel <marius at kintel dot net>

  Copyright:: 2009-2016 Doc Walker

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at

      http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.

*/

#include <ModbusMaster.h>

/*!
  We're using a MAX485-compatible RS485 Transceiver.
  Rx/Tx is hooked up to the hardware serial port at 'Serial'.
  The Data Enable and Receiver Enable pins are hooked up as follows:
*/
#define MAX485_DE      3 //Driver output Enable pin DE Active HIGH
#define MAX485_RE_NEG  2 //receiver output Enable pin RE Active LOW
/////////////////////////In many cases you may also shorting both pin together
// instantiate ModbusMaster object
ModbusMaster node;

void preTransmission()  //set up call back function
{
  digitalWrite(MAX485_RE_NEG, 1);
  digitalWrite(MAX485_DE, 1);
}

void postTransmission()   //set up call back function
{
  digitalWrite(MAX485_RE_NEG, 0);
  digitalWrite(MAX485_DE, 0);
}

void setup()
{
  pinMode(MAX485_RE_NEG, OUTPUT);
  pinMode(MAX485_DE, OUTPUT);
  // Init in receive mode
  digitalWrite(MAX485_RE_NEG, 0);
  digitalWrite(MAX485_DE, 0);

  // Modbus communication runs at 115200 baud
  Serial.begin(9600);
  Serial2.begin(9600); //serial 2: RX2 and TX2 in Arduino Mega
  // Modbus slave ID 1, numbers are in decimal format
  node.begin(1, Serial2);  //data from max 485 are communicating with serial2
  // Callbacks allow us to configure the RS485 transceiver correctly
  node.preTransmission(preTransmission);
  node.postTransmission(postTransmission);
}


void loop()
{
  uint8_t result;  

  // Read 16 registers starting at 0x00, read 11 register. Meaning that read 0x00, then read 0x01, so on and so forth. Until the eleventh resister 0x0A
  result = node.readHoldingRegisters(0x0000, 11);
  if (result == node.ku8MBSuccess)
  {
    Serial.println("------------");
    Serial.print("eCO2: ");
    Serial.println(node.getResponseBuffer(0x00));
    Serial.print("TVOC: ");
    Serial.println(node.getResponseBuffer(0x01));
    Serial.print("CH2O: ");
    Serial.println(node.getResponseBuffer(0x02));
    Serial.print("PM2.5: ");
    Serial.println(node.getResponseBuffer(0x03));
    Serial.print("HUMI: ");
    Serial.println(node.getResponseBuffer(0x04)/100.0f);
    Serial.print("TEMP: ");
    Serial.println(node.getResponseBuffer(0x05)/100.0f);
    Serial.print("PM10: ");
    Serial.println(node.getResponseBuffer(0x06));
    Serial.print("PM1.0: ");
    Serial.println(node.getResponseBuffer(0x07));
    Serial.print("Lux: ");
    Serial.println(node.getResponseBuffer(0x08));
    Serial.print("MCU TEMP: ");
    Serial.println(node.getResponseBuffer(0x09)/100.0f);
    Serial.print("NOISE (dB): ");
    Serial.println(node.getResponseBuffer(0x0A));
  }
  delay(5000);


}
Schematic diagram for Arduino Mega and ten-in-one sensor
ESP32 and temperature sensor

For ESP32, I switched to another 485 to TTL module, MAX13487E. The MAX13487E supports TTL-side hot swapping and auto direction control, which make this chip much easier to use then MAX485. From the datasheet, the MAX13487E also requires a 5v power . But this module works in my set up, so please take your own risk when powering with 3.3v.

The hexadecimal code ESP32 is sending to the sensor is:

Device addressFunction CodeStarting Address HighStarting Address LowQuantity HighQuantity lowCRC HighCRC low
0x490x030x000x200x000x020xCA0x49
Master Read Command (from ESP32)
Reading data and print on serial monitor
#include <ModbusMaster.h>
int errorcnt =0;
int cycle =0;

#define RXD2 16
#define TXD2 17

ModbusMaster node;

void setup() {
  Serial.begin(9600);
  Serial2.begin(9600, SERIAL_8N1, RXD2, TXD2); //using serial 2 to read the signal from MAX13487E
  node.begin(73, Serial2);
}

void loop()
{
  uint8_t result;  

  // Read 16 registers starting at 0x3100)
  result = node.readHoldingRegisters(0x20, 2);
  if (result == node.ku8MBSuccess)
  {
    Serial.println("------------");
    Serial.print("Temp: ");
    Serial.println(node.getResponseBuffer(0x00)/10.0f);
    Serial.print("Humi: ");
    Serial.println(node.getResponseBuffer(0x01)/10.0f);

    Serial.print("ERROR count: ");
    Serial.println(errorcnt);    
    Serial.print("cycle: ");
    Serial.println(cycle);  
    cycle++;
  }
  else {
    errorcnt++;
    cycle++;
    Serial.print("ERROR count: ");
    Serial.println(errorcnt);
  }
  delay(5000);

}
XY-107 with SP3485E 485 to TTL module

There is another common 485 to TTL module using SP3485E. This chip operates with 3.3V and is 5V logic tolerant. The SP3485 does not support auto direction control but uses hardware to latch the enable pin. Please note that the TXD pin is connecting to the TX pin of the MCU, and RXD to RX.

In part 2, we will discuss a few common RS485 to TTL modules and check out which one is more power efficient.

Chinese Text generation for LILYGO T5-4.7 inch E-Paper ESP32

There is a python script on the LilyGo-EPD47 GitHub for text generation. But the program is not optimized for Chinese words, one big reason is that the Struct GFXfont uses Unicode interval to provide a range of characters. We usually won’t import the whole Chinese dictionary into ESP32 because there are way too many words. Instead, we will search the Unicode for the corresponding Chinese characters and only import the words which will be displayed.

And that comes to a problem when using the fontconvert.py. Usually, those Chinese words we are using will not have a close Unicode number. Take 像素(pixel)as an example, these 2 Chinese words have a Unicode of U+50CF and U+7D20. And there are already 11346 words in between, a full set of fonts could easily cover more than twenty thousand characters which the esp32 just doesn’t have enough memory to handle.

I am using these two very useful links to inspect the font file and check the corresponding Unicode of any characters. For example, I want to import a sentence “最大像素 and esp32”, found out their Unicode: 0x6700, 0x5927, 0x50CF, 0x7D20 and sort them in ascending order. Finally, load them into a modified version of fontconvert.py.

#!python3
from code import interact
import freetype
import zlib
import sys
import re
import math
import argparse
from collections import namedtuple

parser = argparse.ArgumentParser(description="Generate a header file from a font to be used with epdiy.")
parser.add_argument("name", action="store", help="name of the font.")
parser.add_argument("size", type=int, help="font size to use.")
parser.add_argument("fontstack", action="store", nargs='+', help="list of font files, ordered by descending priority.")
parser.add_argument("--compress", dest="compress", action="store_true", help="compress glyph bitmaps.")
args = parser.parse_args()

GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "compressed_size", "data_offset", "code_point"])

font_stack = [freetype.Face(f) for f in args.fontstack]
compress = args.compress
size = args.size
font_name = args.name

# inclusive unicode code point intervals
# must not overlap and be in ascending order
intervals = [
    [32, 126],
    [160, 255],
    [0x50CF],
    [0x5927],
    [0x6700],
    [0x7D20],

    # (0x2500, 0x259F),
    # (0x2700, 0x27BF),
    # # powerline symbols
    # (0xE0A0, 0xE0A2),
    # (0xE0B0, 0xE0B3),
    # (0x1F600, 0x1F680),
]
newintervals = []

def norm_floor(val):
    return int(math.floor(val / (1 << 6)))

def norm_ceil(val):
    return int(math.ceil(val / (1 << 6)))

for face in font_stack:
    # shift by 6 bytes, because sizes are given as 6-bit fractions
    # the display has about 150 dpi.
    face.set_char_size(size << 6, size << 6, 150, 150)

def chunks(l, n):
    for i in range(0, len(l), n):
        yield l[i:i + n]

total_size = 0
total_packed = 0
all_glyphs = []

def load_glyph(code_point):
    face_index = 0
    while face_index < len(font_stack):
        face = font_stack[face_index]
        glyph_index = face.get_char_index(code_point)
        if glyph_index > 0:
            face.load_glyph(glyph_index, freetype.FT_LOAD_RENDER)
            return face
            break
        face_index += 1
        print (f"falling back to font {face_index} for {chr(code_point)}.", file=sys.stderr)
    raise ValueError(f"code point {code_point} not found in font stack!")

for interval in intervals:
    if len(interval) <= 1:
        interval.append(interval[0]+1)
    newintervals.append(interval)


for i_start, i_end in newintervals:
    for code_point in range(i_start, i_end + 1):
        face = load_glyph(code_point)
        bitmap = face.glyph.bitmap
        pixels = []
        px = 0
        for i, v in enumerate(bitmap.buffer):
            y = i / bitmap.width
            x = i % bitmap.width
            if x % 2 == 0:
                px = (v >> 4)
            else:
                px = px | (v & 0xF0)
                pixels.append(px);
                px = 0
            # eol
            if x == bitmap.width - 1 and bitmap.width % 2 > 0:
                pixels.append(px)
                px = 0

        packed = bytes(pixels);
        total_packed += len(packed)
        compressed = packed
        if compress:
            compressed = zlib.compress(packed)

        glyph = GlyphProps(
            width = bitmap.width,
            height = bitmap.rows,
            advance_x = norm_floor(face.glyph.advance.x),
            left = face.glyph.bitmap_left,
            top = face.glyph.bitmap_top,
            compressed_size = len(compressed),
            data_offset = total_size,
            code_point = code_point,
        )
        total_size += len(compressed)
        all_glyphs.append((glyph, compressed))

# pipe seems to be a good heuristic for the "real" descender
face = load_glyph(ord('|'))

glyph_data = []
glyph_props = []
for index, glyph in enumerate(all_glyphs):
    props, compressed = glyph
    glyph_data.extend([b for b in compressed])
    glyph_props.append(props)

print("total", total_packed, file=sys.stderr)
print("compressed", total_size, file=sys.stderr)

print("#pragma once")
print("#include \"epd_driver.h\"")
print(f"const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
for c in chunks(glyph_data, 16):
    print ("    " + " ".join(f"0x{b:02X}," for b in c))
print ("};");

print(f"const GFXglyph {font_name}Glyphs[] = {{")
for i, g in enumerate(glyph_props):
    print ("    { " + ", ".join([f"{a}" for a in list(g[:-1])]),"},", f"// {chr(g.code_point) if g.code_point != 92 else '<backslash>'}")
print ("};");

print(f"const UnicodeInterval {font_name}Intervals[] = {{")
offset = 0
for i_start, i_end in newintervals:
    print (f"    {{ 0x{i_start:X}, 0x{i_end:X}, 0x{offset:X} }},")
    offset += i_end - i_start + 1
print ("};");

print(f"const GFXfont {font_name} = {{")
print(f"    (uint8_t*){font_name}Bitmaps,")
print(f"    (GFXglyph*){font_name}Glyphs,")
print(f"    (UnicodeInterval*){font_name}Intervals,")
print(f"    {len(newintervals)},")
print(f"    {1 if compress else 0},")
print(f"    {norm_ceil(face.size.height)},")
print(f"    {norm_ceil(face.size.ascender)},")
print(f"    {norm_floor(face.size.descender)},")
print("};")