一、项目概述

1.1 项目背景与目标

随着人们对室内环境健康的关注度不断提升,空气质量监测已成为智能家居和健康管理领域的热门方向。室内空气中的PM2.5颗粒物、甲醛、CO2浓度以及温湿度等参数,直接影响人体健康和生活舒适度。

传统的空气质量检测方式存在以下问题:

  • 依赖手持式检测仪,无法实现24小时连续监测
  • 数据无法远程查看和存储,缺乏历史趋势分析
  • 单一参数检测,无法全面反映室内空气状况
  • 缺乏报警机制,无法及时发现空气质量异常

本项目基于STM32F103C8T6微控制器,集成PM2.5、甲醛、CO2、温湿度四类传感器,通过ESP8266 WiFi模块实现数据上传,并配合Python PyQt5开发PC端上位机,实现数据可视化与历史趋势分析。

1.2 技术栈选型

技术领域 具体选型 选择理由
主控芯片 STM32F103C8T6 成本低、外设丰富、开发资料完善
PM2.5传感器 PMS5003 激光散射原理,精度高,数字输出
甲醛传感器 ZE08-CH2O 电化学原理,精度高,UART输出
CO2传感器 MH-Z19B NDIR红外原理,精度±50ppm,UART输出
温湿度传感器 DHT22 精度±0.5℃/±2%RH,性价比高
WiFi模块 ESP8266-01S 支持AT指令,成熟稳定
显示模块 0.96寸OLED(SSD1306) I2C接口,功耗低
上位机 Python 3.x + PyQt5 开发效率高,界面美观

1.3 系统功能

  • 实时采集PM2.5、甲醛、CO2浓度及温湿度数据
  • OLED本地显示当前环境参数
  • WiFi无线数据上传至PC上位机
  • PyQt5上位机实时数据展示与历史曲线绘制
  • 阈值报警功能(蜂鸣器+上位机弹窗)
  • SQLite本地数据库存储历史数据

二、系统架构设计

2.1 整体架构

系统采用经典的三层架构设计:

在这里插入图片描述

2.2 数据流向

在这里插入图片描述

三、硬件设计与连接

3.1 硬件清单

组件 型号 数量 接口方式
主控芯片 STM32F103C8T6 1 -
PM2.5传感器 PMS5003 1 UART(USART2)
甲醛传感器 ZE08-CH2O 1 UART(USART3)
CO2传感器 MH-Z19B 1 UART(软件串口)
温湿度传感器 DHT22 1 单总线(GPIO)
WiFi模块 ESP8266-01S 1 UART(USART1)
显示模块 0.96寸OLED 1 I2C
蜂鸣器 有源蜂鸣器 1 GPIO
电源模块 5V/3.3V 1 -

3.2 引脚分配

ESP8266:    TX  → PA10 (USART1_RX)
            RX  → PA9  (USART1_TX)
            VCC → 3.3V
            CH_PD → 3.3V

PMS5003:    TX  → PA3  (USART2_RX)
            RX  → PA2  (USART2_TX)
            VCC → 5V

ZE08-CH2O:  TX  → PB11 (USART3_RX)
            RX  → PB10 (USART3_TX)
            VCC → 3.3V

MH-Z19B:    TX  → PB8  (软件串口RX)
            RX  → PB9  (软件串口TX)
            VCC → 5V

DHT22:      DATA → PA0  (GPIO)
            VCC  → 3.3V

OLED:       SCL → PB6  (I2C1_SCL)
            SDA → PB7  (I2C1_SDA)

蜂鸣器:     IN  → PA1  (GPIO输出)

⚠️ 重要提醒:

  • ESP8266必须使用3.3V供电,接5V会烧毁模块
  • PMS5003和MH-Z19B需要5V供电,但数据线为3.3V电平,可直连STM32
  • DHT22的DATA引脚需要外接4.7KΩ上拉电阻

四、代码实现详解

