第四节:读取温湿度与气体数据

好,咱们进入第四节课。这节课要解决一个核心问题——怎么让ESP32准确读取环境数据。

你想想看,一个空气净化器,如果连传感器数据都读不准,那后面的联动控制全是瞎扯。我见过不少项目,代码写得花里胡哨,结果一上电,温度跳了5度,气体浓度忽高忽低——这种数据你敢用吗?

这节课,咱们就踏踏实实把DHT22和MQ-135这两个传感器搞定。我会把我在实际项目中踩过的坑、总结的经验,全部分享给你。

4.1 DHT22单总线协议驱动编写

DHT22这个传感器,我用了不下十年。它最大的特点就是——便宜、稳定、精度够用。但它的通信协议有点特殊,叫「单总线」。

说白了,就是一根数据线,既要发信号又要收信号。时序要求很严格,差个几微秒就读不出来。

4.1.1 单总线协议时序分析

DHT22的通信过程,其实就三步:

  1. 主机发起信号:拉低总线至少18ms,然后拉高20-40μs
  2. 传感器响应:拉低80μs,再拉高80μs
  3. 数据传输:40位数据,每位以50μs低电平开始

这里有个关键点——怎么区分0和1?

很简单:

  • 高电平持续26-28μs → 表示0
  • 高电平持续70μs → 表示1

嗯,这里要注意。ESP32的主频是240MHz,但Arduino框架下的delayMicroseconds()函数精度有限。我建议用esp_timer_get_time()来做微秒级计时,比delay靠谱得多。

⚠️ 我曾经踩过的坑: 用delayMicroseconds()做时序控制,结果在WiFi开启时,中断导致时序错乱,数据全乱套。后来改用硬件定时器+GPIO中断,问题才解决。

4.1.2 驱动代码实现

好,直接上代码。这是我个人习惯的写法——把DHT22封装成一个类,方便复用。

class DHT22 {
private:
    uint8_t pin;
    int16_t temperature;
    int16_t humidity;
    
    bool waitForLevel(uint8_t level, uint32_t timeout_us) {
        uint32_t start = esp_timer_get_time();
        while (digitalRead(pin) != level) {
            if (esp_timer_get_time() - start > timeout_us) return false;
        }
        return true;
    }
    
    uint8_t readByte() {
        uint8_t data = 0;
        for (int i = 0; i < 8; i++) {
            // 等待50μs低电平结束
            waitForLevel(HIGH, 100);
            uint32_t start = esp_timer_get_time();
            waitForLevel(LOW, 100);
            uint32_t duration = esp_timer_get_time() - start;
            // 判断是0还是1
            data <<= 1;
            if (duration > 40) data |= 1;
        }
        return data;
    }
    
public:
    DHT22(uint8_t gpio_pin) : pin(gpio_pin) {}
    
    bool read() {
        // 1. 主机发起信号
        pinMode(pin, OUTPUT);
        digitalWrite(pin, LOW);
        delay(20);  // 至少18ms
        digitalWrite(pin, HIGH);
        delayMicroseconds(30);
        pinMode(pin, INPUT_PULLUP);
        
        // 2. 等待响应
        if (!waitForLevel(LOW, 100)) return false;
        if (!waitForLevel(HIGH, 100)) return false;
        
        // 3. 读取40位数据
        uint8_t data[5];
        for (int i = 0; i < 5; i++) {
            data[i] = readByte();
        }
        
        // 4. 校验和验证
        uint8_t checksum = data[0] + data[1] + data[2] + data[3];
        if (checksum != data[4]) return false;
        
        // 5. 解析数据
        humidity = (data[0] << 8) | data[1];
        temperature = (data[2] << 8) | data[3];
        
        return true;
    }
    
    float getTemperature() { return temperature / 10.0f; }
    float getHumidity() { return humidity / 10.0f; }
};
💡 我的经验: 每次读取间隔至少2秒。DHT22的采样率就是0.5Hz,你读太快它也不更新数据。我见过有人用1秒间隔,结果读出来的数据全是上一次的缓存。

4.2 MQ-135模拟量读取(ADC)

MQ-135这个气体传感器,说白了就是个可变电阻。它检测到有害气体时,内部电阻会变化。我们通过ADC读取电压值,就能反推出气体浓度。

但这里有个问题——MQ-135的模拟输出是非线性的。你不能直接用电压值当浓度用,得做校准。

4.2.1 ADC配置与读取

ESP32的ADC有12位精度,也就是0-4095。但它的线性度嘛...说实话,不太行。尤其是靠近0V和3.3V两端,误差比较大。

我个人习惯用ADC1的通道,因为ADC2在WiFi开启时会受影响。

#include "driver/adc.h"
#include "esp_adc_cal.h"

#define MQ135_PIN GPIO_NUM_34  // ADC1_CH6

static esp_adc_cal_characteristics_t adc_chars;

void initMQ135() {
    // 配置ADC
    adc1_config_width(ADC_WIDTH_BIT_12);
    adc1_config_channel_atten(ADC1_CHANNEL_6, ADC_ATTEN_DB_11);
    
    // 校准ADC
    esp_adc_cal_characterize(
        ADC_UNIT_1, 
        ADC_ATTEN_DB_11, 
        ADC_WIDTH_BIT_12, 
        1100,  // 参考电压,单位mV
        &adc_chars
    );
}

