After a recent home renovation, I often worried about the potential health impact of formaldehyde. Although many detection devices are on the market, I found them too single-purpose.
So, I thought—why not make one myself? Following the DIY spirit and practicality-first principle, and since I already had boards and modules, it was actually quite simple.
This device can be paired with a desktop air purifier. Later, I may improve this function further.
External Interface DesignTwo pins have been reserved. The external interface has four pins: originally, I intended one for VCC, one for GND, and the other two for device control—one for fan speed and one for on/off. The idea was: when air quality is poor, the fan turns on. I planned to use a 12cm PC fan, combined with PM2.5 filter paper, to make an air purifier.
However, I realized I hadn’t considered the switch. Without one, the device would stay on until the battery died. Since I had already printed the case, I repurposed two pins as a switch by breaking the positive line of the battery. Once the pin header is inserted, the positive line is connected, acting as a switch.
Dev Board: Ai-Thinker Ai-M61-32S Kit
Sensor: 21VOC 5-in-1 Air Quality Module (TVOC, CH₂O, CO₂, Temp, Humidity)
21VOC (TVOC, Formaldehyde, CO2, Temp & Humidity) Module Manual - V01.01.pdf
21VOC to Ai-M61-32S
- GND → GND
- 3V3 → 3V3
- RX → IO25
- TX → IO26
Display: 1.3” TFT (240x240)
Button to Ai-M61-32S
- GND → GND
- BUTTON → IO18
Vent HolesSmall perforated aluminum mesh (diamond or hexagonal holes).
External Interface (4-Pin)Mainly used to control external devices or serve as a switch.
- Pin1 → Bin+
- Pin2 → Bout+
- Pin3 → IO reserved
- Pin4 → IO reserved
The detection device UI doesn’t need to be fancy, so I built a simple layout.
Create a new project
Choose LVGL v8.3.10
Select device template
Select application template
In project config: set panel type to Custom and name it. My screen is 240x240, so I set that resolution.
Code
voc.h
#ifndef VOC_H
#define VOC_H
typedef enum {
SINGLE_CLICK,
DOUBLE_CLICK,
LONG_CLICK,
NONE_CLICK,
} click_t;
void voc_init(void);
float convert_temperature(float temperature);
void voc21Task (void *pvParameters);
void send_sensor_data(int voc, int ch2o, int eco2, int temperature, int humidity);
#endif
voc.c
#include "bflb_mtimer.h"
#include "board.h"
#include "bflb_uart.h"
#include "bflb_gpio.h"
#include "FreeRTOS.h"
#include "task.h"
#include "cJSON.h"
#include "math.h"
#include <FreeRTOS.h>
#define DBG_TAG "MAIN"
#include "log.h"
#include <task.h>
#include <queue.h>
#include "custom.h"
#define BUFFER_SIZE 1024*2
// UART serial port reading mov21
struct bflb_device_s *voc_uart;
// Total number of cached data, default mov21 data length is 12 bytes starting with 0x2C
int BUFFER_LEN = 12;
// Current array index
int voc_index = 0;
// Data reading status flag
int flag = 0;
// Data buffer array
uint8_t UART_RECEIVE_BUFFER[12];
custom_event_t custom_event = CUSTOM_EVENT_GET_PM25_DATA;
extern QueueHandle_t queue;
float convert_temperature(uint16_t raw) {
// Check if the highest bit is 1 (negative case)
if (raw & 0x8000) {
return -(0xFFFF - raw) * 0.1f;
}
return raw * 0.1f;
}
static void uart_isr(int irq, void* arg)
{
uint32_t intstatus = bflb_uart_get_intstatus(voc_uart);
uint32_t rx_data_len = 0;
char* queue_buff = pvPortMalloc(64);
if (intstatus & UART_INTSTS_RX_FIFO) {
LOG_I("rx fifo\r\n");
while (bflb_uart_rxavailable(voc_uart)) {
int ch = bflb_uart_getchar(voc_uart);
if(voc_index < BUFFER_LEN){
// Prevent reading after completion and also prevent index overflow
if(ch!=-1 && voc_index < BUFFER_LEN){
if(flag == 1){
// Store data into buffer
UART_RECEIVE_BUFFER[voc_index++] = ch;
}else{
// Once the start byte 0x2C is detected, set flag=1 and prepare to read data
if(ch == 0x2C){
flag = 1;
memset(UART_RECEIVE_BUFFER,0 , sizeof(UART_RECEIVE_BUFFER));
UART_RECEIVE_BUFFER[0] = ch;
voc_index = 1;
}else{
flag = 0;
}
}
}
}else{
LOG_I("0x%02x\r\n",ch);
}
}
}
if (intstatus & UART_INTSTS_RTO) {
LOG_I("rto");
bflb_uart_int_clear(voc_uart, UART_INTCLR_RTO);
LOG_I("uart int clear");
}
if (intstatus & UART_INTSTS_TX_FIFO) {
LOG_I("tx fifo\r\n");
for (uint8_t i = 0; i < 27; i++) {
bflb_uart_putchar(voc_uart, UART_RECEIVE_BUFFER[i]);
}
bflb_uart_txint_mask(voc_uart, true);
}
vPortFree(queue_buff);
}
void init_voc(void){
// Initialize UART
voc_uart = bflb_device_get_by_name("uart1");
struct bflb_device_s* gpio;
// UART configuration parameters
struct bflb_uart_config_s conf = {
.baudrate = 9600,
.data_bits = UART_DATA_BITS_8,
.stop_bits = UART_STOP_BITS_1,
.parity = UART_PARITY_NONE,
.flow_ctrl = UART_FLOWCTRL_NONE,
.rx_fifo_threshold = 7,
.tx_fifo_threshold = 7
};
gpio = bflb_device_get_by_name("gpio");
bflb_gpio_uart_init(gpio, GPIO_PIN_25, GPIO_UART_FUNC_UART1_TX);
bflb_gpio_uart_init(gpio, GPIO_PIN_26, GPIO_UART_FUNC_UART1_RX);
bflb_uart_init(voc_uart, &conf);
bflb_uart_txint_mask(voc_uart, false);
bflb_uart_rxint_mask(voc_uart, false);
bflb_irq_attach(voc_uart->irq_num, uart_isr, NULL);
bflb_irq_enable(voc_uart->irq_num);
}
void send_sensor_data(int voc, int ch2o, int eco2, int temperature, int humidity) {
char json_str[BUFFER_SIZE]; // Ensure buffer is large enough
memset(json_str, 0, sizeof(json_str));
printf(custom_event == CUSTOM_EVENT_GET_PM25_DATA ? "PM2.5" : "CH2O");
if(custom_event == CUSTOM_EVENT_GET_PM25_DATA){
snprintf(json_str, sizeof(json_str),
"{\"pm25\":{"
"\"pm25\":%d,"
"\"ch2o\":%d,"
"\"eco2\":%d,"
"\"temperature\":%.2f,"
"\"humidity\":%.2f"
"}}",
voc, ch2o, eco2, convert_temperature(temperature), humidity * 0.1f
);
}else if(custom_event == CUSTOM_EVENT_GET_CH2O_DATA){
snprintf(json_str, sizeof(json_str),
"{\"ch2o\":{"
"\"pm25\":%d,"
"\"ch2o\":%d,"
"\"eco2\":%d,"
"\"temperature\":%.2f,"
"\"humidity\":%.2f"
"}}",
voc, ch2o, eco2, convert_temperature(temperature), humidity * 0.1f
);
}
// Send queue data (ensure queue is initialized)
if (queue != NULL) {
xQueueSend(queue, json_str, portMAX_DELAY);
printf("[DEBUG] Sending to queue: %s\n", json_str);
}
}
void voc21Task(void* pvParameters){
while (1)
{
// If buffer length is reached, process the received data
if(voc_index == BUFFER_LEN){
// Print the received raw data
for (size_t i = 0; i < sizeof(UART_RECEIVE_BUFFER); i++) {
printf("0x%02x ", UART_RECEIVE_BUFFER[i]);
}
printf("\r\n");
uint32_t voc = UART_RECEIVE_BUFFER[1] <<8 | UART_RECEIVE_BUFFER[2];
printf("VOC Air Quality: %d ug/m3", voc);
printf(" ,");
uint32_t ch2o = UART_RECEIVE_BUFFER[3] <<8 | UART_RECEIVE_BUFFER[4];
printf("Formaldehyde: %d ug/m3", ch2o);
printf(" ,");
uint32_t eco2 = UART_RECEIVE_BUFFER[5] <<8 | UART_RECEIVE_BUFFER[6];
printf("eCO2 (PPM): %d ppm", eco2);
printf(" ,");
uint16_t temperature = UART_RECEIVE_BUFFER[7] <<8 | UART_RECEIVE_BUFFER[8];
printf("Temperature: %.1f °C", convert_temperature(temperature));
printf(" ,");
uint32_t humidity = UART_RECEIVE_BUFFER[9] <<8 | UART_RECEIVE_BUFFER[10];
printf("Humidity: %.1f %% RH", humidity*0.1f);
printf("\r\n");
send_sensor_data(voc, ch2o, eco2, temperature, humidity);
memset(UART_RECEIVE_BUFFER, 0, BUFFER_LEN);
// Reset status after processing
flag = 0;
voc_index = 0;
}
vTaskDelay(500 / portTICK_PERIOD_MS);
}
}
FreeCAD Case DesignThis is the mold for the vent hole aluminum mesh. When cutting manually, place the flat side on the aluminum sheet and cut in circles. Then press the mesh into the curved side to form an arched cover. Looks neat!
Assembly & Debugging
Screen wiring
Buttons & assembly
Final effect with charging
Comments