4.1 传感器驱动层

PMS5003 PM2.5传感器读取

PMS5003采用UART通信,主动上报模式,数据帧格式固定为32字节:

// pms5003.h
typedef struct {
    uint16_t pm1_0;      // PM1.0浓度 (μg/m³)
    uint16_t pm2_5;      // PM2.5浓度 (μg/m³)
    uint16_t pm10;       // PM10浓度  (μg/m³)
} PMS5003_Data;

HAL_StatusTypeDef PMS5003_ReadData(PMS5003_Data *data);

// pms5003.c
#define PMS_FRAME_LEN  32
#define PMS_START1     0x42
#define PMS_START2     0x4D

static uint8_t pms_rx_buf[PMS_FRAME_LEN];

HAL_StatusTypeDef PMS5003_ReadData(PMS5003_Data *data) {
    // 接收一帧数据
    if (HAL_UART_Receive(&huart2, pms_rx_buf, PMS_FRAME_LEN, 2000) != HAL_OK) {
        return HAL_ERROR;
    }

    // 校验帧头
    if (pms_rx_buf[0] != PMS_START1 || pms_rx_buf[1] != PMS_START2) {
        return HAL_ERROR;
    }

    // 校验和验证
    uint16_t checksum = 0;
    for (int i = 0; i < PMS_FRAME_LEN - 2; i++) {
        checksum += pms_rx_buf[i];
    }
    uint16_t rx_check = (pms_rx_buf[30] << 8) | pms_rx_buf[31];
    if (checksum != rx_check) {
        return HAL_ERROR;
    }

    // 解析数据(大气环境下浓度值)
    data->pm1_0 = (pms_rx_buf[10] << 8) | pms_rx_buf[11];
    data->pm2_5 = (pms_rx_buf[12] << 8) | pms_rx_buf[13];
    data->pm10  = (pms_rx_buf[14] << 8) | pms_rx_buf[15];

    return HAL_OK;
}
ZE08-CH2O 甲醛传感器读取

ZE08采用主动上报模式,数据帧为9字节:

// ze08_ch2o.h
float ZE08_ReadCH2O(void);

// ze08_ch2o.c
float ZE08_ReadCH2O(void) {
    uint8_t rx_buf[9];

    if (HAL_UART_Receive(&huart3, rx_buf, 9, 2000) != HAL_OK) {
        return -1.0f;
    }

    // 校验帧头
    if (rx_buf[0] != 0xFF || rx_buf[1] != 0x17 || rx_buf[2] != 0x04) {
        return -1.0f;
    }

    // 校验和
    uint8_t checksum = 0;
    for (int i = 1; i < 8; i++) {
        checksum += rx_buf[i];
    }
    checksum = (~checksum) + 1;
    if (checksum != rx_buf[8]) {
        return -1.0f;
    }

    // 甲醛浓度 = (高字节 * 256 + 低字节) * 0.001 mg/m³
    uint16_t raw = (rx_buf[4] << 8) | rx_buf[5];
    float ch2o = raw * 0.001f;

    return ch2o;  // 单位: mg/m³
}
MH-Z19B CO2传感器读取

MH-Z19B通过UART发送查询指令获取CO2浓度:

// mhz19b.h
uint16_t MHZ19B_ReadCO2(void);

// mhz19b.c
// 使用软件串口(定时器模拟)
static uint8_t mhz19_cmd[9] = {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79};

uint16_t MHZ19B_ReadCO2(void) {
    uint8_t response[9] = {0};

    // 发送读取指令
    SoftUART_Transmit(mhz19_cmd, 9);
    HAL_Delay(100);

    // 接收响应
    if (SoftUART_Receive(response, 9, 1000) != 0) {
        return 0;
    }

    // 校验
    if (response[0] != 0xFF || response[1] != 0x86) {
        return 0;
    }

    uint8_t checksum = 0;
    for (int i = 1; i < 8; i++) {
        checksum += response[i];
    }
    checksum = (~checksum) + 1;
    if (checksum != response[8]) {
        return 0;
    }

    // CO2浓度 = 高字节 * 256 + 低字节
    uint16_t co2 = (response[2] << 8) | response[3];
    return co2;  // 单位: ppm
}
DHT22 温湿度传感器读取
// dht22.h
typedef struct {
    float temperature;  // 温度 (℃)
    float humidity;     // 湿度 (%RH)
} DHT22_Data;