float readMQ135Raw() {
    uint32_t adc_reading = adc1_get_raw(ADC1_CHANNEL_6);
    uint32_t voltage = esp_adc_cal_raw_to_voltage(adc_reading, &adc_chars);
    return voltage / 1000.0f;  // 转换为伏特
}
⚠️ 注意: MQ-135需要预热。刚上电的前几分钟,读数会漂移得很厉害。我一般会在代码里加一个「预热标志位」,前120秒的数据不参与控制逻辑。

4.2.2 气体浓度换算

MQ-135的数据手册里有个曲线图,展示了不同气体浓度下Rs/R0的比值。我们需要把这个曲线拟合成数学公式。

我实测下来,用指数函数拟合效果最好:

// 以NH3为例的浓度换算
float calculateNH3(float voltage) {
    // Rs = (Vc - Vout) / Vout * RL
    float Rs = (3.3f - voltage) / voltage * 10.0f;  // RL=10kΩ
    
    // R0:在洁净空气中的阻值
    float ratio = Rs / R0;
    
    // 指数拟合:ppm = a * (ratio)^b
    // 参数来自数据手册曲线拟合
    float ppm = 10.0f * pow(ratio, -1.5f);
    
    return ppm;
}
💡 我的建议: R0值最好在传感器稳定后自动校准。我一般会在开机后第5分钟,取30秒的平均值作为R0。这样即使传感器老化,也能自动补偿。

4.3 数据校准与滤波

说实话,原始数据直接拿来用,那是不行的。DHT22还好,MQ-135的噪声特别大。我见过有人用原始数据做控制,结果净化器一会儿开一会儿关,跟抽风似的。

4.3.1 滑动平均滤波

这是我最常用的滤波方法。说白了,就是取最近N次数据的平均值。N越大越平滑,但响应也越慢。

class MovingAverage {
private:
    float* buffer;
    int size;
    int index;
    float sum;
    bool filled;
    
public:
    MovingAverage(int n) : size(n), index(0), sum(0), filled(false) {
        buffer = new float[n];
        memset(buffer, 0, n * sizeof(float));
    }
    
    float addValue(float value) {
        sum -= buffer[index];
        buffer[index] = value;
        sum += value;
        index = (index + 1) % size;
        if (index == 0) filled = true;
        
        int count = filled ? size : index;
        return sum / count;
    }
};

4.3.2 中值滤波

对于突发噪声(比如有人走过带来的气流扰动),中值滤波效果更好。我一般取5个数据的中位数。

float medianFilter(float* data, int len) {
    // 复制数组
    float temp[len];
    memcpy(temp, data, len * sizeof(float));
    
    // 冒泡排序
    for (int i = 0; i < len - 1; i++) {
        for (int j = 0; j < len - i - 1; j++) {
            if (temp[j] > temp[j + 1]) {
                float t = temp[j];
                temp[j] = temp[j + 1];
                temp[j + 1] = t;
            }
        }
    }
    
    return temp[len / 2];
}
🔑 关键经验: 我建议把滑动平均和中值滤波结合起来用。先做中值滤波去掉野值,再做滑动平均平滑曲线。这样既抗干扰,又不会太迟钝。

4.4 多传感器数据融合显示

好,现在我们有温度、湿度、气体浓度三个数据了。怎么把它们整合到一起显示?

我习惯用一个结构体来打包所有数据:

typedef struct {
    float temperature;    // 温度,单位℃
    float humidity;       // 湿度,单位%
    float nh3_ppm;        // 氨气浓度,单位ppm
    float co2_ppm;        // CO2浓度,单位ppm
    uint32_t timestamp;   // 时间戳
    uint8_t quality;      // 数据质量:0-100
} SensorData_t;

SensorData_t currentData;

void updateSensorData() {
    // 读取DHT22
    if (dht22.read()) {
        currentData.temperature = dht22.getTemperature();
        currentData.humidity = dht22.getHumidity();
    }
    
    // 读取MQ-135
    float voltage = readMQ135Raw();
    currentData.nh3_ppm = calculateNH3(voltage);
    
    // 数据质量评估
    currentData.quality = evaluateDataQuality();
    currentData.timestamp = millis();
}

显示方面,我推荐用OLED屏幕。128x64的分辨率,I2C接口,占两个引脚就够了。

void displaySensorData() {
    display.clearDisplay();
    display.setTextSize(1);
    display.setCursor(0, 0);
    
    // 第一行:温度
    display.printf("Temp: %.1f C\n", currentData.temperature);
    
    // 第二行:湿度
    display.printf("Humi: %.1f %%\n", currentData.humidity);
    
    // 第三行:气体
    display.printf("NH3:  %.1f ppm\n", currentData.nh3_ppm);
    
    // 第四行:数据质量
    display.printf("Qual: %d%%\n", currentData.quality);
    
    display.display();
}
💡 我的习惯: 在屏幕上加一个「数据质量」指示条。如果质量低于80%,就用黄色显示;低于50%,用红色闪烁。这样一眼就能看出传感器是不是出问题了。

好了,这节课的内容就到这里。总结一下:

  • DHT22的时序要精确,用硬件定时器比delay靠谱
  • MQ-135需要预热和校准,R0值最好自动获取
  • 滤波是必须的,中值+滑动平均组合效果最好
  • 数据融合用结构体打包,显示时加上质量指示

下节课,咱们要讲怎么用这些数据做联动控制。到时候,我会分享一个我实际做过的净化器控制算法——不是简单的阈值开关,而是PID调节。敬请期待。