External Interface Design

Two 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.

 

Hardware

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

Ai-M61-32S

GND

GND

3V3

3V3

RX

IO25

TX

IO26

 

Wiring Schemes

21VOC to Ai-M61-32S

Display: 1.3” TFT (240x240)

Button to Ai-M61-32S

Vent Holes: Small perforated aluminum mesh (diamond or hexagonal holes).

 

External Interface (4-Pin) Mainly used to control external devices or serve as a switch.

 

UI Design (LVGL)

The detection device UI doesn’t need to be fancy, so I built a simple layout.

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 Design

This 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!