HAL_StatusTypeDef DHT22_ReadData(DHT22_Data *data);

// dht22.c
static void DHT22_SetPinOutput(void) {
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin   = DHT22_PIN;
    GPIO_InitStruct.Mode  = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(DHT22_PORT, &GPIO_InitStruct);
}

static void DHT22_SetPinInput(void) {
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin  = DHT22_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    HAL_GPIO_Init(DHT22_PORT, &GPIO_InitStruct);
}

HAL_StatusTypeDef DHT22_ReadData(DHT22_Data *data) {
    uint8_t bits[5] = {0};
    uint32_t timeout;

    // 主机拉低至少1ms(DHT22要求≥1ms)
    DHT22_SetPinOutput();
    HAL_GPIO_WritePin(DHT22_PORT, DHT22_PIN, GPIO_PIN_RESET);
    HAL_Delay(2);
    HAL_GPIO_WritePin(DHT22_PORT, DHT22_PIN, GPIO_PIN_SET);

    // 切换为输入,等待DHT22响应
    DHT22_SetPinInput();
    timeout = 0;
    while (HAL_GPIO_ReadPin(DHT22_PORT, DHT22_PIN) == GPIO_PIN_SET) {
        if (++timeout > 10000) return HAL_ERROR;
    }
    timeout = 0;
    while (HAL_GPIO_ReadPin(DHT22_PORT, DHT22_PIN) == GPIO_PIN_RESET) {
        if (++timeout > 10000) return HAL_ERROR;
    }
    timeout = 0;
    while (HAL_GPIO_ReadPin(DHT22_PORT, DHT22_PIN) == GPIO_PIN_SET) {
        if (++timeout > 10000) return HAL_ERROR;
    }

    // 读取40位数据
    for (int i = 0; i < 40; i++) {
        timeout = 0;
        while (HAL_GPIO_ReadPin(DHT22_PORT, DHT22_PIN) == GPIO_PIN_RESET) {
            if (++timeout > 10000) return HAL_ERROR;
        }
        uint32_t start = DWT->CYCCNT;
        timeout = 0;
        while (HAL_GPIO_ReadPin(DHT22_PORT, DHT22_PIN) == GPIO_PIN_SET) {
            if (++timeout > 10000) return HAL_ERROR;
        }
        uint32_t duration = DWT->CYCCNT - start;

        // 高电平持续时间 > 40μs 为'1',否则为'0'
        if (duration > (SystemCoreClock / 1000000 * 40)) {
            bits[i / 8] |= (1 << (7 - (i % 8)));
        }
    }

    // 校验和
    if (bits[4] != ((bits[0] + bits[1] + bits[2] + bits[3]) & 0xFF)) {
        return HAL_ERROR;
    }

    // 解析(DHT22数据含小数位)
    int16_t raw_hum  = (bits[0] << 8) | bits[1];
    int16_t raw_temp = (bits[2] << 8) | bits[3];

    data->humidity    = raw_hum  * 0.1f;
    data->temperature = (raw_temp & 0x7FFF) * 0.1f;
    if (raw_temp & 0x8000) data->temperature = -data->temperature;

    return HAL_OK;
}

4.2 数据采集与WiFi上传

主程序逻辑
// main.c
#include "stm32f1xx_hal.h"
#include "pms5003.h"
#include "ze08_ch2o.h"
#include "mhz19b.h"
#include "dht22.h"
#include "esp8266.h"
#include "oled.h"
#include <stdio.h>
#include <string.h>

