iron's blog

ESP32 Programming

Last week I started using a small Wemos D1 Mini ESP32 development board for a small project. The ESP32 is a small microcontroller that has WiFi capabilities, so it can create and join WiFi networks. Having WiFi capabilities means that the ESP32 can easily be used for IoT and Home Automation purposes.

HTTP server

Creating a small HTTP server on the ESP32 is quite easy, especially if a embedded filesystem such as LittleFS is used. A very minimal hello world can be seen below.

WiFiClient client = server.available();
if (!client) { return; }

int newlines = 0;
while (client.connected())
{
    if (!client.available()) { continue; }

    char c = client.read();
    if (c == '\r') { continue; }
    if (c != '\n') {
        // HTTP request and headers are discarded.
        newlines = 0;
        continue;
    }
    newlines++;

    if (newlines == 2)
    {
        client.println("HTTP/1.1 200 Ok");
        client.println("Content-type: text/html");
        client.println("Connection: close");
        client.println("");
        client.println("<html>Hello World!</html>");
        break;
    }
}
client.stop();

A real version would ofcourse parse the HTTP request and headers, doing so is fairly trivial (just fairly long).

Programmable HTTP server

Having a basic HTTP server is nice, but there is no reason to run such a program on a microcontroller (NGINX on a server would be better) if it can’t be easily programmed. Making it programmable would enable users of the webserver to send and receive dynamic data. This could then be used for many projects, such as a HTTP controlled light, or an IoT sensor. Using modern C++ features such as std::function makes adding programmability very straight forward.

Lets add some basic domain definitions that allow us to define a very user-friendly API.

// Used to indicate HTTP satus codes
enum class StatusCode
{
    OK,
    NOT_FOUND,
    // Other status codes are left out for brevity.
};

// Used to represent the states of an Optional type
enum class Option
{
    Some,
    None,
};

// Used to represent optional values.
template <typename T> struct Optional
{
    Option result;
    T data;
};

// Used to represent HTTP arguments set in the URL.
// eg. http://test.com/page.html?left=right&value=1
struct Argument
{
    std::string left;
    std::string right;
};

// Used to represent a HTTP Reply to clients
struct HttpReply
{
    Optional<StatusCode> status;
    Optional<std::string> content;
};

// Used to represent the function signature of user-defined callbacks
typedef std::function<HttpReply(std::vector<Argument>)> HttpCallback;

Then add some functions to the HTTP server which allow the user to register and unregister callbacks

// Adds a callback for a specified HTTP Method and path.
void Server::Register(Method method, std::string path, HttpCallback callback)
{
  RegisteredUris.push_back(RegisteredUri{method, path, callback});
}

// Removed all callbacks for a specified HTTP Method and path.
void Server::Unregister(Method method, std::string path)
{
  while (true)
  {
    auto itr = std::find_if(RegisteredUris.begin(), RegisteredUris.end(),
        [&](RegisteredUri const& p) { return p.method == method && p.path == path; });

    if (itr == RegisteredUris.end()) { break; }
    RegisteredUris.erase(itr);
  }
}

// Gets all callbacks for a specified HTTP Method and path.
std::vector<HttpCallback> Server::FindCallbacks(Method method, std::string path)
{
  auto output = std::vector<HttpCallback>();
  while (true)
  {
    auto itr = std::find_if(RegisteredUris.begin(), RegisteredUris.end(),
        [&](RegisteredUri const& p) { return p.method == method && p.path == path; });

    if (itr == RegisteredUris.end()) { break; }
    output.push_back(itr->callback);
  }
  return output;
}

Then in the HTTP server, whenever a request is fully parsed, find and execute all callbacks.

auto callbacks = FindCallbacks(method, path);
for (auto callback : callbacks)
{
    auto output = callback(arguments);
    if (output.content.result == Option::Some)
    {
        // The latest reply set by any callback is sent to the user.
        reply = output;
    }
}

We now have defined a suprisingly simple and user-friendly API which allows all consumers of the HTTP server to add custom callbacks. An example of using the callback system can be seen below, where the result of a WiFi scan is send over HTTP.

server.Register(Method::GET, "/networks", [&](std::vector<Argument> arguments) {
  DynamicJsonDocument doc(1024);

  int networks = WiFi.scanNetworks();
  for (int i = 0; i < networks; ++i)
  {
    auto SSID = WiFi.SSID(i);
    if (!doc["networks"][SSID].isNull())
    {
      continue;
    }
    doc["networks"][SSID]["BSSID"] = WiFi.BSSIDstr(i);
    doc["networks"][SSID]["RSSI"] = WiFi.RSSI(i);
    doc["networks"][SSID]["Auth"] = WiFi.encryptionType(i);
  }

  std::stringstream stream;
  serializeJson(doc, stream);
  return HttpReply{Optional<StatusCode>{Option::Some, StatusCode::OK},
                   Optional<std::string>{Option::Some, stream.str()}};
});

Calling the endpoint now results in a JSON file containing all WiFi networks that the ESP32 can receive. (Duplicate SSID’s are left out)

// BSSID's left out for privacy reasons.
{
  "networks": {
    "Hackalot": { "BSSID": "XX:XX:XX:XX:XX:XX", "RSSI": -55, "Auth": 3 },
    "esp-now": { "BSSID": "XX:XX:XX:XX:XX:XX", "RSSI": -71, "Auth": 0 },
    "Ziggo1234567": { "BSSID": "XX:XX:XX:XX:XX:XX", "RSSI": -71, "Auth": 3 },
    "pineapple": { "BSSID": "XX:XX:XX:XX:XX:XX", "RSSI": -87, "Auth": 0 }
  }
}

The ESP32 is a very powerful microcontroller. Using a simple callback system makes creating a setup portal (as seen in many IoT devices) where the user can configure the device over WiFi straight-forward.

Thank you for reading this article.
If you spot any mistakes or if you would like to contact me, visit the contact page for more details.