Introduction
In this post we are going to learn how to use the inja library as an HTML template processor for the ESP32, using the Arduino core.
We are going to cover the basics on how to use inja and, in our last example, we are going to use it to process a HTML template and then serve it using a HTTP web server hosted on the ESP32. The server will be setup with the async HTTP web server lib.
It’s important to mention that inja is a generic template processor, which can be used outside the scope of HTML templates. Although our ultimate goal with this tutorial is to learn how to use it to gain flexibility when serving HTML, there are many other applications for it. As such, and to also make our examples easier to read, some of the code sections below use the inja engine to render simple text strings without HTML.
Inja depends on the nlohmann/json library, a JSON library for C++. In this previous tutorial we have covered in detail how to install the nlohmann/json as an Arduino library. If you haven’t done it yet, please install it using that guide as in this post we won’t be covering how to install nlohmann/json.
Since inja is a C++ library (naturally not developed with the Arduino syntax / abstraction in mind), we will need to resort to some code constructs that are not very common in typical Arduino sketches. Nonetheless all of the code we will write will be quite simple and easy to follow, so it should be easy to understand without specific knowledge of C++.
Furthermore, the exposure to C++ code may help you better understand Arduino code and make it easier to explore the implementation of libraries, which some times have not documented functionality that we can only discover by looking into their files. The Arduino core for the ESP32 is a perfect example that has many APIs that are not yet documented or exposed with Arduino wrappers but that we can use if we have a basic understanding of what is happening under the hood.
One such example of C++ is that, in the code below, we are going to see a lot the scope resolution operator “::”, which may not be very common in typical Arduino programs. Nonetheless, it is used simply for accessing functions and classes of different namespaces. An alternative to make the code more compact could be a using declaration.
Please note that this tutorial has some sections covering a bit of theory. If you are interested only on the code, please jump to the “Hello World” section, which is where the first practical example will be covered. Take also in consideration that I’m giving a very simplified view about the concepts on these theoretical sections.
The inja library version used was 3.3.0 and the json library version was 3.10.2. The Arduino core version used was 2.0.0 and the Arduino IDE version was 1.8.15, running on Windows 8.1.
The tests shown below were performed on a ESP32-E FireBeetle board from DFRobot.
HTML, template engines and the ESP32
The possibility of serving HTML from the ESP32 is very interesting as it opens up a new way of displaying information. Naturally, the typical use case won’t be navigating through the web via the ESP32 or acting as a server opened to the whole web and serving very complex websites to many users.
Instead, we can think of some scenarios where the possibility to serve HTML may be very useful:
- Serving a page to display metrics from sensors attached to the ESP32.
- Creating a web portal to configure access for the ESP32 to a WiFi network.
- Implementing a debugging / log display page for an ESP32 based product.
Naturally these are just some examples that I can think of. Taking in consideration that we can cover more complex scenarios such as serving a react app or serving the jQuery framework from the ESP32, there’s a lot of ways to get creative.
It’s also important to clarify what it means to serve HTML. For the ESP32, without any special type of pre-processing, it simply means answering to a request with that piece of information. As far as the ESP32 is concerned, serving a raw file of HTML, a bit of text, a CSV or even an image is pretty much the same thing.
This happens because the ESP32 doesn’t need to understand what is HTML to be able to store it (in a file, a SD card or even in memory) and later serve it as a response to a request. The same principle applies to other types of data, assuming they fit the limits of the storage and the web server is able to handle their size.
Naturally, in the specific case of HTML, it won’t be the responsibility of the ESP32 to render whichever elements are present in the served file. That’s actually the responsibility of the browser that does the request to the ESP32, which indeed needs to understand how to interpret such file and what each element means. The same goes for CSS or JavaScript, which we can also easily serve from a ESP32 web server.
So, our base assumption is that the ESP32 is able to serve raw HTML withou caring about what is inside. That should work for very simple and static UIs that don’t change depending on any data.
Nonetheless, the web would be very boring if all that could be served was static HTML. When we are surfing the web, we know that we access many sites in the same URL, which display different data depending on the user or the time of the day, just to name a few factors. Let’s take as an example a site that displays a list with the 10 most voted songs of the day: probably the layout of the page will always be the same, but the list has to change every day.
This means that, in fact, there is a way to actually set an HTML web page with dynamic data. There are many ways of doing it, but two commonly used ones are the following:
- Serving some JavaScript alongside the HTML, which later will do a request to an API, receive the data and manipulate the HTML DOM to add that data.
- Pre-processing the HTML file, in the server, with a template processor, to merge the dynamic data with the HTML, before serving it to the client.
The good news is that we can do both with the ESP32. For the first approach, we can easily serve a HTML page with some JavaScript embedded (or even serve the JavaScript as a separate file and include it in the HTML), and then also create some routes in our server to serve the dynamic data separately (if we want to segment things, we can even run two server instances: one for serving the HTML / CSS / JavaScript and another for the API).
This approach is very flexible as we can really do a lot with JavaScript and we would basically put most of the load on the browser that will be running it. Nowadays, frameworks such as React even allow us to implement things such as single page applications, where all the HTML, CSS and JS is retrieved by the browser only once and then the information to fill it is retrieved by calling APIs as the user interacts with the page, without needing to do a full page load again during that session.
Nonetheless, as one might expect by the title and introduction of this tutorial, the approach we are going to cover here is the second: using a template engine to process the file in the server (in our case, the ESP32) before serving it to the client. That way, we can also merge dynamic data we might have with a pre-defined HTML template.
So, when we use this approach, the ESP32 will actually need to look into the content before serving it to the client. This means that the HTML file is no longer just something agnostic that it needs to answer to the client, but rather something that it will need to partially understand before serving.
Note however that it doesn’t necessarily mean that the ESP32 will need to understand the HTML syntax. Many template engines have a scope broader than just HTML files, which is the case of inja, the one we will be using. So, when this particular template engine is processing the file, it doesn’t really care if it is HTML or some plain text, but rather if there is some of the syntax it uses. We will see that in some of our examples, where the engine will actually be handling a simple text string.
Different template engines have different capabilities. Some common operations they allow us to do are the following (inja supports all the ones in the list):
- Replacing variable placeholders by dynamic data.
- Doing for loops over arrays of data.
- Evaluating conditional sentences.
We will have the opportunity to see these 3 features in action in the code sections below.
It’s also important to mention that there are other template processing solutions for the ESP32. For example, the Async HTTP web server that we will be using to serve the HTML file (in the last code section) has it’s own template processing solution. In the “Suggested Readings” section you can check some previous posts about using it.
Nonetheless, at the time of writing, the capabilities of that built-in processor are really limited, allowing only to replace variables. This might be more than enough for many use cases, but I though it could interesting to cover a more flexible solution such as inja, to increase our toolset.
Just as a final note, please take in consideration that we are using the term “rendering” in two distinct cases. When we say that the browser “renders HTML”, we are referring to it transforming the HTML elements in the visual elements we see in websites. When we are talking about “template rendering”, we are referring to the operation of evaluating a template and instructions, and merging it with some data source.
About the library
Inja is a C++ template library for modern C++ inspired in Jinja, a Python template library [1]. It’s syntax is very simple and intuitive and it supports variables, loops, conditions, includes, callbacks, and comments, both nested and combined [1]. We will see some examples of these features in the code sections below.
The library is available as a single header include [1], making it very easy to integrate in any project. In particular, as we will see in the section below, installing it as an Arduino IDE library is very simple.
Although our main use case for inja is using it as a HTML template processor, the library is a more generic template processor that is not limited to HTML. As such, we can use it as template engine for other file types.
Like already mentioned, inja depends on the nlohmann/json library. Basically, after defining a template, we need to provide an object with the data that will be used to process that template (ex: the properties that will replace template variables). The library uses a json object from the nlohmann/json library as source for that data. As such, a typical flow consists on defining a string with a template and then a json object with the information to be used in the rendering.
You can check the library documentation here.
Installing the library
Since inja has a single header version, installing it as an Arduino library is quite simple. First, we need to locate the folder of or Arduino libraries. This may vary depending on your installation, but mine are on the following path:
C:\Users\MyUserName\Documents\Arduino\libraries
Once you locate it, simply create a new empty folder and call it inja. After that, save the single header inja.hpp file on the created folder. Note that you should keep the extension as .hpp and also use it later when including the file.
From this point onward, you should be able to include inja with the following line of code:
#include <inja.hpp>
However, if you followed my previous tutorial on how to install the Nlohmann/json library (which is a dependency of inja), there’s one final step to do.
If you followed any of the code from that tutorial, you will notice that we include the library simply as json.hpp. Nonetheless, if we look at the documentation of Nlohmann/json, you will notice that the include is usually done as nlohmann/json.hpp.
If we analyze the inja single header file, at the top we will notice that it tries to include the Nlohmann/json library exactly as nlohmann/json.hpp. Because of the way we installed it, it won’t be found and a compiler error will be shown.
Fixing this is as trivial as opening the file we have just copied to the library folder and replacing the following include:
#include <nlohmann/json.hpp>
Instead, we should use:
#include <json.hpp>
To better illustrate, the final file should look like figure 1.
After this small change you should be able to compile Arduino sketches with the inja lib include.
Hello World
This first section will cover a very simple “Hello World” example, to illustrate the basics of how the library works. In short, we will define a template string with two variables and a data structure (a json object) that will contain the data necessary to substitute those variables.
In this example our template string won’t be a piece of HTML. This is done on purpose to show that this library can be used outside the scope of HTML template processing.
We will start the code by the library includes. We will need json.hpp and inja.hpp.
#include <json.hpp>
#include <inja.hpp>
The setup function will start by opening a serial connection, so we can later print the result of our program.
Serial.begin(115200);
Then we will create a json object to hold the data that will be used to replace the placeholders in our string template. We will add a first name and a last name fields.
nlohmann::json data;
data["firstName"] = "John";
data["lastName"] = "Smith";
We will also define a string that will be our template. This will contain the placeholder variables that then will be replaced by the template engine. We will define a very simple “hello” message that receives the first name and the last name of a person, as variables.
The syntax to define a placeholder variable is described here. In short, it is as follows:
{{variableName}}
Note that the variable name refers to the names of the fields of the JSON that we will use to feed the data to our template. So, in our case, the two template variables should be firstName and lastName.
std::string templateString = "hello {{firstName}} {{lastName}}.";
Now that we have our data object and the template, we will render it with a call to the render function. This function receives as first input the template string and as second input the json object with the data.
As output, the render function returns the processed template. In our case, we expect the string with the actual values instead of the template variables.
std::string result = inja::render(templateString, data);
Finally, we will print the result to the serial port. Since we obtain a std::string as output of the render function and the println Serial method doesn’t support these strings, we need to call the c_str method before printing the result.
Serial.println(result.c_str());
The whole code can be seen below.
#include <json.hpp>
#include <inja.hpp>
void setup() {
Serial.begin(115200);
nlohmann::json data;
data["firstName"] = "John";
data["lastName"] = "Smith";
std::string templateString = "hello {{firstName}} {{lastName}}.";
std::string result = inja::render(templateString, data);
Serial.println(result.c_str());
}
void loop() {
}
To test the code, simply compile it and upload it to your ESP32. You should see a result similar to figure 2, which shows the template after being processed, with the replaced variables.
Reusing templates
The ideia about templates is that we define them once and then we reuse them with our dynamic data. So, in this section, we will see an example where we are going to reuse the same string template and render it against two different objects.
The code will be very similar to the previous, except that this time we will instantiate two different json objects with the same properties but different data. As such, the complete code is shown below.
#include <json.hpp>
#include <inja.hpp>
void setup() {
Serial.begin(115200);
nlohmann::json person1;
person1["firstName"] = "John";
person1["lastName"] = "Smith";
nlohmann::json person2;
person2["firstName"] = "Ted";
person2["lastName"] = "Willis";
std::string templateString = "hello {{firstName}} {{lastName}}.";
std::string result1 = inja::render(templateString, person1);
Serial.println(result1.c_str());
std::string result2 = inja::render(templateString, person2);
Serial.println(result2.c_str());
}
void loop() {
}
The output of the previous code is shown in figure 3. As expected, the template string can be reused for different json objects, and we obtain the correct result in both cases.
Different data types
Our previous examples covered only the usage of strings to replace template variables. Naturally, we may want to work with other data types such as integers or Booleans. In this section we are going to illustrate that the template variable replacement is supported also for the following data types:
- int
- float
- bool
Additionally to these types, we are also going to set a property that will contain an array of strings and another that is a nested JSON object, which has some properties itself.
As such, in the setup, we are going to define a json object with properties of these types.
nlohmann::json data;
data["string"] = "John";
data["int"] = 10;
data["float"] = 11.23;
data["bool"] = true;
data["array"] = {"a", "b", "c"};
data["object"]["int"] = 10;
data["object"]["string"] = "test";
After that we are going to define our template string. We will be using a raw string literal for this. Regarding the placeholders, we should use exactly the same syntax as before, regardless of the new data types we are testing.
std::string templateString = R"(
string: {{string}}
int: {{int}}
float: {{float}}
bool: {{bool}}
array: {{array}}
object: {{object}}
)";
Finally, we are going to render the template and print the result to the serial port, like we have done before.
std::string result = inja::render(templateString, data);
Serial.println(result.c_str());
The whole code is shown below.
#include <json.hpp>
#include <inja.hpp>
void setup() {
Serial.begin(115200);
nlohmann::json data;
data["string"] = "John";
data["int"] = 10;
data["float"] = 11.23;
data["bool"] = true;
data["array"] = {"a", "b", "c"};
data["object"]["int"] = 10;
data["object"]["string"] = "test";
std::string templateString = R"(
string: {{string}}
int: {{int}}
float: {{float}}
bool: {{bool}}
array: {{array}}
object: {{object}}
)";
std::string result = inja::render(templateString, data);
Serial.println(result.c_str());
}
void loop() {
}
Upon testing the code, you should get a result similar to figure 4. As can be seen, all the primitive data types were replaced correctly, as expected. Even for more complex data types, such as the array and the object, the template processor is able to substitute. The default format for both arrays and objects is the one we would see in a serialized JSON.
Nested objects and arrays in JSON data
As we have seen, we are using JSON objects as the source of our data, to replace the variables in the templates. Naturally, a JSON object supports properties that are objects themselves, meaning that we can have nested properties. JSON also supports arrays.
The inja library supports both situations, meaning we can replace template variables by nested properties of our json object and also by elements of arrays. Note that this is a different use case from the previous section, where we simply printed these two data types as a whole. Here we basically want to drill down on them.
Like before, we start by the library includes.
#include <json.hpp>
#include <inja.hpp>
Moving on to the Arduino setup and after opening a serial connection, we will create a json object and assign some fields to it. Our object will represent, like in the previous section, a person. Nonetheless, this time, it will be slightly more complex, as it will have the following structure:
- A first name and last name properties at the root of the object.
- An inner object representing an address, which contains a street and an address properties.
- An array of languages that the person speaks, at the root of the object.
nlohmann::json person;
person["firstName"] = "John";
person["lastName"] = "Smith";
person["address"]["street"] = "Boat Street";
person["address"]["code"] = "2770-929";
person["languages"] = {"pt-pt", "en-gb"};
Now we will define a raw string literal that will be our template. This time we are going to use a piece of HTML code with template variables inside.
For the first name and the last name, which are properties at the root of the json object, we already know that the syntax is the following:
{{variableName}}
In case the property is nested, then the syntax is exactly the same and we simply add a “.” per each part of the “path” to the property. To better illustrate this, we can see below how to define the template variables for both properties of the address:
{{address.street}}
{{address.code}}
Similarly, we can access elements of arrays by using a “.” and the zero-based index of the element we want, after the name of the property. We can see the example below to access both the elements of the languages array:
{{languages.0}}
{{languages.1}}
Taking the previous syntax in consideration, the whole string is shown below. It corresponds to a very basic HTML where each property corresponds to a different paragraph and the languages are in an unordered list.
std::string templateString = R"(
<p>User: {{firstName}} {{lastName}}</p>
<p>Steet: {{address.street}}</p>
<p>Code: {{address.code}}</p>
<p>Languages:</p>
<ul>
<li>{{languages.0}}</li>
<li>{{languages.1}}</li>
</ul>
)";
Finally we just need to render the template and print the result to the serial port, like we have done before.
std::string result = inja::render(templateString, person);
Serial.println(result.c_str());
The whole code is shown below.
#include <json.hpp>
#include <inja.hpp>
void setup() {
Serial.begin(115200);
nlohmann::json person;
person["firstName"] = "John";
person["lastName"] = "Smith";
person["address"]["street"] = "Boat Street";
person["address"]["code"] = "2770-929";
person["languages"] = {"pt-pt", "en-gb"};
std::string templateString = R"(
<p>User: {{firstName}} {{lastName}}</p>
<p>Steet: {{address.street}}</p>
<p>Code: {{address.code}}</p>
<p>Languages:</p>
<ul>
<li>{{languages.0}}</li>
<li>{{languages.1}}</li>
</ul>
)";
std::string result = inja::render(templateString, person);
Serial.println(result.c_str());
}
void loop() {
}
The result is shown below in figure 5. As expected, all the template variables were replaced.
Loops
If we look at the code from the previous section, accessing the elements of an array index by index is not very scalable. If the array is very big, we will end up having to write a lot of code, to access each individual position. If the array doesn’t have a fixed size, we won’t be able to index its positions this way.
As such, the inja library supports loops in its template syntax, as we can see here. There are two ways of enclosing loops (and other statements that are supported) such as we can see here. Basically, one of the approaches allows for single line statements and the other for multi line statements.
In the case of the loops I believe the multi line syntax allows for an easier to read template, so we are going to use it in this section. In short, we need to use ## characters to start and to end the sentence. Note that the ## characters need to start the line in the template without any indentation [2].
## start of the sentence
middle can have indentation
## end of the sentence
In the particular case of loops, the sentence is as follows:
## for element in listOfElements
Example usage: {{ element }}
## endfor
Note that element is an arbitrary name I’ve used for the variable that will hold the current element. You can name it however you prefer. In the case of listOfElements, it’s also an arbitrary name I’ve chosen that needs to match the same name of the property in the json object that will feed the template.
To print the current element, we can use the same variable syntax we have already covered.
There are also a couple of special variables that are made available by the template engine which we can use in our statement. For example, if we want to print the current iteration of the loop, we can use either {{loop.index}} or {{loop.index1}}. The first one is zero based and the second one starts at 1.
An example of the usage of one of these variables is shown below.
## for element in listOfElements
Element nr {{loop.index1}}: {{ element }}
## endfor
Now that we have covered the syntax, we will take a look at the code. We will focus on the Arduino setup, as the rest of the code will be similar to what we already covered.
So, we will start by creating a data object that has a single property: an array of strings.
nlohmann::json data;
data["languages"] = {"pt-pt", "en-us", "en-gb"};
Then we are going to define a simple HTML list where we will levare the looping capabilities of the template processor to generate a list item per element of the array. We are also print the current index value, starting at 1.
std::string templateString = R"(
<ul>
## for language in languages
<li>{{loop.index1}}: language</li>
## endfor
</ul>
)";
Notice that this is much more compact that having to access element by element. Additionally, it is independent of the size of the array, meaning that we could provide objects with arrays of different sizes and the template engine would work as expected.
Also, don’t forget the detail of having the ## characters without indentation. Since we are using a raw string literal, we really need to write the characters at the beginning of the newline in the IDE, which is a bit ugly in terms of overall code indentation. Nonetheless, it should work as expected.
For illustration purposes, we are going to define a second template, this time printing the current iteration starting at zero.
std::string templateStringZeroBased = R"(
<ul>
## for language in languages
<li>{{loop.index}}: language</li>
## endfor
</ul>
)";
Finally we will run the engine for both template strings and print the results to the serial port.
std::string result1 = inja::render(templateString, data);
Serial.println(result1.c_str());
std::string result2 = inja::render(templateStringZeroBased, data);
Serial.println(result2.c_str());
The complete code is available on the snippet below.
#include <json.hpp>
#include <inja.hpp>
void setup() {
Serial.begin(115200);
nlohmann::json data;
data["languages"] = {"pt-pt", "en-us", "en-gb"};
std::string templateString = R"(
<ul>
## for language in languages
<li>{{loop.index1}}: language</li>
## endfor
</ul>
)";
std::string templateStringZeroBased = R"(
<ul>
## for language in languages
<li>{{loop.index}}: language</li>
## endfor
</ul>
)";
std::string result1 = inja::render(templateString, data);
Serial.println(result1.c_str());
Serial.println();
std::string result2 = inja::render(templateStringZeroBased, data);
Serial.println(result2.c_str());
}
void loop() {
}
Figure 6 below shows the result, which matches exactly what we expected: the template engine iterates over the array and prints a line per element. The current iteration number is also printed for both cases.
Conditionals
Other important capability of the inja engine is that it is able to handle conditional sentences. In this section we are going to cover a very simple IF ELSE, but the engine supports much more, such as ELSE IF blocks, negations and logical operations. You can check this section of the GitHub page to get examples of these.
For exemplification purposes, here we are going to use the single line syntax:
{% sentence %}
Applied to conditionals, we should write something similar to the example below:
{% if condition %} something {% else %} something else {% endif %}
Our code will cover a very simple use case where we are going to print a sentence if a number is lesser or equal than 5, and another sentence if it is greater. To test both outcomes, we will define two json objects.
nlohmann::json data1;
data1["int"] = 1;
nlohmann::json data2;
data2["int"] = 10;
Our string template will then apply the IF condition we described before.
std::string templateString = R"({% if int > 5 %}greater than 5{% else %}lesser or equal to 5{% endif %})";
Finally, we will render both data objects against the template and print the result.
std::string result1 = inja::render(templateString, data1);
Serial.println(result1.c_str());
std::string result2 = inja::render(templateString, data2);
Serial.println(result2.c_str());
The complete code is shown below.
#include <json.hpp>
#include <inja.hpp>
void setup() {
Serial.begin(115200);
nlohmann::json data1;
data1["int"] = 1;
nlohmann::json data2;
data2["int"] = 10;
std::string templateString = R"({% if int > 5 %}greater than 5{% else %}lesser or equal to 5{% endif %})";
std::string result1 = inja::render(templateString, data1);
Serial.println(result1.c_str());
Serial.println();
std::string result2 = inja::render(templateString, data2);
Serial.println(result2.c_str());
}
void loop() {
}
Upon running the code, you should get a result similar to figure 7. As can be seen, for the first render, the number was lesser than 5, meaning that we got the corresponding sentence. For the second render, since the number was greater than 5, we got the else sentence.
Combining loops and conditionals
We have already covered how to iterate through arrays and how to apply conditionals. In this section we are going to take it one step further and learn how to combine both, to have even greater flexibility.
In this use case, we are going to be defining a list of integers in our json data object. Then, in our template, we are going to iterate through each element and compare it against the value 5. If it is lesser or equal we print one sentence, otherwise we print a different one.
Note that here we are going to write a multi line sentence for the loop but inside we are going to nest a single line conditional.
Focusing on the important parts of the code, we will start by defining our json object.
nlohmann::json data;
data["integers"] = {1, 10, 3, 12, 34, 100, 2};
Note that we called integers to the JSON property that holds the array, meaning that we need to use that same name in our loop.
Then we are going to write the template string. Like before, we are going to start and end the multi line statement with the ## characters, which should have no indentation.
std::string templateString = R"(
<ul>
## for int in integers
<li>{{int}} - {% if int > 5 %}greater than 5{% else %}lesser or equal to 5{% endif %})</li>
## endfor
</ul>
)";
Taking a closer look at our template, we can quickly check that we can use the current element of the loop not only as a variable but also in conditions.
The complete code is shown below.
#include <json.hpp>
#include <inja.hpp>
void setup() {
Serial.begin(115200);
nlohmann::json data;
data["integers"] = {1, 10, 3, 12, 34, 100, 2};
std::string templateString = R"(
<ul>
## for int in integers
<li>{{int}} - {% if int > 5 %}greater than 5{% else %}lesser or equal to 5{% endif %})</li>
## endfor
</ul>
)";
std::string result = inja::render(templateString, data);
Serial.println(result.c_str());
}
void loop() {
}
After compiling and uploading the code to your ESP32, you should have a result similar to figure 8 on the Arduino IDE serial monitor. As can be seen, the engine iterated through all elements of our array and applied the nested conditionals correctly.
Rendering and serving an HTML page
In the previous sections we have been analyzing some of the capabilities of the rendering engine without integrating it anywhere. In this final section, we are going to create a HTTP web server to run on the ESP32 and to serve some HTML that will be dynamically built using the inja rendering engine.
For this we are going to use the async HTTP web server library. If you are not familiar with it, you can follow this tutorial for installation instructions and for the basic concepts about it.
Our HTML will be a simple list of sensor readings, which will be dynamically filed by a json object that contains some hardcoded values. We are keeping things simple for illustration purposes, giving the basic tools to be able to build more complex applications. Naturally, a real application scenario might have a much more complex HTML template and the measurements wouldn’t be hardcoded in the json object, but the principles should be the same we are illustrating.
Moving on to the actual code, we will start with the library includes:
- WiFi.h: Allows to connect the ESP32 to a WiFi network.
- ESPAsyncWebServer.h: Allows to setup an async HTTP web server on the ESP32.
- json.hpp: Exposes the json class that holds the data used by the template engine.
- inja.hpp: Exposes the template engine functionality.
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <json.hpp>
#include <inja.hpp>
Since we will need to connect the ESP32 to a WiFi network, we need its credentials: network name (SSID) and password. We will store those in two global variables. Please make sure to replace the placeholders that I’m using below by the actual credentials of your network.
const char* ssid = "YourNetworkName";
const char* password = "yourNetworkPassword";
Then we will create an object of class AsyncWebServer, which is the basis to setup the HTTP server. As input of the constructor we will pass the value 80, which corresponds to the port where our server will be listening for incoming requests. We used the value 80 simply because it is the default HTTP port.
AsyncWebServer server(80);
Then we are going to define a string containing our template. It will correspond to a very simple HTML snippet that renders a list of sensor readings. Since it is a list, we are going to use the looping capabilities of the template engine.
const std::string templateString = R"(
<p>Latest readings: </p>
<ul>
## for reading in sensorReadings
<li>{{reading}}</li>
## endfor
</ul>
)";
Moving on to the Arduino setup, we will start by opening a serial connection and then connecting the ESP32 to the WiFi network, using the previously defined credentials. After establishing the connection, we will print the local IP address assigned to the ESP32, since we will need it to reach the HTTP server.
Serial.begin(115200);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.println("Connecting to WiFi..");
}
Serial.println(WiFi.localIP());
After that we are going to configure a server route on “/readings“. It will answer to HTTP GET requests (we are going to obtain a HTML page) and we will use the C++ lambda syntax to define the callback function to be executed whenever a request is received on that endpoint.
server.on("/readings", HTTP_GET, [](AsyncWebServerRequest *request){
// Route handling function implementation
});
In the route handling function we will create a json object with a list containing some fixed values, for testing purposes. Naturally, in a more realistic scenario, we would be getting these measurements from somewhere else.
When adding the list to our object, we need to consider that we called sensorReadings to the array to be iterated, when we defined our string template. As such, we need to use the same exact key below.
nlohmann::json data;
data["sensorReadings"] = {10, 10, 3, 12, 34, 30, 22};
Now that we have our json to feed the template, we will simply render it and store the result in a variable.
std::string result = inja::render(templateString, data);
Finally we will send back the response to the client with a call to the send method on the AsyncWebServerRequest object (recall from the introductory tutorial about the HTTP server lib that we receive a pointer to this object as input of the callback function that handles the route).
We are going to return a status 200 (HTTP OK) and set the content-type header to “text/html”, so the browser knows that it is supposed to interpret and render the result as HTML.
To finalize, as third parameter of the send method we will pass the rendered template. However, pretty much like we have been doing for the println method, we need to call the c_str before because none of the signatures of the send method support a std::string.
request->send(200, "text/html", result.c_str());
The full route declaration can be seen below.
server.on("/readings", HTTP_GET, [](AsyncWebServerRequest *request){
nlohmann::json data;
data["sensorReadings"] = {10, 10, 3, 12, 34, 30, 22};
std::string result = inja::render(templateString, data);
request->send(200, "text/html", result.c_str());
});
To finish the Arduino setup, the only thing left to do is calling the begin method on our AsyncWebServer object. Only after this it will start listening to incoming requests.
server.begin();
The whole setup function is summarized below.
void setup(){
Serial.begin(115200);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.println("Connecting to WiFi..");
}
Serial.println(WiFi.localIP());
server.on("/readings", HTTP_GET, [](AsyncWebServerRequest *request){
nlohmann::json data;
data["sensorReadings"] = {10, 10, 3, 12, 34, 30, 22};
std::string result = inja::render(templateString, data);
request->send(200, "text/html", result.c_str());
});
server.begin();
}
Since we are using an async HTTP web server solution, we don’t need to periodically poll any object for it to process incoming requests. Consequently, for our use case, we can simply leave the Arduino main loop empty.
void loop(){}
The complete code is shown below.
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <json.hpp>
#include <inja.hpp>
const char* ssid = "YourNetworkName";
const char* password = "yourNetworkPassword";
AsyncWebServer server(80);
const std::string templateString = R"(
<p>Latest readings: </p>
<ul>
## for reading in sensorReadings
<li>{{reading}}</li>
## endfor
</ul>
)";
void setup(){
Serial.begin(115200);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.println("Connecting to WiFi..");
}
Serial.println(WiFi.localIP());
server.on("/readings", HTTP_GET, [](AsyncWebServerRequest *request){
nlohmann::json data;
data["sensorReadings"] = {10, 10, 3, 12, 34, 30, 22};
std::string result = inja::render(templateString, data);
request->send(200, "text/html", result.c_str());
});
server.begin();
}
void loop(){}
To test the code, first make sure to replace the placeholders for the WiFi network credentails by the actual values for your network. When done, compile and upload the code using your Arduino IDE.
When the code upload procedure finishes, open the Arduino IDE serial monitor. There, the IP address assigned to your ESP32 on your local network should be printed, like we defined in the setup function. Copy that IP address.
Then, on a web browser of your choice, paste the following in the address bar, changing “#yourESPIp# by the value you just copied from the serial monitor:
http://#yourESPIp#/readings
Upon navigating to the URL, you should obtain a result like figure 9. As can be seen, the HTML was displayed correctly by the browser and it displays the readings we have set in our json object.
Resources
Suggested ESP32 Readings
- Getting started with the async HTTP server
- Async HTTP server: built-in template engine
- Async HTTP server template engine: Rendering multiple placeholders
- Async HTTP server template engine: Processing HTML from file system
- Using the nlohmann/json library
References
[1] https://github.com/pantor/inja
[2] https://github.com/pantor/inja#statements