// 报警阈值定义
#define PM25_ALARM_THRESHOLD    75      // PM2.5报警阈值 (μg/m³)
#define CH2O_ALARM_THRESHOLD    0.08f   // 甲醛报警阈值 (mg/m³)
#define CO2_ALARM_THRESHOLD     1000    // CO2报警阈值 (ppm)

// 全局数据
PMS5003_Data  g_pms_data;
DHT22_Data    g_dht_data;
float         g_ch2o  = 0.0f;
uint16_t      g_co2   = 0;

// 蜂鸣器控制
void Buzzer_On(void)  { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET);   }
void Buzzer_Off(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); }

// 报警检测
void Check_Alarm(void) {
    if (g_pms_data.pm2_5 > PM25_ALARM_THRESHOLD ||
        g_ch2o > CH2O_ALARM_THRESHOLD ||
        g_co2 > CO2_ALARM_THRESHOLD) {
        Buzzer_On();
        HAL_Delay(200);
        Buzzer_Off();
    }
}

// OLED显示
void Display_Data(void) {
    char buf[32];

    OLED_Clear();

    sprintf(buf, "PM2.5: %d ug/m3", g_pms_data.pm2_5);
    OLED_ShowString(0, 0, buf);

    sprintf(buf, "CH2O: %.3f mg", g_ch2o);
    OLED_ShowString(0, 2, buf);

    sprintf(buf, "CO2: %d ppm", g_co2);
    OLED_ShowString(0, 4, buf);

    sprintf(buf, "T:%.1fC H:%.1f%%", g_dht_data.temperature, g_dht_data.humidity);
    OLED_ShowString(0, 6, buf);

    OLED_Refresh();
}

// 数据打包为JSON并通过WiFi发送
void Send_Data_WiFi(void) {
    char json[256];
    sprintf(json,
        "{\"pm25\":%d,\"pm10\":%d,\"ch2o\":%.3f,\"co2\":%d,\"temp\":%.1f,\"hum\":%.1f}",
        g_pms_data.pm2_5, g_pms_data.pm10,
        g_ch2o, g_co2,
        g_dht_data.temperature, g_dht_data.humidity
    );

    ESP8266_SendData(json, strlen(json));
}

int main(void) {
    HAL_Init();
    SystemClock_Config();

    // 外设初始化
    MX_GPIO_Init();
    MX_USART1_UART_Init();  // ESP8266
    MX_USART2_UART_Init();  // PMS5003
    MX_USART3_UART_Init();  // ZE08-CH2O
    MX_I2C1_Init();         // OLED
    SoftUART_Init();        // MH-Z19B 软件串口

    // OLED初始化
    OLED_Init();
    OLED_Clear();
    OLED_ShowString(0, 3, "System Starting...");
    OLED_Refresh();

    // ESP8266初始化并连接WiFi
    ESP8266_Init();
    ESP8266_ConnectWiFi("YourSSID", "YourPassword");
    ESP8266_ConnectTCP("192.168.1.100", 8080);  // 连接上位机

    HAL_Delay(2000);

    while (1) {
        // 1. 采集传感器数据
        PMS5003_ReadData(&g_pms_data);
        g_ch2o = ZE08_ReadCH2O();
        g_co2  = MHZ19B_ReadCO2();
        DHT22_ReadData(&g_dht_data);

        // 2. OLED显示
        Display_Data();

        // 3. 报警检测
        Check_Alarm();

        // 4. WiFi上传数据
        Send_Data_WiFi();

        // 5. 采集间隔3秒
        HAL_Delay(3000);
    }
}

4.3 ESP8266 WiFi模块驱动

// esp8266.h
typedef enum {
    ESP_OK = 0,
    ESP_ERROR,
    ESP_TIMEOUT
} ESP_Status;

