The Sunday Blog: Talking to your Nextion HMI

Part 7: Time to wrap things in classes

Over the last weeks, we have seen how a few lines of Python or C++ (Arduino) code allowed us to fully control our Nextion HMI and how we could catch events happening there on the MCU (Arduino) side. Now, these were simple examples and if you remember well from episode 4, our code was compact and worked well, but this was for only one component on a single page. Also, episode 6 gave us the opportunity to “listen” to generic Touch Press and Touch Release events, but that’s far from covering the full list of data which the Nextion HMI can send back to the MCU.

Time to make things more flexible and to de-clutter our Arduino Sketches. You may naturally use the official Nextion Library, but if you are like me and you want to understand how things work and you want to remain in control of every.single.byte, you are cordially invited to read this and the coming episodes and follow the idea of modularizing our elementary functions and to wrap them up in a few classes. I don’t know yet where this will end, but odds are good that we end up with a simple, flexible, and compact miniature edition of a Nextion library which we will fully understand since we have written it ourselves, and which at the same time overcomes some restrictions of the actual library.

Which serial port(s) to use?

As I have always stated, one should use a MCU with 2 or more hardware serial ports, so that we have one for communicating with the Nextion HMI and another for debugging purpose, so that we can trace things in the Serial Monitor of the Arduino, Eclipse or VS-Code IDE. Most modern MCUs have much more than only 2 UARTs and it would be nice if we could use several of them to communicate with 2 or 3 Nextion HMIs. I admit, that’s a rather rare use case, but if technically doable, we should be able to do so. Thus, our future “mini library” should handle multiple Nextion HMI and serial port instances.

On the other side, some of the most prominent Arduinos like the UNO or some small and nifty PIC MCUs have only one single UART, so that we must be able to work either without a second port, or use an emulated one. These software emulations use GPIO pins and functions to drive the TX pin high and low, and they use interrupts to react on level changes at the RX pin. All this eats naturally precious CPU resources, thus the execution speed risks to be reduced, compared to a hardware UART which handles everything through FIFO buffers, so that the CPU can do other things in the meantime. But it’s better than nothing.

We’ll thus have to tell our future C++ class which serial ports we intend to use. There is only one hurdle: If we pass the serial ports as variables or references to our class during initialization, the C++ compiler will want to know the type of each variable or reference. In the simplest case, all our ports are HardwareSerial types. But they can also be SoftwareSerial, or AltSoftSerial (using a famous alternative library), or USBSerial, or…, or…, or… Since each type is (seen with the compiler’s eyes) different and uses for example different code inside the respective .begin() functions. Thus, to satisfy the compiler, we’ll have not only to tell which serial ports we use, but also their respective types.

Fortunately, modern versions of C++ allow template classes. That means that we write a single class, but it will be compiled differently, depending on the template parameters. And this is the place where we tell which types our serial ports have.

Let’s thus create a new file (open a new tab in the Arduino IDE) and name it newNextion.h. There, we’ll put the template pattern which will allow us to set the respective types for the Nextion and Debug serial ports first and then start by declaring an empty class:

template<class nexSerType, class dbgSerType>
class NexComm
{
};

The class constructor and initialization of member variables

Then, we need a so-called constructor. That’s the member function which is called once, when a real object, based on that class, is instantiated. It is used to initialize the object and to (pre-)set internal variables. We declare our internal variables in the “private:” section of our class definition because we won’t need to access these from our Arduino main sketch. What we need are variables for the Nextion serial port, the Debug serial port, both declared with their types from the template, a boolean to set if the Debug port should basically be used or not, and finally another boolean variable which will allow us to control if we want actually to output debug messages in case we previously allowed the use of the Debug port. During initialization, when having 2 serial ports at hands, we’ll by default use the debug port, thus _useDbg will be true, and we will enable debug messages, thus _dbgEn will take the same value by default. Our class looks now like this:

template<class nexSerType, class dbgSerType>
class NexComm
{
  public:
    NexComm(nexSerType& nexSer, dbgSerType& dbgSer): _nexSer(nexSer), _dbgSer(dbgSer) , _useDbg(true)
    {
      this->_dbgEn = this->_useDbg;
    }   
  private:
    nexSerType& _nexSer;
    dbgSerType& _dbgSer;
    bool _useDbg;
    bool _dbgEn;  
};

In our main sketch file, we may now declare our first NexComm object. For that, we need to include our newly created newNextion.h file before we proceed:

#include "newNextion.h" 
NexComm<HardwareSerial, HardwareSerial> nex1(Serial1, Serial);

In this example, we use an Arduino Mega and use hardware serial ports: Serial1 for the Nextion and Serial for debugging. But almost all configurations for all MCUs are possible with this highly flexible declaration. Imagine, we want to add a second Nextion HMI and connect it to Serial2, we can immediately create a second NexComm object as follows:

#include "newNextion.h" 
NexComm<HardwareSerial, HardwareSerial> nex1(Serial1, Serial);
NexComm<HardwareSerial, HardwareSerial> nex2(Serial2, Serial);

The compiler will then create a second instance without us having to create and install a copy of the library and renaming all object names, as it would be needed with most “classic” libraries. Isn’t that nice? And the attentive reader has also discovered that this highly flexible initialization allows us to get rid of all the needed #define tags which makes our code still simpler and easier to read!

What if I don’t want to use the debug port?

