On August 16, I'll demonstrate this project and present how I developed the CO2 sensor using the LwM2M standard. You can register here.
Being in an office environment often exposes us to high levels of CO2. Sometimes, when sitting in a crowded office space, we may not even notice how stuffy the room is, which can affect our productivity and mood. Finding a room with proper air quality for a meeting with a customer can also be challenging. A good approach to this issue is to measure air quality, and an even better option is to utilize Internet of Things (IoT) solutions to remotely monitor crucial air parameters.
The project is based on the implementation of the Anjay client for ESP32. Anjay is an open-source C library that implements the OMA Lightweight Machine to Machine (LwM2M) protocol which enables remote device management in the IoT and other Machine-to-Machine (M2M) applications. We will utilize it for transferring data from our ESP32 device to the server, where it will be displayed on dashboards. Our device, apart from measuring the level of CO2, will also measure temperature and humidity.
Let’s build!Our microcontroller will communicate with peripherals by using an I2C bus. Additionally, the CO2 sensor will indicate the completion of a measurement by changing the logic level on one of its pins. Data from SHTC3 is going to be read periodically.
PASCO2 sensor heats up air in its chamber and then measures the CO2 level based on the PAS (Photo Acoustic Spectroscopy) principle. For this purpose, it requires an external 12V power supply. Measurements and connection status will be shown on the OLED display.
After wiring, we will end up with something like this:
For readability, below is a wiring diagram:
For those who would like to order or create the PCB and 3D printed housing for this project, all required files can be found at the end of this article.
Our project, as mentioned above, is based on the Anjay ESP32 project. This project is an example implementation of the Anjay library for ESP32 devices. In order to integrate it with our sensors and display, in addition to the drivers, we also need to implement the corresponding LwM2M objects.
But what exactly are LwM2M and LwM2M objects?
LwM2M is an abbreviation for Lightweight Machine to Machine, which is a protocol developed for remote device management in the IoT. With its help, we are going to send our data to the server.
Each LwM2M Client presents a data model - standardized, symbolic representation of its configuration and state that is accessible for reading and modifying by LwM2M Servers.
This data model consists of objects that represent a collection of related data. In our project, one object can represent a CO2 sensor, while the other may represent a temperature sensor. Each object possesses a unique identity called OID (Object ID). However, it is conceivable that we may have an application with multiple sensors of the same type, such as temperature sensors scattered across different rooms. This is where instances of objects show up, enabling us to distinguish and track individual sensors. With this approach, we can have up to 65535 instances of an object of the same type.
Each object instance of a given object contained the same set of resources as defined by the object definition. What is more, resources can also be multi-instance.
In our case, an object with OID 3303 represents a temperature sensor. In addition to the obvious resource representing the current measurement called Sensor Value, it also has resources such as Max/Min Measured Value or Sensor Units. Also worth mentioning is that not all resources require implementation, in the matter of 3303 objects only the resource representing the current temperature is mandatory.
The data model structure is illustrated below:
Here you will find the definitions of the objects, but don't feel limited - there's nothing stopping you from implementing your own custom objects.
And finally, after going through all this theory, we can utilize resources and assign them to our sensor data!
We will use three different objects:
- 3303 - for temperature measurements
- 3304 - for humidity measurements
- 3428 - only 17th and 18th resources - for CO2 concentration measurements
If you want, you can skip this point and download the finished project from this repository.
Let’s start by creating a local repository with the Anjay ESP32 client:
git clone https://github.com/AVSystem/Anjay-esp32-client.git -b 22.12 --recursive
cd Anjay-esp32-client
After that we should be able to build our basic project:
. $HOME/esp/esp-idf/export.sh // assuming you have installed your ESP IDF under the default directory, also note that you should use the v4.4 version
idf.py build
To avoid obscuring this article, you can find all the required drivers for the sensors and display at this link. Copy them to the previously downloaded basic Anjay-esp32-client repository to the./main directory.
Needed files for individual peripherals:
- Temperature & humidity sensor - shtc3.c, shtc3.h
- Carbon dioxide sensor - pasco2.c, pasco2.h
- OLED display - oled.c, oled.h, oled_page.c, oled_page.h, ascii_font.h
- I2C drivers - i2c_wrapper.c, i2c_wrapper.h (both of these files are already in the basic repository, but have been slightly modified)
- Wi-Fi - connect.c (this file is also already in the basic repository)
Also, be sure to copy./main/CMakeLists.txt and./main/KConfig.
Now we will generate a stub for our objects by using lwm2m_object_registry.py and anjay_codegen.py scripts, which are bundled with the Anjay library:
./main/anjay/tools/lwm2m_object_registry.py --get-xml 3428 |./main/anjay/tools/anjay_codegen.py -r 17 18 -n 1 -i - -o./main/objects/air_quality.c
After executing this command, we obtained a file that contains a lot of TODOs, which we will replace with actual code, but first let's include the necessary header files and macros to the files we are going to edit:
./main/objects/air_quality.c (line 17)
#include <inttypes.h>
#include <pthread.h>
#if CONFIG_ANJAY_CLIENT_BOARD_PASCO2
# include "oled_page.h"
# include "pasco2.h"
#endif // CONFIG_ANJAY_CLIENT_BOARD_PASCO2
/**
* Air quality object ID
*/
#define OID_AIR_QUALITY 3428
./main/objects/sensors.c (line 31)
#include "shtc3.h"
./main/main.c (line 55)
#include "driver/gpio.h"
#include "driver/i2c.h"
#include "oled.h"
#include "oled_page.h"
#include "pasco2.h"
#include "shtc3.h"
Now let's return to the beginning of the./main/objects/air_quality.c file, where we find two structure definitions - air_quality_instance_t
and air_quality_object_t
. The first structure should contain variables that are individual for each instance. In our case, we need an array of measurements taken over the last hour to calculate the average CO2 over that period, a variable to store the average, a variable pointing to the array field where the new measurement will be stored, and another variable to indicate that the entire array has been filled.
typedef struct air_quality_instance_struct {
uint16_t carbon_dioxide_level[CO2_NUMBER_OF_MEASURMENTS_PER_HOUR];
uint32_t carbon_dioxide_level_avg;
uint16_t current_measurment_array_field;
bool measurment_array_filled;
} air_quality_instance_t;
The next structure contains the variables associated with the entire object. In this structure, we require mutex for ensuring thread safety and a statically allocated instance since we only have one CO2 sensor.
typedef struct air_quality_object_struct {
const anjay_dm_object_def_t *def;
pthread_mutex_t mutex;
air_quality_instance_t instances[1];
} air_quality_object_t;
Add synchronization to list_instances()
function:
static int list_instances(anjay_t *anjay,
const anjay_dm_object_def_t *const *obj_ptr,
anjay_dm_list_ctx_t *ctx) {
(void) anjay;
air_quality_object_t *obj = get_obj(obj_ptr);
pthread_mutex_lock(&obj->mutex);
for (anjay_iid_t iid = 0; iid < AVS_ARRAY_SIZE(obj->instances); iid++) {
anjay_dm_emit(ctx, iid);
}
pthread_mutex_unlock(&obj->mutex);
return 0;
}
The functions instance_reset()
and list_resources()
can be left unchanged.
The handler for reading resources should look like this:
static int resource_read(anjay_t *anjay,
const anjay_dm_object_def_t *const *obj_ptr,
anjay_iid_t iid,
anjay_rid_t rid,
anjay_riid_t riid,
anjay_output_ctx_t *ctx) {
(void) anjay;
air_quality_object_t *obj = get_obj(obj_ptr);
assert(obj);
pthread_mutex_lock(&obj->mutex);
int result = ANJAY_ERR_NOT_FOUND;
assert(iid < AVS_ARRAY_SIZE(obj->instances));
air_quality_instance_t *inst = &obj->instances[iid];
switch (rid) {
case RID_CO2:
assert(riid == ANJAY_ID_INVALID);
if (!inst->measurment_array_filled
&& inst->current_measurment_array_field == 0) {
result = ANJAY_ERR_METHOD_NOT_ALLOWED;
break;
}
size_t index = inst->current_measurment_array_field
? (inst->current_measurment_array_field - 1)
: CO2_NUMBER_OF_MEASURMENTS_PER_HOUR - 1;
result = anjay_ret_double(ctx,
(double) inst->carbon_dioxide_level[index]);
break;
case RID_CO2_1_HOUR_AVERAGE:
assert(riid == ANJAY_ID_INVALID);
result = anjay_ret_double(ctx,
(double) inst->carbon_dioxide_level_avg);
break;
default:
result = ANJAY_ERR_NOT_FOUND;
}
pthread_mutex_unlock(&obj->mutex);
return result;
}
We also need to add code to create a mutex during creation of an air quality object:
const anjay_dm_object_def_t **air_quality_object_create(void) {
pthread_mutexattr_t attr;
if (pthread_mutexattr_init(&attr)) {
return NULL;
}
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
air_quality_object_t *obj =
(air_quality_object_t *) avs_calloc(1,
sizeof(air_quality_object_t));
if (!obj) {
return NULL;
}
obj->def = &OBJ_DEF;
if (pthread_mutex_init(&obj->mutex, &attr)) {
pthread_mutexattr_destroy(&attr);
avs_free(obj);
return NULL;
}
pthread_mutexattr_destroy(&attr);
return &obj->def;
}
And destroy it when releasing an object:
void air_quality_object_release(const anjay_dm_object_def_t **def) {
if (def) {
air_quality_object_t *obj = get_obj(def);
pthread_mutex_destroy(&obj->mutex);
avs_free(obj);
}
}
We have to add an additional function for updating resources values:
void air_quality_update_measurment_val(
const anjay_t *anjay,
const anjay_dm_object_def_t *const *obj_ptr,
const uint16_t val) {
air_quality_object_t *obj = get_obj(obj_ptr);
assert(obj);
pthread_mutex_lock(&obj->mutex);
air_quality_instance_t *inst = &obj->instances[0];
inst->carbon_dioxide_level[inst->current_measurment_array_field++] = val;
if (!(inst->current_measurment_array_field %=
CO2_NUMBER_OF_MEASURMENTS_PER_HOUR)) {
inst->measurment_array_filled =
true; // the entire table is filled with measurements
}
inst->carbon_dioxide_level_avg = 0;
for (uint32_t meas = 0U;
meas < (inst->measurment_array_filled
? CO2_NUMBER_OF_MEASURMENTS_PER_HOUR
: inst->current_measurment_array_field);
meas++) {
inst->carbon_dioxide_level_avg += inst->carbon_dioxide_level[meas];
}
inst->carbon_dioxide_level_avg /=
(inst->measurment_array_filled
? CO2_NUMBER_OF_MEASURMENTS_PER_HOUR
: inst->current_measurment_array_field);
anjay_notify_changed((anjay_t *) anjay, OID_AIR_QUALITY, 0, RID_CO2);
anjay_notify_changed((anjay_t *) anjay, OID_AIR_QUALITY, 0,
RID_CO2_1_HOUR_AVERAGE);
pthread_mutex_unlock(&obj->mutex);
}
To use the functions associated with our new objects, let’s add their declarations to the./main/objects/objects.h header file:
const anjay_dm_object_def_t **air_quality_object_create(void);
void air_quality_object_release(const anjay_dm_object_def_t **def);
void air_quality_update_measurment_val(
const anjay_t *anjay,
const anjay_dm_object_def_t *const *obj_ptr,
const uint16_t val);
Now we will create our new object, let’s move to the./main/main.c file.
Add new variable (line 84):
static const anjay_dm_object_def_t **AIR_QUALITY_OBJ;
And use it in the anjay_init()
function (line 330):
if ((AIR_QUALITY_OBJ = air_quality_object_create())) {
anjay_register_object(anjay, AIR_QUALITY_OBJ);
}
We would also like to know if our board is connected to Wi-Fi and the LwM2M server, for this we will display the corresponding pictograms on the OLED display. The code that handles the Wi-Fi pictogram has been implemented in the connect.c file, which has already been copied, it remains for us to add support for a second pictogram. We should display it when we have a connection to the server. Copy the new function and modify the update_connection_status_job()
(line 250) :
#if CONFIG_ANJAY_CLIENT_BOARD_PASCO2
static void check_and_write_connection_status(anjay_t *anjay) {
if ((anjay_get_socket_entries(anjay) != NULL)
&& !anjay_all_connections_failed(anjay)
&& !anjay_ongoing_registration_exists(anjay)) {
oled_avs_icon(true);
} else {
oled_avs_icon(false);
}
}
#endif // CONFIG_ANJAY_CLIENT_BOARD_PASCO2
static void update_connection_status_job(avs_sched_t *sched,
const void *anjay_ptr) {
anjay_t *anjay = *(anjay_t *const *) anjay_ptr;
#if defined(CONFIG_ANJAY_CLIENT_LCD) \
|| defined(CONFIG_ANJAY_CLIENT_BOARD_PASCO2)
check_and_write_connection_status(anjay);
#endif // defined(CONFIG_ANJAY_CLIENT_LCD) ||
// defined(CONFIG_ANJAY_CLIENT_BOARD_PASCO2)
Then initialize the display and the shtc3 sensor in the app_main()
function (line 575):
#if CONFIG_ANJAY_CLIENT_OLED
oled_init();
oled_set_display_on();
#endif // CONFIG_ANJAY_CLIENT_OLED
#if CONFIG_ANJAY_CLIENT_BOARD_PASCO2
oled_page_init();
double temp, humi;
if (shtc3_wakeup() || shtc3_get_temp_and_humi_polling(&temp, &humi)
|| shtc3_sleep()) {
avs_log(tutorial, WARNING, "Reading temperature and humidity failed");
} else {
oled_update_temp(temp);
oled_update_humi(humi);
}
Next, let’s configure the ESP32 to capture notifications from the CO2 sensor indicating that the measurement is ready to be read. To achieve this, configure the selected pin (in our case pin 19) to generate an interrupt on the falling edge (line 589):
gpio_config_t io_conf = {
.pin_bit_mask = (1 << GPIO_NUM_19),
// interrupt on falling edge
.intr_type = GPIO_INTR_NEGEDGE,
.mode = GPIO_MODE_INPUT,
.pull_up_en = true,
};
gpio_config(&io_conf);
gpio_install_isr_service(0);
gpio_isr_handler_add(GPIO_NUM_19, gpio_isr_handler, NULL);
Now, let's create an semaphore and corresponding task to handle the reading of measurements (line 100):
#if CONFIG_ANJAY_CLIENT_BOARD_PASCO2
static xSemaphoreHandle gpio_semaphore = NULL;
#endif // CONFIG_ANJAY_CLIENT_BOARD_PASCO2
... (line 602)
vSemaphoreCreateBinary(gpio_semaphore);
xTaskCreate(&air_quality_task, "air_quality_task", 4092, NULL, 5, NULL);
#endif // CONFIG_ANJAY_CLIENT_BOARD_PASCO2
The interrupt handler will only give the semaphore… (line 375)
#if CONFIG_ANJAY_CLIENT_BOARD_PASCO2
static void IRAM_ATTR gpio_isr_handler(void *arg) {
xSemaphoreGiveFromISR(gpio_semaphore, pdFALSE);
}
... for our separated task, which will read the sensor data, update our object resources and display the data (line 378):
static void air_quality_task(void *pvParameters) {
uint16_t co2_val = 0;
while (pasco2_init()) {
avs_log(tutorial, ERROR, "PASCO2 init failed");
vTaskDelay(pdMS_TO_TICKS(2500));
}
avs_log(tutorial, INFO, "PASCO2 init done");
for (;;) {
xSemaphoreTake(gpio_semaphore, portMAX_DELAY);
if (!pasco2_is_measur_rdy()) {
pasco2_get_measur_val(&co2_val);
avs_log(tutorial, INFO, "CO2 value: %uppm", co2_val);
oled_page_update_co2(co2_val);
oled_update();
air_quality_update_measurment_val(anjay, AIR_QUALITY_OBJ, co2_val);
} else {
avs_log(tutorial, INFO, "Measurment not ready");
}
while (pasco2_reset_int_status_clear()) {
vTaskDelay(pdMS_TO_TICKS(100));
}
}
}
#endif // CONFIG_ANJAY_CLIENT_BOARD_PASCO2
The humidity and temperature objects are IPSO objects, so their implementation will look different. In the case of the temperature object, all we need to do is define the temperature_read_data()
and temperature_get_data()
functions, which have already been done in the shtc3.c file. The same applies to the humidity object (omitting the names of the functions, in this case, they will be humidity_read_data()
and humidity_get_data()
functions), but here we will have to add a few lines in the./main/objects/sensors.c file (line 77):
static basic_sensor_context_t BASIC_SENSORS_DEF[] = {
#ifdef CONFIG_ANJAY_CLIENT_TEMPERATURE_SENSOR_AVAILABLE
{
.name = "Temperature sensor",
.unit = "Cel",
.oid = 3303,
.read_data = temperature_read_data,
.get_data = temperature_get_data,
},
#endif // CONFIG_ANJAY_CLIENT_TEMPERATURE_SENSOR_AVAILABLE
#ifdef CONFIG_ANJAY_CLIENT_HUMIDITY_SENSOR_AVAILABLE
{
.name = "Humidity sensor",
.unit = "%",
.oid = 3304,
.read_data = humidity_read_data,
.get_data = humidity_get_data,
},
#endif // CONFIG_ANJAY_CLIENT_HUMIDITY_SENSOR_AVAILABLE
};
Choose your LwM2M server and add a new deviceNow we will create an account at Coiote IoT Device Management. A free account with the Developer plan on this server can manage up to 10 devices, which is enough to deploy devices in a medium-sized office or home.
To add a new device log in to Coiote IoT DM and from the left side menu, select Device inventory
and click Add device
. Then select the Add device manually
:
Select the Connect your LwM2M device via the Management server
tile:
In the Device credentials
, specify your Endpoint name, the identity of the PSK key, and the key itself.
Example credentials:
Click Add device
and go through the rest of the configuration tabs; there's no need to change anything in them.
Most settings can be set using NVS (Non-volatile Storage), a dedicated partition on our board. To do this edit the nvs_config.csv file. The additional parameters under the writable_wifi
namespace are used to provide a secondary Wi-Fi configuration (it is not obligatory). This allows for switching between Wi-Fi configurations while the device is running.
File filled with example credentials from the previous step:
key,type,encoding,value
config,namespace,,
wifi_ssid,data,string,[wifi_ssid]
wifi_pswd,data,string,[wifi_password]
wifi_inter_en,data,u8,1
endpoint_name,data,string,esp32-air-quality-room01
identity,data,string,esp32-air-quality-room01
psk,data,string,1234
uri,data,string,coaps://eu.iot.avsystem.cloud:5684
writable_wifi,namespace,,
wifi_ssid,data,string,[wifi_ssid]
wifi_pswd,data,string,[wifi_password]
wifi_inter_en,data,u8,0
In the terminal where you are going to use ESP-IDF, run:
. $HOME/esp/esp-idf/export.sh // assuming you have installed your esp idf under the default directory, also note that you should use the v4.4 version
After that create config partition by running:
python3 $IDF_PATH/components/nvs_flash/nvs_partition_generator/nvs_partition_gen.py generate nvs_config.csv nvs_config.bin 0x4000
If you are working on Windows, try replacing python3
with python
in the above command.
And flash this partition:
esptool.py -b 256000 --chip esp32 write_flash 0x9000 nvs_config.bin
Next, choose the appropriate board by using menuconfig. Run:
idf.py menuconfig
Navigate to Component config/anjay-esp32-client/Choose targeted development board
and select Custom board with CO2 sensor
:
Now it is time to build our project and flash the board:
idf.py build
idf.py flash
After that, you should see measurements on the display and on the server!
After going to the Data model
tab, you can see that one object is undefined; let's change that by adding its definition. Click Go to previous version
, then navigate to the Objects
tab, click Add new LwM2M object definition
, and paste the following link.
It would be nice to have graphs showing how individual air condition parameters changed throughout the day. To achieve this we will use the dashboards.
Go to the Data model
tab and click on the Widget pictogram.
We also need to observe our resources so that the device automatically updates its value. Click Observe resource
and set the attributes associated with the observation appropriately:
Repeat this step for the humidity and temperature Sensor Value resources.
To see our charts go to the Dashboard
tab from the main server website view.
Comments