ESP_Status ESP8266_Init(void);
ESP_Status ESP8266_ConnectWiFi(const char *ssid, const char *pwd);
ESP_Status ESP8266_ConnectTCP(const char *ip, uint16_t port);
ESP_Status ESP8266_SendData(const char *data, uint16_t len);

// esp8266.c
static char esp_rx_buf[256];

static ESP_Status ESP8266_SendCmd(const char *cmd, const char *expect, uint32_t timeout) {
    memset(esp_rx_buf, 0, sizeof(esp_rx_buf));
    HAL_UART_Transmit(&huart1, (uint8_t *)cmd, strlen(cmd), 1000);
    HAL_UART_Transmit(&huart1, (uint8_t *)"\r\n", 2, 100);

    HAL_UART_Receive(&huart1, (uint8_t *)esp_rx_buf, sizeof(esp_rx_buf) - 1, timeout);

    if (strstr(esp_rx_buf, expect) != NULL) {
        return ESP_OK;
    }
    return ESP_ERROR;
}

ESP_Status ESP8266_Init(void) {
    HAL_Delay(1000);
    if (ESP8266_SendCmd("AT", "OK", 1000) != ESP_OK) return ESP_ERROR;
    if (ESP8266_SendCmd("AT+CWMODE=1", "OK", 1000) != ESP_OK) return ESP_ERROR;
    return ESP_OK;
}

ESP_Status ESP8266_ConnectWiFi(const char *ssid, const char *pwd) {
    char cmd[128];
    sprintf(cmd, "AT+CWJAP=\"%s\",\"%s\"", ssid, pwd);
    return ESP8266_SendCmd(cmd, "WIFI GOT IP", 15000);
}

ESP_Status ESP8266_ConnectTCP(const char *ip, uint16_t port) {
    char cmd[128];
    sprintf(cmd, "AT+CIPSTART=\"TCP\",\"%s\",%d", ip, port);
    return ESP8266_SendCmd(cmd, "CONNECT", 5000);
}

ESP_Status ESP8266_SendData(const char *data, uint16_t len) {
    char cmd[32];
    sprintf(cmd, "AT+CIPSEND=%d", len);
    if (ESP8266_SendCmd(cmd, ">", 2000) != ESP_OK) return ESP_ERROR;

    HAL_UART_Transmit(&huart1, (uint8_t *)data, len, 2000);
    HAL_Delay(100);

    return ESP_OK;
}

4.4 Python上位机实现

TCP服务端 + 数据存储
# tcp_server.py
import socket
import json
import sqlite3
import threading
from datetime import datetime
from PyQt5.QtCore import QObject, pyqtSignal