That’s another piece of C++ class magic. We will just add a second constructor function which will only take the Nextion serial port as a parameter. But since we are on a template class, things are slightly more complex because the template pattern doesn’t care, it requires type definitions and initialization for both ports as long as the class has variables for both ports, be they used or not. But this can be solved in a simple way. We extend the template with a default value for the debug port type and we re-use the type of the Nextion port. In a same manner, we initialize the debug port in the constructor simply with the Nextion serial port. That doesn’t matter since we’ll set _useDbg to false at the same time and the debug port reference which points towards the Nextion port will never be addressed afterwards. So, our class looks now like this:

template<class nexSerType, class dbgSerType = nexSerType>
class NexComm
{
  public:
    NexComm(nexSerType& nexSer, dbgSerType& dbgSer): _nexSer(nexSer), _dbgSer(dbgSer) , _useDbg(true)
    {
      this->_dbgEn = this->_useDbg;
    }   
    NexComm(nexSerType& nexSer): _nexSer(nexSer), _dbgSer(nexSer) , _useDbg(false)
    {
      this->_dbgEn = this->_useDbg;
    }
  private:
    nexSerType& _nexSer;
    dbgSerType& _dbgSer;
    bool _useDbg;
    bool _dbgEn;  
};

Now, we can alternatively set up our Nextion communication without declaring a second serial port for debugging or even use mixed versions, one with and one without debugging:

#include "newNextion.h" 
NexComm<HardwareSerial, HardwareSerial> nex1(Serial1, Serial);
NexComm<HardwareSerial> nex2(Serial2);

Then, we need to write a little function wrapper which will output messages over the debug serial port, but only if _useDbg and _dbgEn are true. Since this function _dbgOut(String) is only for internal use, we put it again in the private section. Then, we’ll need still a public function dbgEnable(bool) to switch the debug output on and off. Our class is slowly growing and looks now like this:

template<class nexSerType, class dbgSerType = nexSerType>
class NexComm
{
  public:
    NexComm(nexSerType& nexSer, dbgSerType& dbgSer): _nexSer(nexSer), _dbgSer(dbgSer) , _useDbg(true)
    {
      this->_dbgEn = this->_useDbg;
    }   
    NexComm(nexSerType& nexSer): _nexSer(nexSer), _dbgSer(nexSer) , _useDbg(false)
    {
      this->_dbgEn = this->_useDbg;
    }
   void dbgEnable(bool en)
    {
      if (this->_useDbg)
      {
        this->_dbgEn = en;
      } else
      {
        this->_dbgEn=false;  
      }
    }
  private:
    nexSerType& _nexSer;
    dbgSerType& _dbgSer;
    bool _useDbg;
    bool _dbgEn;  
    void _dbgOut(String txt) {
      if (_useDbg && _dbgEn)
      {
        this->_dbgSer.println(txt);
      }
    }
};

Since the _dbgOut() function checks each time when called if the corresponding boolean flags allow debug output, we can later throw as many debug messages as we want in other class functions without caring. They will only be sent if the flags allow it.

In the beginning was the begin() before we can send commands

No serial communication without begin. Thus, our class needs a public function begin() which internally calls _nexSer.begin() and, if _useDbg is true, _dbgSer.begin() with the corresponding baud rates. To make things easier, you can call NexComm.begin() without parameters, then the default parameters, 9600 baud for both will be automatically applied. Last, but not least (for today), we add the already well known sendCmd() function:

template<class nexSerType, class dbgSerType = nexSerType>
class NexComm
{
 public:
   NexComm(nexSerType& nexSer, dbgSerType& dbgSer): _nexSer(nexSer), _dbgSer(dbgSer) , _useDbg(true)
   {
     this->_dbgEn = this->_useDbg;
   }   
   NexComm(nexSerType& nexSer): _nexSer(nexSer), _dbgSer(nexSer) , _useDbg(false)
   {
     this->_dbgEn = this->_useDbg;
   }
   void begin(uint32_t nexBaud = 9600, uint32_t dbgBaud = 9600)
   {
     if (this->_useDbg)
     {
       this->_dbgSer.begin(dbgBaud);
       while (!this->_dbgSer);
       this->_dbgOut("Debug Serial ready!");
     }
     this->_nexSer.begin(nexBaud);
     while (!this->_nexSer);
     this->_nexSer.write("\xFF\xFF\xFF");
      this->_dbgOut("Nextion Serial ready & buffer cleared!");
   }
   void dbgEnable(bool en)
   {
     if (this->_useDbg)
     {
       this->_dbgEn = en;
     } else
     {
       this->_dbgEn=false;  
     }
   }
   void sendCmd(String cmd)
   {
     this->_nexSer.print(cmd);
     this->_nexSer.write("\xFF\xFF\xFF");
     this->_dbgOut("Command sent: "+cmd);
   }    
 private:
   nexSerType& _nexSer;
   dbgSerType& _dbgSer;
   bool _useDbg;
   bool _dbgEn;  
   void _dbgOut(String txt) {
     if (_useDbg && _dbgEn)
     {
       this->_dbgSer.println(txt);
     }
   }
};

With this, less than 60 lines of code, we have already a very flexible and universal toolbox to handle the interaction between multiple Nextion HMIs and whatever Arduino compatible MCU. In the next episodes, we’ll integrate an improved listening part into our class and then move on to address components with their attributes (read and write)  and event handling.

If you can’t wait, here already a “Hello world” test sketch which can be used with the Nextion GUI firmware from episode 4 where we did the text effect stuff. This simplicity is delightful:

#include "newNextion.h" 
NexComm<HardwareSerial, HardwareSerial> nex1(Serial1, Serial);
void setup() {
 nex1.begin();
 nex1.sendCmd("t0.txt=\"Hello world!\"");
}
void loop() {
}

The excitement continues, stay tuned!