ESP32: Using a modular approach

8 minute read

Where I examine the differences between writing a monolithic program versus a much more modular program.

Important

While I display some of the code on this page, the absolute truth of working code is on github. Use it to fully understand and/or implement what I describe below. The github site also contains the latest information in its README.

Introduction

Many of the examples of programming the ESP32 are monolithic programs, the data gathering code is combined with the setup code and the web display code. A monolithic approach is not a good programming practice for several reasons. I’ll go into detail as to why you won’t want to program in this manner, as well as how to write code in a more modular fashion.

All this said, this is not a criticism of the code developers, themselves. Many times, the best and easiest to understand code, are monolithic programs which accomplish multiple elements. What we want to do is to know that once the initial attempt works (in a single file) we will want to break it down in to modular pieces.

Why Modular?

Modular programming is the concept of having blocks of code, each existing in a separate file, having one purpose and (ideally) be fully tested and re-usable. This allows one to quickly develop new applications based on the combination of existing modules and adding new functionality by new modules.

This modular approach has the benefits of:

  1. Its easy to test a module as your are only testing for one set of functionality.
  2. A module is easy to understand as the code is typically short and focused on providing a specific purpose.
  3. Faster development occurs, as one is only compiling the changed code in a small specific file (typically), so the compilation times decrease.

How to Start

There are several ways to start when attempting to make a program more modular. If I already have a good understanding of the board and software, I might attempt to take an existing program which is monolithic (typically from an educational example) and break it into modules.

As I don’t have a good understanding of the ESP32 SoC along with the Arduino C++ approach, I’m going to develop a set of modules which provide specific functions, then pull them together. Writing the program in this manner, helps me to develop a better understanding of how the ESP32 board works.

For example, I know I want to have the following, with the ESP32 board acting as:

  • an Access Point with a Webserver
  • providing a graph of data
  • controlled by buttons and dials

I will want to create a module for each bullet point, test the module then write a sketch which pulls them all together.

Update

While the three bullet points above remain essentially correct, I expanded on the concept of cards introduced by RNT. Each card is an element on the web page and is specific section of code in the index.htm, as well as a specific module with a header and code files, card_n.h and card_n.c, respectively. This follows the concept mentioned above, which allows one to add/remove/test cards separately, without seriously affecting the rest of the code. Herein lies the importance of modularity.

The new bullet points appear more like:

  • an Access Point startup, Webserver startup, and card setup (if required) in Dashboard.ino (minimal changes going forward)
  • home provides the code specific to displaying the webpages (minimal changes going forward)
  • processor provides the simple state machine which moves through the different cards
  • cards_(0-n) which provide the specific routes to display results and/or control a sensor
  • index.htm and script.js

The last three bullets are the ones, which need a change for every new card.

I have also replaced SPIFFS with LittleFS, as my reading indicated that it was more reliable and faster than SPIFFS. Please review the detailed notes as to how to implement or in the README.

Access Point/Start Server

The first step is to simplify the initial start of the application so that it creates an access point and a server, however, nothing more. This file doesn’t need to change, once it is fully tested. To develop it, I began with an initial monolithic file from RNT which creates an access point, starts a server and displays an index.html web page and style.css which resides in the ESP32 file system SPIFFS THe steps I followed were:

  1. Pull everything out the .ino, which doesn’t create an access point or server
  2. Put the SSID and Password into a arduino_secrets.h file
  3. Change the argument to the server to be by reference as it will be accessed in another file.
  4. To simplify a bit of programming, I did include the setup for various modules (cards) in the Dashboard.ino file as well.

The new .ino file, renamed Dashboard.ino looks like this:

/*
  Dashboard.ino creates a WiFi access point and starts the Asynch Server
  If you are adding code here, you are probably doing something wrong.
*/

#include <WiFi.h>
#include <WiFiAP.h>
#include <ESPAsyncWebServer.h>
#include <LittleFS.h>
#include <Arduino_JSON.h>

#include "arduino_secrets.h"
#include "serve.h"
#include "card_0.h"
#include "card_1.h"
#include "card_2.h"
#include "card_3.h"

// switch from SPIFFS to LittleFS

// Serial Port Constants and Variables
#define SERIAL_BAUD 921600

// Access Point Constants and Variables
const char *ssid = SECRET_AP;
const char *password = SECRET_PASS;


// Create AsyncWebServer 
#define WEB_PORT 80
AsyncWebServer server(WEB_PORT);

// Create an Event Source on /events
AsyncEventSource events("/events");

// Timer variables
unsigned long lastTime = 0;
unsigned long timerDelay = 10000;

void setup() {
    // Serial port for debugging purposes
    Serial.begin(SERIAL_BAUD);

    // Initialize SPIFFS
    if(!LittleFS.begin(true)){
    Serial.println("An Error has occurred while mounting LittleFS");
    return;
    }
    // Card 1: configure LED ON/OFF properties
    pinMode(LED0, OUTPUT);
    pinMode(LED1, OUTPUT);

    // Card 2: configure GPIO PWM properties
    ledcSetup(LEDCHANNEL, FREQUENCY, RESOLUTION);
    ledcAttachPin(LED2, LEDCHANNEL);
    ledcWrite(LEDCHANNEL, sliderValue.toInt());

    Serial.begin(921600);
    Serial.println();
    Serial.println("Configuring access point...");

    WiFi.softAP(ssid, password);
    IPAddress myIP = WiFi.softAPIP();
    Serial.print("AP IP address: ");
    Serial.println(myIP);

    events.onConnect([](AsyncEventSourceClient *client){
      if(client->lastId()){
        Serial.printf("Client reconnected! Last message ID that it got is: %u\n", client->lastId());
      }
      // send event with message "hello!", id current millis
      // and set reconnect delay to 1 second
      client->send("hello!", NULL, millis(), 10000);
    });
    server.addHandler(&events);

    Serial.println("Server started");
    serve(&server);
    server.begin();

}