class AirQualityServer(QObject):
    """TCP服务端,接收STM32上传的空气质量数据"""
    data_received = pyqtSignal(dict)

    def __init__(self, host='0.0.0.0', port=8080):
        super().__init__()
        self.host = host
        self.port = port
        self.running = False

        # 初始化数据库
        self.db = sqlite3.connect('air_quality.db', check_same_thread=False)
        self.db_lock = threading.Lock()
        self._init_db()

    def _init_db(self):
        cursor = self.db.cursor()
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS sensor_data (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                pm25 INTEGER,
                pm10 INTEGER,
                ch2o REAL,
                co2 INTEGER,
                temperature REAL,
                humidity REAL,
                timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
            )
        ''')
        self.db.commit()

    def start(self):
        self.running = True
        self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.server.bind((self.host, self.port))
        self.server.listen(1)
        self.server.settimeout(1.0)

        threading.Thread(target=self._accept_loop, daemon=True).start()

    def _accept_loop(self):
        while self.running:
            try:
                client, addr = self.server.accept()
                print(f"设备连接: {addr}")
                threading.Thread(target=self._handle_client,
                                 args=(client,), daemon=True).start()
            except socket.timeout:
                continue

    def _handle_client(self, client):
        buffer = b''
        while self.running:
            try:
                chunk = client.recv(1024)
                if not chunk:
                    break
                buffer += chunk

                # 按JSON对象分割(每条数据以}结尾)
                while b'}' in buffer:
                    idx = buffer.index(b'}') + 1
                    raw = buffer[:idx]
                    buffer = buffer[idx:]

                    try:
                        data = json.loads(raw.decode())
                        data['timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                        self._save_data(data)
                        self.data_received.emit(data)
                    except json.JSONDecodeError:
                        continue
            except Exception as e:
                print(f"接收异常: {e}")
                break
        client.close()

    def _save_data(self, data):
        with self.db_lock:
            cursor = self.db.cursor()
            cursor.execute('''
                INSERT INTO sensor_data (pm25, pm10, ch2o, co2, temperature, humidity)
                VALUES (?, ?, ?, ?, ?, ?)
            ''', (data.get('pm25', 0), data.get('pm10', 0),
                  data.get('ch2o', 0), data.get('co2', 0),
                  data.get('temp', 0), data.get('hum', 0)))
            self.db.commit()

    def get_history(self, hours=24):
        """获取最近N小时的历史数据"""
        with self.db_lock:
            cursor = self.db.cursor()
            cursor.execute('''
                SELECT pm25, pm10, ch2o, co2, temperature, humidity, timestamp
                FROM sensor_data
                WHERE timestamp >= datetime('now', ?)
                ORDER BY timestamp ASC
            ''', (f'-{hours} hours',))
            return cursor.fetchall()

    def stop(self):
        self.running = False
        self.server.close()
        self.db.close()
PyQt5 可视化界面
# main_window.py
import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
import pyqtgraph as pg
from tcp_server import AirQualityServer

class AirQualityMonitor(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('室内空气质量监测系统')
        self.setGeometry(100, 100, 1280, 800)
        self.setStyleSheet("background-color: #1e1e2e; color: #cdd6f4;")

        # 历史数据缓存
        self.history = {'pm25': [], 'ch2o': [], 'co2': [], 'temp': [], 'hum': [], 'time': []}
        self.max_points = 200  # 最多显示200个数据点

        self._init_ui()
        self._init_server()

    def _init_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QVBoxLayout(central)

        # ===== 顶部标题栏 =====
        title = QLabel('🌿 室内空气质量监测系统')
        title.setStyleSheet("font-size: 22px; font-weight: bold; padding: 10px; color: #a6e3a1;")
        title.setAlignment(Qt.AlignCenter)
        main_layout.addWidget(title)

        # ===== 中间区域:数据卡片 + 图表 =====
        content_layout = QHBoxLayout()
        main_layout.addLayout(content_layout)

        # 左侧:实时数据卡片
        card_panel = QVBoxLayout()
        content_layout.addLayout(card_panel, 1)

        self.cards = {}
        card_configs = [
            ('pm25',  'PM2.5',  'μg/m³', '#f38ba8', 75),
            ('ch2o',  '甲醛',   'mg/m³', '#fab387', 0.08),
            ('co2',   'CO2',    'ppm',   '#f9e2af', 1000),
            ('temp',  '温度',   '℃',     '#89b4fa', None),
            ('hum',   '湿度',   '%RH',   '#74c7ec', None),
        ]

        for key, name, unit, color, threshold in card_configs:
            card = self._create_card(name, unit, color, threshold)
            card_panel.addWidget(card['widget'])
            self.cards[key] = card

        card_panel.addStretch()

        # 右侧:实时曲线图
        chart_panel = QVBoxLayout()
        content_layout.addLayout(chart_panel, 3)

        # PM2.5 + CO2 曲线
        self.plot1 = pg.PlotWidget(title="PM2.5 & CO2 趋势")
        self.plot1.setBackground('#313244')
        self.plot1.showGrid(x=True, y=True, alpha=0.3)
        self.plot1.addLegend()
        self.curve_pm25 = self.plot1.plot(pen=pg.mkPen('#f38ba8', width=2), name='PM2.5')
        self.curve_co2  = self.plot1.plot(pen=pg.mkPen('#f9e2af', width=2), name='CO2')
        chart_panel.addWidget(self.plot1)

        # 温湿度 + 甲醛曲线
        self.plot2 = pg.PlotWidget(title="温湿度 & 甲醛趋势")
        self.plot2.setBackground('#313244')
        self.plot2.showGrid(x=True, y=True, alpha=0.3)
        self.plot2.addLegend()
        self.curve_temp = self.plot2.plot(pen=pg.mkPen('#89b4fa', width=2), name='温度')
        self.curve_hum  = self.plot2.plot(pen=pg.mkPen('#74c7ec', width=2), name='湿度')
        chart_panel.addWidget(self.plot2)

        # ===== 底部状态栏 =====
        self.status_bar = QLabel('⏳ 等待设备连接...')
        self.status_bar.setStyleSheet("font-size: 13px; padding: 5px; color: #a6adc8;")
        main_layout.addWidget(self.status_bar)

    def _create_card(self, name, unit, color, threshold):
        widget = QFrame()
        widget.setStyleSheet(f"""
            QFrame {{
                background-color: #313244;
                border-left: 4px solid {color};
                border-radius: 8px;
                padding: 12px;
                margin: 4px;
            }}
        """)
        layout = QVBoxLayout(widget)

        name_label = QLabel(name)
        name_label.setStyleSheet(f"font-size: 13px; color: #a6adc8;")

        value_label = QLabel('--')
        value_label.setStyleSheet(f"font-size: 28px; font-weight: bold; color: {color};")

        unit_label = QLabel(unit)
        unit_label.setStyleSheet("font-size: 11px; color: #585b70;")

        layout.addWidget(name_label)
        layout.addWidget(value_label)
        layout.addWidget(unit_label)

        return {
            'widget': widget,
            'value_label': value_label,
            'threshold': threshold,
            'color': color
        }

    def _init_server(self):
        self.server = AirQualityServer(port=8080)
        self.server.data_received.connect(self.on_data)
        self.server.start()
        self.status_bar.setText('✅ 服务已启动,监听端口 8080,等待设备连接...')

    @pyqtSlot(dict)
    def on_data(self, data):
        """收到新数据时更新界面"""
        # 更新数据卡片
        mapping = {
            'pm25': ('pm25', '{:.0f}'),
            'ch2o': ('ch2o', '{:.3f}'),
            'co2':  ('co2',  '{:.0f}'),
            'temp': ('temp', '{:.1f}'),
            'hum':  ('hum',  '{:.1f}'),
        }

        alarm_msgs = []
        for key, (data_key, fmt) in mapping.items():
            val = data.get(data_key, 0)
            card = self.cards[key]
            card['value_label'].setText(fmt.format(val))

            # 超阈值变红
            if card['threshold'] is not None and val > card['threshold']:
                card['value_label'].setStyleSheet("font-size: 28px; font-weight: bold; color: #f38ba8;")
                alarm_msgs.append(f"{key.upper()} 超标!")
            else:
                card['value_label'].setStyleSheet(f"font-size: 28px; font-weight: bold; color: {card['color']};")

        # 更新历史数据
        self.history['pm25'].append(data.get('pm25', 0))
        self.history['co2'].append(data.get('co2', 0))
        self.history['ch2o'].append(data.get('ch2o', 0))
        self.history['temp'].append(data.get('temp', 0))
        self.history['hum'].append(data.get('hum', 0))

        # 限制数据点数量
        for k in self.history:
            if len(self.history[k]) > self.max_points:
                self.history[k] = self.history[k][-self.max_points:]

        # 更新曲线
        self.curve_pm25.setData(self.history['pm25'])
        self.curve_co2.setData(self.history['co2'])
        self.curve_temp.setData(self.history['temp'])
        self.curve_hum.setData(self.history['hum'])

        # 更新状态栏
        ts = data.get('timestamp', '')
        if alarm_msgs:
            self.status_bar.setText(f'⚠️ {ts} | {" | ".join(alarm_msgs)}')
            self.status_bar.setStyleSheet("font-size: 13px; padding: 5px; color: #f38ba8;")
        else:
            self.status_bar.setText(f'✅ {ts} | 空气质量正常')
            self.status_bar.setStyleSheet("font-size: 13px; padding: 5px; color: #a6e3a1;")

    def closeEvent(self, event):
        self.server.stop()
        super().closeEvent(event)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = AirQualityMonitor()
    window.show()
    sys.exit(app.exec_())

五、调试与常见问题

5.1 ESP8266连接问题

调试时可通过串口助手发送AT指令测试:

AT                          → OK
AT+CWMODE=1                 → OK
AT+CWLAP                    → 扫描周围WiFi
AT+CWJAP="SSID","密码"      → WIFI GOT IP
AT+CIPSTART="TCP","192.168.1.100",8080  → CONNECT

⚠️ 如果AT无响应,检查:波特率是否匹配(默认115200)、TX/RX是否接反、CH_PD是否拉高

5.2 传感器数据异常

  • PMS5003读数为0:检查供电是否为5V,传感器需要预热约30秒
  • ZE08甲醛值偏高:新传感器需要48小时老化期,初始读数不准确属正常现象
  • MH-Z19B CO2值400不变:传感器需要预热3分钟,且需要校准(通电后在室外放置20分钟,发送校准指令)
  • DHT22读取失败:确认DATA引脚是否接了4.7KΩ上拉电阻,读取间隔不要小于2秒

5.3 数据丢包处理

WiFi传输不稳定时,可增加重发机制:

// 带重试的数据发送
void Send_Data_WithRetry(const char *data, uint16_t len, uint8_t max_retry) {
    for (uint8_t i = 0; i < max_retry; i++) {
        if (ESP8266_SendData(data, len) == ESP_OK) {
            return;  // 发送成功
        }
        HAL_Delay(500);

        // 重连TCP
        if (i == max_retry / 2) {
            ESP8266_ConnectTCP("192.168.1.100", 8080);
        }
    }
}

六、项目总结与扩展

6.1 项目成果

本项目成功实现了一套完整的室内空气质量监测系统,具备以下功能:

  • 多参数监测:PM2.5、甲醛、CO2、温湿度四类环境参数实时采集
  • 本地显示:OLED屏幕实时显示当前环境数据
  • 无线传输:ESP8266 WiFi模块实现数据无线上传
  • 可视化界面:PyQt5上位机提供实时数据展示和历史趋势曲线
  • 报警功能:阈值超限时蜂鸣器报警 + 上位机界面提示
  • 数据存储:SQLite数据库保存历史数据,支持回溯分析

6.2 关键技术点

  • STM32多串口管理:USART1/2/3 + 软件串口,同时驱动4个UART设备
  • 多传感器协议解析:PMS5003/ZE08/MH-Z19B各有不同的帧格式和校验方式
  • ESP8266 AT指令封装:WiFi连接、TCP通信的完整驱动实现
  • PyQt5 + pyqtgraph:实时数据可视化与动态曲线绘制
  • TCP Socket通信:STM32与PC之间的可靠数据传输

6.3 扩展方向

  • 接入MQTT协议,支持阿里云IoT/OneNET等云平台
  • 增加手机端小程序或APP远程查看
  • 加入空气净化器/新风系统联动控制
  • 使用FreeRTOS实现多任务调度,提升系统实时性
  • 增加SD卡本地存储,断网时数据不丢失

📌 如果这篇文章对你有帮助,请点赞👍收藏⭐关注🔔,后续会持续更新更多嵌入式物联网项目!

有问题欢迎评论区交流,看到都会回复~

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