void loop() {
    if ((millis() - lastTime) > timerDelay) {
      // Send Events to the client with the Sensor Readings Every 10 seconds
      events.send("ping",NULL,millis());
      events.send(getSensorReadings().c_str(),"new_readings" ,millis());
      lastTime = millis();
    }
}

Web Pages

I like the example code from RNT as it demonstrates how to use the ESP32 file system, SPIFFS to hold the files for the web pages. This is a preferable approach than to store them as variable data or as print statements. In the former, it makes the code a bit unwieldy as its more meta-data than data, meaning you can’t use test the page using a PC-based web server. In the latter case, having to execute print statements for each line of code becomes laborious for anything but a couple lines of code.

I used the styles.css file with very little changes, however, I made substantial changes to the index.html page:

  1. The original was intended to connect to the internet so there was a connection to fontawesome in the metadata at the top. As this code won’t connect to the internet, I had to remove the reference.
  2. Due to #1, I wanted to find lightweight icon files which could be stored locally so I used svg files. These are great as they are line drawings which typically consume 500-700 bytes at any size.
  1. The example only had one card and I added another and will expand this aspect of the file quite a bit.
<!DOCTYPE html>
<html>
  <head>
    <title>Dashboard</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" type="text/css" href="style.css">
    <link rel="icon" type="image/png" href="favicon.png">
  </head>
  <body>
    <div class="topnav">
      <h1>Dashboard</h1>
    </div>
    <div class="content">
      <div class="card-grid">

        <!-- Card 1 -->
        <div class="card">
          <p class="card-title">
            <img src = "lightbulb.svg" alt="Light Bulb image"/>
            LED 1</p>
          <p>
            <a href="on1"><button class="button-on">
              <i><img src = "switch-closed.svg" alt="closed switch image "/></i>
            </button></a>
            <a href="off1"><button class="button-off">
              <i><img src = "switch-open.svg" alt="open switch image"/></i>
            </button></a>
          </p>
          <p class="state">State: %STATE1%</p>
        </div>
        <!-- End of Card 1 -->

        <!-- Card 2 -->
        <div class="card">
          <p class="card-title">
            <img src = "lightbulb.svg" alt="Light Bulb image"/>
            LED 2</p>
              <p><span id="textSliderValue">%SLIDERVALUE%</span></p>
              <p><input type="range" onchange="updateSliderPWM(this)" id="pwmSlider" min="0" max="255" value="%SLIDERVALUE%" step="1" class="slider"></p>
            <script>
            function updateSliderPWM(element) {
              var sliderValue = document.getElementById("pwmSlider").value;
              document.getElementById("textSliderValue").innerHTML = sliderValue;
              console.log(sliderValue);
              var xhr = new XMLHttpRequest();
              xhr.open("GET", "/slider?value="+sliderValue, true);
              xhr.send();
            }
            </script>
        </div>
        <!-- End of Card 2 -->

      </div>
    </div>
  </body>
</html>

home/processor/serve Code

There are three files which provide the actual dashboard, home, processor and serve.

home

The home.cpp file provides the file routes used by the browser (as compared to cards, which provide data routes). A file route is the code following the url of a website requesting a file. For example, http://192.168.2.100 will have an /. A blank “/” indicates to a browser to request “index.html” and that corresponds to line 8 “server->on(”/", HTTP_GET,…", and the rest of the command shows the file index.html, how to access it LittleFS and the browser MIME type text/html.

There is also a general purpose route, line 12 “server->serveStatic(”/", LittleFS, “/”);" which greatly simplifies the remaining file routes. It says to serve any static files using LittleFS in the same folder, such as script.js, style.css, and even icon files.

processor

The processor.cpp file provides the template responses. Each card will have its own name STATEn, where n = card_n. And each STATEn will have logic as to what to display. If the card is handling a variable or template variable, there must be an entry in the file to process what needs to happen. It will help to review existing cards to create a new one.

serve

The serve.cpp file provides the hooks for each card to serve. It is a very simple file, however, it too must be updated for each additional card.

Adding cards

Here is additional information in the README as to how to add a new card to the Website. (It doesn’t include how to solve formatting issues as when one card is much longer to the one beside it in the row.):

  1. serve: pretty simple.
  • add an include to the .h file
  • add a “card_n(server);” to the .cpp file
  1. processor(optional): this file centralizes the processor code required for each card. Some cards won’t require processor code such as one using the canvas_gauge code.
  • add an include to the .h file
  • If the card has a variable, which appears as “/variable” in the route, a processor needs to exist to fill-in the variable.
  1. Dashboard.ino(optional, only if setup is required.): if there is code required to setup the sensor or dial (such as PinMode or ledCAttachPin) then add that to the Setup() code. If it needs to be performed on a periodic basis, add an event to loop().
  2. card_n: this is the main code for each specific card and provides specifically what code is required for the code to display on index.html. I recommend you look at the existing cards, duplicate one that is closest and go from there. Remember to add the declaration code to card_n.h.
  3. data/index.html: each card requires a card section, similar to card_n, find one that is similar…
  4. data/script.js(optional): if the sensor or dial requires an event, you will need to add javascript code to manage it in the script.js file.

Comments powered by Talkyard.