基于STM32的智能水质监测与远程预警系统设计与实现

一、项目概述

1.1 项目背景与目标

水质安全是关系到生产生活的重要问题,传统水质检测依赖人工采样送检,存在以下痛点:

  • 人工采样周期长,无法实时掌握水质变化趋势
  • 检测设备昂贵,中小型养殖场和水处理站难以承受
  • 缺乏远程监控手段,异常情况无法及时发现和处理
  • 多参数综合评估困难,单一指标难以反映真实水质状况

本项目设计一套低成本、高可靠的智能水质监测系统,实现以下目标:

  • 多参数实时采集:pH值、浊度、TDS(总溶解固体)、水温四项核心指标
  • 本地显示与存储:OLED屏实时显示,SD卡本地存储历史数据
  • 远程数据上报:通过ESP8266 WiFi模块,基于MQTT协议上报云端
  • 多级阈值预警:支持预警、报警、危险三级阈值,蜂鸣器+LED本地告警,MQTT远程推送
  • 上位机可视化:PyQt5桌面端实时曲线、历史查询、报表导出
1.2 技术栈选型
技术领域 具体选型 选择理由
主控芯片 STM32F103C8T6 72MHz Cortex-M3,12位ADC,资源充足且成本低
pH检测 PH-4502C模块 模拟输出0-5V线性对应pH 0-14,BNC接口方便更换探头
浊度检测 TSW-30浊度传感器 模拟输出,量程0-4000NTU,响应速度快
TDS检测 TDS Meter V1.0 模拟输出,量程0-1000ppm,自带温度补偿接口
水温检测 DS18B20防水型 单总线协议,±0.5°C精度,不锈钢封装适合水下
显示模块 0.96寸OLED(SSD1306) I2C接口,功耗低,显示清晰
WiFi通信 ESP8266-01S 成本低,AT指令控制,MQTT协议支持成熟
通信协议 MQTT v3.1.1 轻量级发布/订阅模式,适合低带宽IoT场景
上位机 Python + PyQt5 跨平台,开发效率高,图表库丰富
本地存储 Micro SD卡(SPI) 大容量,CSV格式存储方便后续分析

二、系统架构设计

2.1 整体架构

系统采用经典的三层物联网架构:感知层负责数据采集与本地控制,传输层负责数据通信与协议转换,应用层负责数据展示与业务逻辑。

感知层(STM32端):

  • 4路传感器数据采集(3路ADC + 1路单总线)
  • 本地OLED显示与SD卡存储
  • 多级阈值判断与本地告警(蜂鸣器+LED)
  • 传感器校准参数管理(Flash存储校准系数)

传输层(ESP8266):

  • WiFi网络接入与MQTT连接管理
  • 数据帧封装与JSON格式上报
  • 断线重连与数据缓存机制
  • 远程指令下发(校准命令、阈值配置)

应用层(PyQt5上位机/云端):

  • 实时数据展示与多参数曲线绑定
  • 历史数据查询与趋势分析
  • 报警记录管理与报表导出
  • 远程阈值配置与校准指令下发
2.2 数据流向

系统数据流分为上行数据流和下行控制流两条路径:

上行数据流:

传感器模拟信号 → STM32 ADC采样 → 数字滤波(滑动平均) → 工程量转换(校准公式) → 本地显示/存储/告警判断 → JSON封装 → UART发送至ESP8266 → MQTT Publish → 上位机接收解析 → 实时曲线/数据库存储

下行控制流:

上位机发送指令 → MQTT Publish → ESP8266接收 → UART转发至STM32 → 指令解析执行(修改阈值/触发校准/切换模式)

三、环境搭建与硬件连接

3.1 硬件清单
组件 型号 数量 备注
主控板 STM32F103C8T6最小系统板 1 带ST-Link下载口
pH传感器 PH-4502C + BNC探头 1 配标准校准液(pH4.0/6.86/9.18)
浊度传感器 TSW-30 1 模拟输出,量程0-4000NTU
TDS传感器 TDS Meter V1.0 1 配TDS探头,模拟输出
温度传感器 DS18B20防水型 1 不锈钢封装,线长1m
显示屏 0.96寸OLED(SSD1306) 1 I2C接口,128×64分辨率
WiFi模块 ESP8266-01S 1 配3.3V稳压和电平转换
存储模块 Micro SD卡模块(SPI) 1 配16GB TF卡
蜂鸣器 有源蜂鸣器模块 1 3.3V驱动,高电平触发
指示灯 LED(红/黄/绿) 3 分别对应危险/预警/正常
电源 5V/2A电源适配器 1 配AMS1117-3.3稳压
3.2 关键接线说明

ADC采集引脚分配:

传感器 STM32引脚 ADC通道 备注
PH-4502C(Po) PA0 ADC1_CH0 模拟输出0-5V,需分压至0-3.3V
TSW-30(AO) PA1 ADC1_CH1 模拟输出0-4.5V,需分压
TDS Meter(AO) PA4 ADC1_CH4 模拟输出0-2.3V,可直连

数字接口分配:

模块 STM32引脚 接口类型 备注
DS18B20(DQ) PB12 单总线(GPIO) 外接4.7KΩ上拉电阻
OLED(SCL/SDA) PB6/PB7 I2C1 外接4.7KΩ上拉电阻
ESP8266(TX/RX) PA9/PA10 USART1 波特率115200,交叉连接
SD卡(SCK/MISO/MOSI/CS) PA5/PA6/PA7/PA4 SPI1 注意:PA4与TDS共用时需切换
蜂鸣器 PB0 GPIO推挽输出 高电平触发
LED(绿/黄/红) PB13/PB14/PB15 GPIO推挽输出 低电平点亮(共阳)

⚠️ 重要提醒:

  1. PH-4502C和TSW-30的模拟输出电压可能超过3.3V,必须通过电阻分压网络降压后再接入STM32的ADC引脚,否则会烧毁ADC通道。推荐使用10KΩ+20KΩ分压,将5V降至3.3V。
  2. DS18B20的DQ引脚必须外接4.7KΩ上拉电阻到VCC(3.3V),否则通信不稳定。
  3. ESP8266-01S工作电流峰值可达300mA,不要从STM32的3.3V引脚取电,必须独立供电。
  4. SD卡模块与TDS传感器共用PA4引脚时,需要通过片选信号分时复用,或将TDS改接到其他ADC通道(如PA2/ADC1_CH2)。
3.3 软件环境配置

开发环境:

  • STM32CubeIDE 1.13.0 或 Keil MDK 5.38
  • STM32CubeMX(HAL库代码生成)
  • Python 3.8+(上位机开发)

Python依赖安装:

bash 复制代码
# 安装上位机依赖包
pip install PyQt5==5.15.9
pip install paho-mqtt==1.6.1
pip install pyqtgraph==0.13.3
pip install pandas==2.0.3

四、代码实现详解

4.1 ADC多通道采集模块

ADC模块负责pH、浊度、TDS三路模拟信号的采集,采用DMA方式连续转换,减少CPU占用。

adc_collect.h
c 复制代码
#ifndef __ADC_COLLECT_H
#define __ADC_COLLECT_H

#include "stm32f1xx_hal.h"

/* ADC通道定义 */
#define ADC_CHANNEL_PH       0    // PA0 - pH传感器
#define ADC_CHANNEL_TURBID   1    // PA1 - 浊度传感器
#define ADC_CHANNEL_TDS      4    // PA4 - TDS传感器
#define ADC_CHANNEL_NUM      3    // 总通道数

/* 滤波参数 */
#define FILTER_WINDOW_SIZE   10   // 滑动平均窗口大小
#define ADC_VREF             3.3f // 参考电压
#define ADC_RESOLUTION       4096 // 12位ADC分辨率

/* ADC原始数据结构 */
typedef struct {
    uint16_t raw[ADC_CHANNEL_NUM];           // DMA缓冲区
    float voltage[ADC_CHANNEL_NUM];          // 转换后电压值
    uint16_t filter_buf[ADC_CHANNEL_NUM][FILTER_WINDOW_SIZE]; // 滤波缓冲
    uint8_t filter_idx;                       // 滤波索引
    uint8_t filter_ready;                     // 滤波缓冲区是否填满
} ADC_Data_t;

/* 函数声明 */
void ADC_Collect_Init(void);
void ADC_Collect_Update(void);
float ADC_Collect_GetVoltage(uint8_t channel);
uint16_t ADC_Collect_GetRaw(uint8_t channel);

#endif
adc_collect.c
c 复制代码
#include "adc_collect.h"

extern ADC_HandleTypeDef hadc1;
extern DMA_HandleTypeDef hdma_adc1;

static ADC_Data_t adc_data;

/**
 * @brief  ADC采集模块初始化,启动DMA连续转换
 */
void ADC_Collect_Init(void)
{
    memset(&adc_data, 0, sizeof(ADC_Data_t));
    
    /* 启动ADC DMA连续转换 */
    HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_data.raw, ADC_CHANNEL_NUM);
}

/**
 * @brief  更新ADC数据,执行滑动平均滤波
 * @note   建议在定时器中断中周期调用,采样周期50ms
 */
void ADC_Collect_Update(void)
{
    uint8_t i;
    uint32_t sum;
    
    /* 将当前采样值存入滤波缓冲区 */
    for (i = 0; i < ADC_CHANNEL_NUM; i++)
    {
        adc_data.filter_buf[i][adc_data.filter_idx] = adc_data.raw[i];
    }
    
    adc_data.filter_idx++;
    if (adc_data.filter_idx >= FILTER_WINDOW_SIZE)
    {
        adc_data.filter_idx = 0;
        adc_data.filter_ready = 1;
    }
    
    /* 计算滑动平均值并转换为电压 */
    uint8_t count = adc_data.filter_ready ? FILTER_WINDOW_SIZE : adc_data.filter_idx;
    
    for (i = 0; i < ADC_CHANNEL_NUM; i++)
    {
        sum = 0;
        for (uint8_t j = 0; j < count; j++)
        {
            sum += adc_data.filter_buf[i][j];
        }
        adc_data.voltage[i] = (float)sum / count * ADC_VREF / ADC_RESOLUTION;
    }
}

/**
 * @brief  获取指定通道的滤波后电压值
 * @param  channel: 通道索引(0-2)
 * @return 电压值(V)
 */
float ADC_Collect_GetVoltage(uint8_t channel)
{
    if (channel >= ADC_CHANNEL_NUM) return 0.0f;
    return adc_data.voltage[channel];
}

/**
 * @brief  获取指定通道的原始ADC值
 * @param  channel: 通道索引(0-2)
 * @return 12位原始值(0-4095)
 */
uint16_t ADC_Collect_GetRaw(uint8_t channel)
{
    if (channel >= ADC_CHANNEL_NUM) return 0;
    return adc_data.raw[channel];
}

ADC采用DMA方式自动搬运数据,主循环只需调用ADC_Collect_Update()即可获取滤波后的电压值。滑动平均窗口设为10,在50ms采样周期下相当于500ms的平滑窗口,能有效抑制传感器噪声。

4.2 传感器数据转换模块

传感器模块负责将ADC电压值转换为实际物理量,包含校准参数管理和温度补偿。

sensor_convert.h
c 复制代码
#ifndef __SENSOR_CONVERT_H
#define __SENSOR_CONVERT_H

#include "stm32f1xx_hal.h"

/* 传感器校准参数结构 */
typedef struct {
    float ph_offset;       // pH零点偏移(V),标准pH7.0对应电压
    float ph_slope;        // pH斜率(pH/V),由两点校准计算
    float turbid_k;        // 浊度系数
    float turbid_b;        // 浊度偏移
    float tds_factor;      // TDS校准系数
    uint32_t magic;        // 校验魔数,判断Flash中是否有有效校准数据
} Sensor_Calib_t;

/* 传感器数据结构 */
typedef struct {
    float ph;              // pH值(0-14)
    float turbidity;       // 浊度(NTU)
    float tds;             // TDS(ppm)
    float temperature;     // 水温(°C)
    uint8_t valid;         // 数据有效标志
} Sensor_Data_t;

/* 函数声明 */
void Sensor_Convert_Init(void);
void Sensor_Convert_Update(float temp_celsius);
Sensor_Data_t* Sensor_Convert_GetData(void);
void Sensor_Calib_SetPH(float ph_low, float v_low, float ph_high, float v_high);
void Sensor_Calib_Save(void);
void Sensor_Calib_Load(void);

#endif
sensor_convert.c
c 复制代码
#include "sensor_convert.h"
#include "adc_collect.h"
#include <math.h>

#define CALIB_MAGIC        0x57515143  // "WQIC" - Water Quality Instrument Calibration
#define CALIB_FLASH_ADDR   0x0800FC00  // Flash最后1KB存储校准参数

static Sensor_Calib_t calib;
static Sensor_Data_t  sensor_data;

/* 默认校准参数(出厂值) */
static const Sensor_Calib_t default_calib = {
    .ph_offset = 2.50f,    // pH7.0对应2.5V(理论值)
    .ph_slope  = -5.70f,   // 每V对应的pH变化量
    .turbid_k  = -1120.4f, // 浊度线性系数
    .turbid_b  = 5742.3f,  // 浊度偏移量
    .tds_factor = 1.0f,    // TDS校准系数
    .magic     = CALIB_MAGIC
};

/**
 * @brief  传感器转换模块初始化,加载校准参数
 */
void Sensor_Convert_Init(void)
{
    Sensor_Calib_Load();
    memset(&sensor_data, 0, sizeof(Sensor_Data_t));
}

/**
 * @brief  pH值转换(含温度补偿)
 * @param  voltage: ADC采集电压(V),已经过分压还原
 * @param  temp: 当前水温(°C),用于温度补偿
 * @return pH值(0-14)
 * @note   pH电极的理论斜率为-59.16mV/pH(25°C),温度每变化1°C斜率变化0.198mV
 */
static float pH_Convert(float voltage, float temp)
{
    /* 分压还原:实际电压 = ADC电压 * (R1+R2)/R2 = V * 3/2 */
    float actual_v = voltage * 3.0f / 2.0f;
    
    /* 温度补偿系数:修正Nernst方程中的温度项 */
    float temp_factor = (273.15f + temp) / (273.15f + 25.0f);
    
    /* pH = 7.0 + (offset - voltage) / (slope * temp_factor) */
    float ph = 7.0f + (calib.ph_offset - actual_v) / (0.05916f * temp_factor);
    
    /* 限幅 */
    if (ph < 0.0f) ph = 0.0f;
    if (ph > 14.0f) ph = 14.0f;
    
    return ph;
}

/**
 * @brief  浊度转换
 * @param  voltage: ADC采集电压(V)
 * @return 浊度值(NTU)
 * @note   TSW-30输出电压与浊度近似线性关系:NTU = k * V + b
 */
static float Turbidity_Convert(float voltage)
{
    /* 分压还原 */
    float actual_v = voltage * 3.0f / 2.0f;
    
    float ntu = calib.turbid_k * actual_v + calib.turbid_b;
    
    if (ntu < 0.0f) ntu = 0.0f;
    if (ntu > 4000.0f) ntu = 4000.0f;
    
    return ntu;
}

/**
 * @brief  TDS转换(含温度补偿)
 * @param  voltage: ADC采集电压(V)
 * @param  temp: 当前水温(°C)
 * @return TDS值(ppm)
 * @note   TDS传感器输出电压与电导率成正比,温度每升高1°C电导率增加约2%
 */
static float TDS_Convert(float voltage, float temp)
{
    /* 温度补偿:将电导率归一化到25°C */
    float comp_coeff = 1.0f + 0.02f * (temp - 25.0f);
    float comp_voltage = voltage / comp_coeff;
    
    /* 三次多项式拟合:TDS = 133.42*V^3 - 255.86*V^2 + 857.39*V */
    float tds = (133.42f * comp_voltage * comp_voltage * comp_voltage
               - 255.86f * comp_voltage * comp_voltage
               + 857.39f * comp_voltage) * calib.tds_factor;
    
    if (tds < 0.0f) tds = 0.0f;
    if (tds > 1000.0f) tds = 1000.0f;
    
    return tds;
}

/**
 * @brief  更新所有传感器数据
 * @param  temp_celsius: DS18B20读取的水温值
 */
void Sensor_Convert_Update(float temp_celsius)
{
    sensor_data.temperature = temp_celsius;
    sensor_data.ph = pH_Convert(ADC_Collect_GetVoltage(0), temp_celsius);
    sensor_data.turbidity = Turbidity_Convert(ADC_Collect_GetVoltage(1));
    sensor_data.tds = TDS_Convert(ADC_Collect_GetVoltage(2), temp_celsius);
    sensor_data.valid = 1;
}

/**
 * @brief  获取传感器数据指针
 */
Sensor_Data_t* Sensor_Convert_GetData(void)
{
    return &sensor_data;
}

/**
 * @brief  pH两点校准
 * @param  ph_low:  低标准液pH值(如4.0)
 * @param  v_low:   低标准液对应电压
 * @param  ph_high: 高标准液pH值(如9.18)
 * @param  v_high:  高标准液对应电压
 */
void Sensor_Calib_SetPH(float ph_low, float v_low, float ph_high, float v_high)
{
    calib.ph_slope = (ph_high - ph_low) / (v_high - v_low);
    calib.ph_offset = v_low - (ph_low - 7.0f) / calib.ph_slope;
}

/**
 * @brief  保存校准参数到Flash
 */
void Sensor_Calib_Save(void)
{
    calib.magic = CALIB_MAGIC;
    
    HAL_FLASH_Unlock();
    FLASH_EraseInitTypeDef erase;
    uint32_t error;
    erase.TypeErase = FLASH_TYPEERASE_PAGES;
    erase.PageAddress = CALIB_FLASH_ADDR;
    erase.NbPages = 1;
    HAL_FLASHEx_Erase(&erase, &error);
    
    /* 按半字写入 */
    uint16_t *p = (uint16_t *)&calib;
    for (uint32_t i = 0; i < sizeof(Sensor_Calib_t) / 2; i++)
    {
        HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, 
                          CALIB_FLASH_ADDR + i * 2, p[i]);
    }
    HAL_FLASH_Lock();
}

/**
 * @brief  从Flash加载校准参数
 */
void Sensor_Calib_Load(void)
{
    memcpy(&calib, (void *)CALIB_FLASH_ADDR, sizeof(Sensor_Calib_t));
    
    /* 校验魔数,无效则使用默认参数 */
    if (calib.magic != CALIB_MAGIC)
    {
        memcpy(&calib, &default_calib, sizeof(Sensor_Calib_t));
    }
}

传感器校准是水质监测的核心环节。pH传感器采用两点校准法(pH4.0和pH9.18标准液),校准参数存储在Flash最后1KB空间,掉电不丢失。TDS传感器的温度补偿系数为2%/°C,这是行业通用经验值。

4.3 DS18B20温度采集模块

DS18B20采用单总线协议,时序要求严格,需要微秒级延时控制。

ds18b20.h
c 复制代码
#ifndef __DS18B20_H
#define __DS18B20_H

#include "stm32f1xx_hal.h"

#define DS18B20_PORT    GPIOB
#define DS18B20_PIN     GPIO_PIN_12

/* 函数声明 */
uint8_t DS18B20_Init(void);
float DS18B20_ReadTemp(void);

#endif
ds18b20.c
c 复制代码
#include "ds18b20.h"

/* 微秒延时(基于DWT周期计数器) */
static void delay_us(uint32_t us)
{
    uint32_t start = DWT->CYCCNT;
    uint32_t ticks = us * (SystemCoreClock / 1000000);
    while ((DWT->CYCCNT - start) < ticks);
}

/* 设置引脚为输出模式 */
static void DS18B20_SetOutput(void)
{
    GPIO_InitTypeDef gpio = {0};
    gpio.Pin = DS18B20_PIN;
    gpio.Mode = GPIO_MODE_OUTPUT_PP;
    gpio.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(DS18B20_PORT, &gpio);
}

/* 设置引脚为输入模式 */
static void DS18B20_SetInput(void)
{
    GPIO_InitTypeDef gpio = {0};
    gpio.Pin = DS18B20_PIN;
    gpio.Mode = GPIO_MODE_INPUT;
    gpio.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(DS18B20_PORT, &gpio);
}

/**
 * @brief  DS18B20复位与存在检测
 * @return 0-检测到设备  1-未检测到
 */
uint8_t DS18B20_Init(void)
{
    /* 使能DWT计数器用于微秒延时 */
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
    
    DS18B20_SetOutput();
    HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_RESET);
    delay_us(480);  // 拉低480us发送复位脉冲
    HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_SET);
    
    DS18B20_SetInput();
    delay_us(60);   // 等待60us后检测存在脉冲
    
    uint8_t presence = HAL_GPIO_ReadPin(DS18B20_PORT, DS18B20_PIN);
    delay_us(420);  // 等待时序完成
    
    return presence; // 0=存在, 1=不存在
}

/* 写一个字节 */
static void DS18B20_WriteByte(uint8_t data)
{
    DS18B20_SetOutput();
    for (uint8_t i = 0; i < 8; i++)
    {
        HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_RESET);
        delay_us(2);
        if (data & 0x01)
            HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_SET);
        delay_us(60);
        HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_SET);
        delay_us(2);
        data >>= 1;
    }
}

/* 读一个字节 */
static uint8_t DS18B20_ReadByte(void)
{
    uint8_t data = 0;
    for (uint8_t i = 0; i < 8; i++)
    {
        DS18B20_SetOutput();
        HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_RESET);
        delay_us(2);
        HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_SET);
        DS18B20_SetInput();
        delay_us(12);
        data >>= 1;
        if (HAL_GPIO_ReadPin(DS18B20_PORT, DS18B20_PIN))
            data |= 0x80;
        delay_us(50);
    }
    return data;
}

/**
 * @brief  读取温度值
 * @return 温度值(°C),精度±0.5°C
 * @note   完整转换耗时约750ms(12位精度),建议在独立任务中调用
 */
float DS18B20_ReadTemp(void)
{
    if (DS18B20_Init() != 0) return -100.0f; // 设备不存在
    
    DS18B20_WriteByte(0xCC); // 跳过ROM(单设备)
    DS18B20_WriteByte(0x44); // 启动温度转换
    
    HAL_Delay(750); // 等待转换完成(12位精度)
    
    if (DS18B20_Init() != 0) return -100.0f;
    
    DS18B20_WriteByte(0xCC); // 跳过ROM
    DS18B20_WriteByte(0xBE); // 读取暂存器
    
    uint8_t lsb = DS18B20_ReadByte();
    uint8_t msb = DS18B20_ReadByte();
    
    int16_t raw = (msb << 8) | lsb;
    return raw * 0.0625f; // 12位精度,分辨率0.0625°C
}

⚠️ 注意: DS18B20温度转换需要750ms(12位精度模式),在FreeRTOS环境下应放在独立任务中,使用vTaskDelay()替代HAL_Delay(),避免阻塞其他任务。

4.4 多级阈值告警模块

告警模块实现三级阈值判断:正常→预警→报警→危险,支持滞回控制防止阈值附近频繁切换。

alarm_manager.h
c 复制代码
#ifndef __ALARM_MANAGER_H
#define __ALARM_MANAGER_H

#include "stm32f1xx_hal.h"
#include "sensor_convert.h"

/* 告警级别 */
typedef enum {
    ALARM_NORMAL  = 0,   // 正常 - 绿灯
    ALARM_WARNING = 1,   // 预警 - 黄灯
    ALARM_ALERT   = 2,   // 报警 - 红灯慢闪
    ALARM_DANGER  = 3    // 危险 - 红灯快闪+蜂鸣器
} Alarm_Level_t;

/* 单参数阈值配置 */
typedef struct {
    float warn_low;      // 预警下限
    float warn_high;     // 预警上限
    float alert_low;     // 报警下限
    float alert_high;    // 报警上限
    float hysteresis;    // 滞回量,防止阈值附近频繁切换
} Alarm_Threshold_t;

/* 告警状态 */
typedef struct {
    Alarm_Level_t ph_level;
    Alarm_Level_t turbid_level;
    Alarm_Level_t tds_level;
    Alarm_Level_t temp_level;
    Alarm_Level_t overall;       // 综合告警级别(取最高)
    uint32_t last_alarm_tick;    // 上次告警时间戳
} Alarm_Status_t;

/* 函数声明 */
void Alarm_Manager_Init(void);
void Alarm_Manager_Update(Sensor_Data_t *data);
Alarm_Status_t* Alarm_Manager_GetStatus(void);
void Alarm_Manager_SetThreshold(uint8_t param_idx, Alarm_Threshold_t *thresh);

#endif
alarm_manager.c
c 复制代码
#include "alarm_manager.h"

/* LED引脚定义 */
#define LED_GREEN_PORT   GPIOB
#define LED_GREEN_PIN    GPIO_PIN_13
#define LED_YELLOW_PORT  GPIOB
#define LED_YELLOW_PIN   GPIO_PIN_14
#define LED_RED_PORT     GPIOB
#define LED_RED_PIN      GPIO_PIN_15
#define BUZZER_PORT      GPIOB
#define BUZZER_PIN       GPIO_PIN_0

static Alarm_Status_t alarm_status;

/* 默认阈值配置(养殖水质标准) */
static Alarm_Threshold_t thresholds[4] = {
    /* pH: 养殖适宜范围6.5-8.5 */
    { .warn_low = 6.5f, .warn_high = 8.5f, 
      .alert_low = 6.0f, .alert_high = 9.0f, .hysteresis = 0.2f },
    /* 浊度(NTU): <50正常, 50-100预警, >100报警 */
    { .warn_low = 0.0f, .warn_high = 50.0f, 
      .alert_low = 0.0f, .alert_high = 100.0f, .hysteresis = 5.0f },
    /* TDS(ppm): 养殖适宜范围200-500 */
    { .warn_low = 200.0f, .warn_high = 500.0f, 
      .alert_low = 100.0f, .alert_high = 800.0f, .hysteresis = 20.0f },
    /* 温度(°C): 养殖适宜范围18-28 */
    { .warn_low = 18.0f, .warn_high = 28.0f, 
      .alert_low = 15.0f, .alert_high = 32.0f, .hysteresis = 0.5f }
};

/**
 * @brief  带滞回的阈值判断
 * @param  value: 当前测量值
 * @param  thresh: 阈值配置
 * @param  current_level: 当前告警级别(用于滞回判断)
 * @return 新的告警级别
 */
static Alarm_Level_t Threshold_Check(float value, Alarm_Threshold_t *thresh, 
                                      Alarm_Level_t current_level)
{
    float hyst = thresh->hysteresis;
    
    /* 从高到低判断,优先检测危险状态 */
    if (value < thresh->alert_low - hyst || value > thresh->alert_high + hyst)
        return ALARM_DANGER;
    
    if (current_level >= ALARM_DANGER)
    {
        /* 当前是危险状态,需要回到报警范围内才降级 */
        if (value > thresh->alert_low + hyst && value < thresh->alert_high - hyst)
            return ALARM_ALERT;
        return ALARM_DANGER;
    }
    
    if (value < thresh->warn_low - hyst || value > thresh->warn_high + hyst)
        return ALARM_ALERT;
    
    if (current_level >= ALARM_ALERT)
    {
        if (value > thresh->warn_low + hyst && value < thresh->warn_high - hyst)
            return ALARM_WARNING;
        return ALARM_ALERT;
    }
    
    if (value < thresh->warn_low || value > thresh->warn_high)
        return ALARM_WARNING;
    
    if (current_level >= ALARM_WARNING)
    {
        if (value > thresh->warn_low + hyst && value < thresh->warn_high - hyst)
            return ALARM_NORMAL;
        return ALARM_WARNING;
    }
    
    return ALARM_NORMAL;
}

void Alarm_Manager_Init(void)
{
    memset(&alarm_status, 0, sizeof(Alarm_Status_t));
    
    /* 初始状态:绿灯亮 */
    HAL_GPIO_WritePin(LED_GREEN_PORT, LED_GREEN_PIN, GPIO_PIN_RESET);   // 共阳低电平亮
    HAL_GPIO_WritePin(LED_YELLOW_PORT, LED_YELLOW_PIN, GPIO_PIN_SET);
    HAL_GPIO_WritePin(LED_RED_PORT, LED_RED_PIN, GPIO_PIN_SET);
    HAL_GPIO_WritePin(BUZZER_PORT, BUZZER_PIN, GPIO_PIN_RESET);
}

/**
 * @brief  更新告警状态
 * @param  data: 传感器数据指针
 */
void Alarm_Manager_Update(Sensor_Data_t *data)
{
    if (!data->valid) return;
    
    /* 逐参数判断告警级别 */
    alarm_status.ph_level = Threshold_Check(data->ph, &thresholds[0], alarm_status.ph_level);
    alarm_status.turbid_level = Threshold_Check(data->turbidity, &thresholds[1], alarm_status.turbid_level);
    alarm_status.tds_level = Threshold_Check(data->tds, &thresholds[2], alarm_status.tds_level);
    alarm_status.temp_level = Threshold_Check(data->temperature, &thresholds[3], alarm_status.temp_level);
    
    /* 综合告警取最高级别 */
    alarm_status.overall = alarm_status.ph_level;
    if (alarm_status.turbid_level > alarm_status.overall)
        alarm_status.overall = alarm_status.turbid_level;
    if (alarm_status.tds_level > alarm_status.overall)
        alarm_status.overall = alarm_status.tds_level;
    if (alarm_status.temp_level > alarm_status.overall)
        alarm_status.overall = alarm_status.temp_level;
    
    /* 驱动LED和蜂鸣器 */
    HAL_GPIO_WritePin(LED_GREEN_PORT, LED_GREEN_PIN, GPIO_PIN_SET);
    HAL_GPIO_WritePin(LED_YELLOW_PORT, LED_YELLOW_PIN, GPIO_PIN_SET);
    HAL_GPIO_WritePin(LED_RED_PORT, LED_RED_PIN, GPIO_PIN_SET);
    HAL_GPIO_WritePin(BUZZER_PORT, BUZZER_PIN, GPIO_PIN_RESET);
    
    uint32_t tick = HAL_GetTick();
    switch (alarm_status.overall)
    {
        case ALARM_NORMAL:
            HAL_GPIO_WritePin(LED_GREEN_PORT, LED_GREEN_PIN, GPIO_PIN_RESET);
            break;
        case ALARM_WARNING:
            HAL_GPIO_WritePin(LED_YELLOW_PORT, LED_YELLOW_PIN, GPIO_PIN_RESET);
            break;
        case ALARM_ALERT:
            /* 红灯慢闪(1Hz) */
            if ((tick / 500) % 2)
                HAL_GPIO_WritePin(LED_RED_PORT, LED_RED_PIN, GPIO_PIN_RESET);
            break;
        case ALARM_DANGER:
            /* 红灯快闪(4Hz) + 蜂鸣器 */
            if ((tick / 125) % 2)
                HAL_GPIO_WritePin(LED_RED_PORT, LED_RED_PIN, GPIO_PIN_RESET);
            HAL_GPIO_WritePin(BUZZER_PORT, BUZZER_PIN, GPIO_PIN_SET);
            break;
    }
    
    alarm_status.last_alarm_tick = tick;
}

Alarm_Status_t* Alarm_Manager_GetStatus(void)
{
    return &alarm_status;
}

void Alarm_Manager_SetThreshold(uint8_t param_idx, Alarm_Threshold_t *thresh)
{
    if (param_idx < 4)
        memcpy(&thresholds[param_idx], thresh, sizeof(Alarm_Threshold_t));
}

告警模块的关键设计是滞回控制:当某个参数在阈值附近波动时,不会频繁触发告警切换。例如pH预警阈值为6.5,滞回量为0.2,那么pH从6.4升到6.5时不会立即解除预警,需要升到6.7以上才会恢复正常。这在工业控制中是标准做法。

4.5 ESP8266 MQTT通信模块

ESP8266通过AT指令控制,实现WiFi连接和MQTT数据上报。通信模块封装了AT指令交互、连接管理和JSON数据封装。

esp8266_mqtt.h
c 复制代码
#ifndef __ESP8266_MQTT_H
#define __ESP8266_MQTT_H

#include "stm32f1xx_hal.h"
#include "sensor_convert.h"
#include "alarm_manager.h"

/* WiFi配置 */
#define WIFI_SSID       "YourWiFi"
#define WIFI_PASSWORD   "YourPassword"

/* MQTT配置 */
#define MQTT_BROKER     "broker.emqx.io"
#define MQTT_PORT       1883
#define MQTT_CLIENT_ID  "wq_monitor_001"
#define MQTT_USERNAME   ""
#define MQTT_PASSWORD   ""

/* MQTT主题定义 */
#define TOPIC_DATA      "waterquality/001/data"      // 上报传感器数据
#define TOPIC_ALARM     "waterquality/001/alarm"     // 上报告警信息
#define TOPIC_CMD       "waterquality/001/cmd"       // 接收控制指令
#define TOPIC_STATUS    "waterquality/001/status"    // 设备状态

/* 连接状态 */
typedef enum {
    ESP_STATE_IDLE = 0,
    ESP_STATE_WIFI_CONNECTING,
    ESP_STATE_WIFI_CONNECTED,
    ESP_STATE_MQTT_CONNECTING,
    ESP_STATE_MQTT_CONNECTED,
    ESP_STATE_ERROR
} ESP_State_t;

/* 函数声明 */
void ESP8266_Init(void);
ESP_State_t ESP8266_GetState(void);
uint8_t ESP8266_Connect(void);
uint8_t ESP8266_PublishData(Sensor_Data_t *data);
uint8_t ESP8266_PublishAlarm(Alarm_Status_t *alarm, Sensor_Data_t *data);
void ESP8266_ProcessRx(void);
void ESP8266_Reconnect(void);

#endif
esp8266_mqtt.c
c 复制代码
#include "esp8266_mqtt.h"
#include <stdio.h>
#include <string.h>

extern UART_HandleTypeDef huart1;

static ESP_State_t esp_state = ESP_STATE_IDLE;
static char tx_buf[512];
static char rx_buf[256];
static volatile uint16_t rx_len = 0;
static uint32_t last_publish_tick = 0;
static uint32_t reconnect_tick = 0;

/**
 * @brief  发送AT指令并等待响应
 * @param  cmd: AT指令字符串
 * @param  expected: 期望的响应关键字(如"OK","CONNECT")
 * @param  timeout_ms: 超时时间(ms)
 * @return 0-成功  1-超时  2-错误
 */
static uint8_t AT_SendCmd(const char *cmd, const char *expected, uint32_t timeout_ms)
{
    rx_len = 0;
    memset(rx_buf, 0, sizeof(rx_buf));
    
    HAL_UART_Transmit(&huart1, (uint8_t *)cmd, strlen(cmd), 100);
    HAL_UART_Transmit(&huart1, (uint8_t *)"\r\n", 2, 10);
    
    uint32_t start = HAL_GetTick();
    while ((HAL_GetTick() - start) < timeout_ms)
    {
        if (strstr(rx_buf, expected) != NULL)
            return 0;
        if (strstr(rx_buf, "ERROR") != NULL)
            return 2;
        HAL_Delay(10);
    }
    return 1; // 超时
}

void ESP8266_Init(void)
{
    esp_state = ESP_STATE_IDLE;
    
    /* 开启UART空闲中断接收 */
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
    HAL_UART_Receive_DMA(&huart1, (uint8_t *)rx_buf, sizeof(rx_buf));
}

/**
 * @brief  连接WiFi和MQTT Broker
 * @return 0-成功  非0-失败
 */
uint8_t ESP8266_Connect(void)
{
    /* 复位模块 */
    if (AT_SendCmd("AT+RST", "ready", 3000) != 0)
        if (AT_SendCmd("AT", "OK", 1000) != 0) return 1;
    
    /* 设置Station模式 */
    AT_SendCmd("AT+CWMODE=1", "OK", 1000);
    
    /* 连接WiFi */
    esp_state = ESP_STATE_WIFI_CONNECTING;
    snprintf(tx_buf, sizeof(tx_buf), "AT+CWJAP=\"%s\",\"%s\"", WIFI_SSID, WIFI_PASSWORD);
    if (AT_SendCmd(tx_buf, "WIFI GOT IP", 15000) != 0)
    {
        esp_state = ESP_STATE_ERROR;
        return 2;
    }
    esp_state = ESP_STATE_WIFI_CONNECTED;
    
    /* 建立TCP连接到MQTT Broker */
    esp_state = ESP_STATE_MQTT_CONNECTING;
    snprintf(tx_buf, sizeof(tx_buf), "AT+MQTTUSERCFG=0,1,\"%s\",\"%s\",\"%s\",0,0,\"\"",
             MQTT_CLIENT_ID, MQTT_USERNAME, MQTT_PASSWORD);
    AT_SendCmd(tx_buf, "OK", 2000);
    
    snprintf(tx_buf, sizeof(tx_buf), "AT+MQTTCONN=0,\"%s\",%d,1", MQTT_BROKER, MQTT_PORT);
    if (AT_SendCmd(tx_buf, "OK", 10000) != 0)
    {
        esp_state = ESP_STATE_ERROR;
        return 3;
    }
    
    /* 订阅控制指令主题 */
    snprintf(tx_buf, sizeof(tx_buf), "AT+MQTTSUB=0,\"%s\",1", TOPIC_CMD);
    AT_SendCmd(tx_buf, "OK", 3000);
    
    esp_state = ESP_STATE_MQTT_CONNECTED;
    return 0;
}

/**
 * @brief  发布传感器数据(JSON格式)
 * @param  data: 传感器数据指针
 * @return 0-成功  非0-失败
 */
uint8_t ESP8266_PublishData(Sensor_Data_t *data)
{
    if (esp_state != ESP_STATE_MQTT_CONNECTED) return 1;
    
    /* 控制发布频率:最快5秒一次 */
    if ((HAL_GetTick() - last_publish_tick) < 5000) return 0;
    
    /* 构造JSON数据 */
    char json[256];
    snprintf(json, sizeof(json),
        "{\"ph\":%.2f,\"turbidity\":%.1f,\"tds\":%.0f,\"temp\":%.1f,\"ts\":%lu}",
        data->ph, data->turbidity, data->tds, data->temperature, HAL_GetTick() / 1000);
    
    snprintf(tx_buf, sizeof(tx_buf), "AT+MQTTPUB=0,\"%s\",\"%s\",1,0", TOPIC_DATA, json);
    uint8_t ret = AT_SendCmd(tx_buf, "OK", 3000);
    
    if (ret == 0) last_publish_tick = HAL_GetTick();
    return ret;
}

/**
 * @brief  发布告警信息
 */
uint8_t ESP8266_PublishAlarm(Alarm_Status_t *alarm, Sensor_Data_t *data)
{
    if (esp_state != ESP_STATE_MQTT_CONNECTED) return 1;
    if (alarm->overall == ALARM_NORMAL) return 0; // 正常状态不上报告警
    
    const char *level_str[] = {"normal", "warning", "alert", "danger"};
    
    char json[256];
    snprintf(json, sizeof(json),
        "{\"level\":\"%s\",\"ph\":{\"v\":%.2f,\"l\":%d},\"turb\":{\"v\":%.1f,\"l\":%d},"
        "\"tds\":{\"v\":%.0f,\"l\":%d},\"temp\":{\"v\":%.1f,\"l\":%d}}",
        level_str[alarm->overall],
        data->ph, alarm->ph_level,
        data->turbidity, alarm->turbid_level,
        data->tds, alarm->tds_level,
        data->temperature, alarm->temp_level);
    
    snprintf(tx_buf, sizeof(tx_buf), "AT+MQTTPUB=0,\"%s\",\"%s\",1,0", TOPIC_ALARM, json);
    return AT_SendCmd(tx_buf, "OK", 3000);
}

/**
 * @brief  断线重连(带退避策略)
 */
void ESP8266_Reconnect(void)
{
    if (esp_state == ESP_STATE_MQTT_CONNECTED) return;
    
    uint32_t now = HAL_GetTick();
    /* 重连间隔:至少10秒 */
    if ((now - reconnect_tick) < 10000) return;
    reconnect_tick = now;
    
    ESP8266_Connect();
}

MQTT数据上报频率控制在5秒一次,避免频繁发送导致网络拥塞。告警信息只在非正常状态下上报,减少不必要的流量。断线重连采用10秒间隔,生产环境建议改为指数退避策略。

4.6 主任务调度(FreeRTOS)

系统基于FreeRTOS实现多任务调度,各模块运行在独立任务中,通过队列和信号量进行通信。

main.c(核心任务部分)
c 复制代码
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "adc_collect.h"
#include "sensor_convert.h"
#include "ds18b20.h"
#include "alarm_manager.h"
#include "esp8266_mqtt.h"

/* 任务句柄 */
static TaskHandle_t task_sensor_handle;
static TaskHandle_t task_alarm_handle;
static TaskHandle_t task_mqtt_handle;
static TaskHandle_t task_display_handle;

/* 数据队列:传感器任务 → 告警/MQTT/显示任务 */
static QueueHandle_t sensor_queue;

/**
 * @brief  传感器采集任务(最高优先级)
 *         周期:500ms,负责ADC更新、温度读取、数据转换
 */
void Task_Sensor(void *param)
{
    ADC_Collect_Init();
    Sensor_Convert_Init();
    
    TickType_t last_wake = xTaskGetTickCount();
    
    for (;;)
    {
        /* 更新ADC滤波数据 */
        ADC_Collect_Update();
        
        /* 读取DS18B20温度(阻塞750ms,但在独立任务中不影响其他任务) */
        float temp = DS18B20_ReadTemp();
        
        /* 转换为工程量 */
        Sensor_Convert_Update(temp);
        
        /* 发送数据到队列 */
        Sensor_Data_t *data = Sensor_Convert_GetData();
        xQueueOverwrite(sensor_queue, data);
        
        /* 周期500ms(DS18B20转换已占750ms,实际周期约1s) */
        vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(500));
    }
}

/**
 * @brief  告警处理任务
 *         周期:200ms,负责阈值判断和LED/蜂鸣器驱动
 */
void Task_Alarm(void *param)
{
    Alarm_Manager_Init();
    Sensor_Data_t data;
    
    for (;;)
    {
        if (xQueuePeek(sensor_queue, &data, pdMS_TO_TICKS(100)) == pdTRUE)
        {
            Alarm_Manager_Update(&data);
        }
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

/**
 * @brief  MQTT上报任务
 *         周期:5s,负责数据上报和断线重连
 */
void Task_MQTT(void *param)
{
    ESP8266_Init();
    vTaskDelay(pdMS_TO_TICKS(2000)); // 等待模块启动
    ESP8266_Connect();
    
    Sensor_Data_t data;
    
    for (;;)
    {
        ESP8266_Reconnect(); // 检查并重连
        
        if (xQueuePeek(sensor_queue, &data, pdMS_TO_TICKS(100)) == pdTRUE)
        {
            ESP8266_PublishData(&data);
            
            Alarm_Status_t *alarm = Alarm_Manager_GetStatus();
            if (alarm->overall > ALARM_NORMAL)
            {
                ESP8266_PublishAlarm(alarm, &data);
            }
        }
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

/**
 * @brief  系统初始化与任务创建
 */
int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_DMA_Init();
    MX_ADC1_Init();
    MX_USART1_UART_Init();
    MX_I2C1_Init();
    MX_SPI1_Init();
    
    /* 创建数据队列(深度1,最新数据覆盖) */
    sensor_queue = xQueueCreate(1, sizeof(Sensor_Data_t));
    
    /* 创建任务 */
    xTaskCreate(Task_Sensor,  "Sensor",  256, NULL, 4, &task_sensor_handle);
    xTaskCreate(Task_Alarm,   "Alarm",   128, NULL, 3, &task_alarm_handle);
    xTaskCreate(Task_MQTT,    "MQTT",    512, NULL, 2, &task_mqtt_handle);
    xTaskCreate(Task_Display, "Display", 256, NULL, 1, &task_display_handle);
    
    /* 启动调度器 */
    vTaskStartScheduler();
    
    while (1); // 不应到达这里
}

任务优先级设计:传感器采集(4) > 告警处理(3) > MQTT上报(2) > 显示刷新(1)。传感器数据通过xQueueOverwrite()发送,队列深度为1,始终保持最新数据,避免数据积压。

4.7 PyQt5上位机实现

上位机通过MQTT订阅传感器数据,实现实时曲线显示、历史查询和告警管理。

water_monitor.py
python 复制代码
import sys
import json
import time
from datetime import datetime
from collections import deque

from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
                              QHBoxLayout, QGridLayout, QLabel, QGroupBox,
                              QTableWidget, QTableWidgetItem, QHeaderView,
                              QPushButton, QComboBox, QStatusBar)
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QFont, QColor
import pyqtgraph as pg
import paho.mqtt.client as mqtt

# MQTT配置
MQTT_BROKER = "broker.emqx.io"
MQTT_PORT = 1883
TOPIC_DATA = "waterquality/001/data"
TOPIC_ALARM = "waterquality/001/alarm"
TOPIC_CMD = "waterquality/001/cmd"

# 数据缓冲区大小(保留最近1小时数据,5秒/次 = 720个点)
BUFFER_SIZE = 720


class SensorCard(QGroupBox):
    """单个传感器参数显示卡片"""
    
    def __init__(self, title, unit, normal_range, parent=None):
        super().__init__(title, parent)
        self.unit = unit
        self.normal_range = normal_range
        
        layout = QVBoxLayout()
        
        # 当前值(大字体)
        self.value_label = QLabel("--")
        self.value_label.setFont(QFont("Arial", 36, QFont.Bold))
        self.value_label.setAlignment(Qt.AlignCenter)
        layout.addWidget(self.value_label)
        
        # 单位和范围
        info_label = QLabel(f"{unit}  正常范围: {normal_range}")
        info_label.setAlignment(Qt.AlignCenter)
        info_label.setStyleSheet("color: #666;")
        layout.addWidget(info_label)
        
        # 状态指示
        self.status_label = QLabel("等待数据...")
        self.status_label.setAlignment(Qt.AlignCenter)
        layout.addWidget(self.status_label)
        
        self.setLayout(layout)
        self.setMinimumWidth(200)
    
    def update_value(self, value, alarm_level=0):
        """更新显示值和告警状态"""
        self.value_label.setText(f"{value:.1f}")
        
        colors = {0: "#2ecc71", 1: "#f39c12", 2: "#e74c3c", 3: "#c0392b"}
        texts = {0: "● 正常", 1: "● 预警", 2: "● 报警", 3: "● 危险"}
        
        color = colors.get(alarm_level, "#2ecc71")
        self.value_label.setStyleSheet(f"color: {color};")
        self.status_label.setText(texts.get(alarm_level, "● 正常"))
        self.status_label.setStyleSheet(f"color: {color}; font-weight: bold;")


class WaterMonitorApp(QMainWindow):
    """水质监测上位机主窗口"""
    
    def __init__(self):
        super().__init__()
        self.setWindowTitle("智能水质监测系统 v1.0")
        self.setMinimumSize(1200, 800)
        
        # 数据缓冲区
        self.time_buf = deque(maxlen=BUFFER_SIZE)
        self.ph_buf = deque(maxlen=BUFFER_SIZE)
        self.turbid_buf = deque(maxlen=BUFFER_SIZE)
        self.tds_buf = deque(maxlen=BUFFER_SIZE)
        self.temp_buf = deque(maxlen=BUFFER_SIZE)
        
        # 告警记录
        self.alarm_records = []
        
        self._init_ui()
        self._init_mqtt()
        
        # 定时刷新曲线(1秒)
        self.refresh_timer = QTimer()
        self.refresh_timer.timeout.connect(self._refresh_plots)
        self.refresh_timer.start(1000)
    
    def _init_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QVBoxLayout(central)
        
        # 顶部:四个传感器卡片
        cards_layout = QHBoxLayout()
        self.ph_card = SensorCard("pH值", "pH", "6.5 - 8.5")
        self.turbid_card = SensorCard("浊度", "NTU", "< 50")
        self.tds_card = SensorCard("TDS", "ppm", "200 - 500")
        self.temp_card = SensorCard("水温", "°C", "18 - 28")
        
        cards_layout.addWidget(self.ph_card)
        cards_layout.addWidget(self.turbid_card)
        cards_layout.addWidget(self.tds_card)
        cards_layout.addWidget(self.temp_card)
        main_layout.addLayout(cards_layout)
        
        # 中部:实时曲线(2×2网格)
        plots_group = QGroupBox("实时趋势曲线")
        plots_layout = QGridLayout()
        
        pg.setConfigOptions(antialias=True)
        
        self.ph_plot = pg.PlotWidget(title="pH值")
        self.ph_plot.setYRange(0, 14)
        self.ph_plot.setLabel('left', 'pH')
        self.ph_curve = self.ph_plot.plot(pen=pg.mkPen('#3498db', width=2))
        
        self.turbid_plot = pg.PlotWidget(title="浊度")
        self.turbid_plot.setLabel('left', 'NTU')
        self.turbid_curve = self.turbid_plot.plot(pen=pg.mkPen('#e67e22', width=2))
        
        self.tds_plot = pg.PlotWidget(title="TDS")
        self.tds_plot.setLabel('left', 'ppm')
        self.tds_curve = self.tds_plot.plot(pen=pg.mkPen('#2ecc71', width=2))
        
        self.temp_plot = pg.PlotWidget(title="水温")
        self.temp_plot.setLabel('left', '°C')
        self.temp_curve = self.temp_plot.plot(pen=pg.mkPen('#e74c3c', width=2))
        
        plots_layout.addWidget(self.ph_plot, 0, 0)
        plots_layout.addWidget(self.turbid_plot, 0, 1)
        plots_layout.addWidget(self.tds_plot, 1, 0)
        plots_layout.addWidget(self.temp_plot, 1, 1)
        plots_group.setLayout(plots_layout)
        main_layout.addWidget(plots_group)
        
        # 底部:告警记录表格
        alarm_group = QGroupBox("告警记录")
        alarm_layout = QVBoxLayout()
        
        self.alarm_table = QTableWidget(0, 5)
        self.alarm_table.setHorizontalHeaderLabels(["时间", "级别", "参数", "当前值", "正常范围"])
        self.alarm_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
        alarm_layout.addWidget(self.alarm_table)
        
        alarm_group.setLayout(alarm_layout)
        main_layout.addWidget(alarm_group)
        
        # 状态栏
        self.statusBar().showMessage("正在连接MQTT服务器...")
    
    def _init_mqtt(self):
        self.mqtt_client = mqtt.Client(client_id="wq_monitor_pc")
        self.mqtt_client.on_connect = self._on_mqtt_connect
        self.mqtt_client.on_message = self._on_mqtt_message
        
        try:
            self.mqtt_client.connect_async(MQTT_BROKER, MQTT_PORT, 60)
            self.mqtt_client.loop_start()
        except Exception as e:
            self.statusBar().showMessage(f"MQTT连接失败: {e}")
    
    def _on_mqtt_connect(self, client, userdata, flags, rc):
        if rc == 0:
            client.subscribe([(TOPIC_DATA, 1), (TOPIC_ALARM, 1)])
            self.statusBar().showMessage("MQTT已连接 | 等待数据...")
    
    def _on_mqtt_message(self, client, userdata, msg):
        try:
            payload = json.loads(msg.payload.decode())
            
            if msg.topic == TOPIC_DATA:
                now = time.time()
                self.time_buf.append(now)
                self.ph_buf.append(payload.get("ph", 0))
                self.turbid_buf.append(payload.get("turbidity", 0))
                self.tds_buf.append(payload.get("tds", 0))
                self.temp_buf.append(payload.get("temp", 0))
                
                # 更新卡片显示
                self.ph_card.update_value(payload.get("ph", 0))
                self.turbid_card.update_value(payload.get("turbidity", 0))
                self.tds_card.update_value(payload.get("tds", 0))
                self.temp_card.update_value(payload.get("temp", 0))
                
                count = len(self.time_buf)
                self.statusBar().showMessage(
                    f"MQTT已连接 | 数据点: {count} | "
                    f"最后更新: {datetime.now().strftime('%H:%M:%S')}")
            
            elif msg.topic == TOPIC_ALARM:
                self._add_alarm_record(payload)
                
        except json.JSONDecodeError:
            pass
    
    def _add_alarm_record(self, alarm):
        """添加告警记录到表格"""
        level = alarm.get("level", "unknown")
        now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        
        param_names = {"ph": "pH值", "turb": "浊度", "tds": "TDS", "temp": "水温"}
        ranges = {"ph": "6.5-8.5", "turb": "<50 NTU", "tds": "200-500 ppm", "temp": "18-28°C"}
        
        for key, name in param_names.items():
            info = alarm.get(key, {})
            if info.get("l", 0) > 0:
                row = self.alarm_table.rowCount()
                self.alarm_table.insertRow(row)
                self.alarm_table.setItem(row, 0, QTableWidgetItem(now_str))
                self.alarm_table.setItem(row, 1, QTableWidgetItem(level.upper()))
                self.alarm_table.setItem(row, 2, QTableWidgetItem(name))
                self.alarm_table.setItem(row, 3, QTableWidgetItem(f"{info.get('v', 0):.2f}"))
                self.alarm_table.setItem(row, 4, QTableWidgetItem(ranges.get(key, "")))
                
                # 告警行变色
                color = {"warning": QColor(255, 243, 205), "alert": QColor(255, 205, 205),
                         "danger": QColor(255, 150, 150)}.get(level, QColor(255, 255, 255))
                for col in range(5):
                    self.alarm_table.item(row, col).setBackground(color)
    
    def _refresh_plots(self):
        """刷新实时曲线"""
        if len(self.time_buf) < 2:
            return
        
        t = list(self.time_buf)
        t0 = t[0]
        x = [(ti - t0) for ti in t]  # 相对时间(秒)
        
        self.ph_curve.setData(x, list(self.ph_buf))
        self.turbid_curve.setData(x, list(self.turbid_buf))
        self.tds_curve.setData(x, list(self.tds_buf))
        self.temp_curve.setData(x, list(self.temp_buf))
    
    def closeEvent(self, event):
        self.mqtt_client.loop_stop()
        self.mqtt_client.disconnect()
        event.accept()


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

上位机采用PyQt5 + pyqtgraph实现,四个传感器参数各有独立的数值卡片和趋势曲线。MQTT消息接收在后台线程中运行,通过loop_start()实现非阻塞。数据缓冲区使用deque固定长度,自动丢弃最早的数据点,保持最近1小时的趋势。

五、调试与优化

5.1 常见问题与解决方案
pH传感器读数漂移

现象: pH值在静水中持续缓慢变化,偏差可达±0.5pH。

原因分析:

  • BNC接头接触不良或受潮
  • 探头长时间未校准,电极老化
  • ADC参考电压不稳定

解决方案:

  1. BNC接头涂抹硅脂防潮,确保接触良好
  2. 每周使用标准缓冲液(pH6.86)做单点校准,每月做两点校准
  3. ADC参考电压改用外部基准源(如REF3033),精度从±1%提升到±0.1%
  4. 软件层面增加长时间窗口的基线漂移补偿算法
浊度传感器气泡干扰

现象: 浊度值突然跳变,出现尖峰噪声。

原因分析: 水中气泡经过传感器光路,造成散射光突变。

解决方案:

c 复制代码
/* 中值滤波:去除尖峰噪声,比均值滤波更适合浊度信号 */
static float Median_Filter(float *buf, uint8_t size)
{
    float temp[FILTER_WINDOW_SIZE];
    memcpy(temp, buf, size * sizeof(float));
    
    /* 简单冒泡排序(窗口小,开销可忽略) */
    for (uint8_t i = 0; i < size - 1; i++)
        for (uint8_t j = 0; j < size - i - 1; j++)
            if (temp[j] > temp[j + 1])
            {
                float t = temp[j];
                temp[j] = temp[j + 1];
                temp[j + 1] = t;
            }
    
    return temp[size / 2]; // 取中值
}

建议对浊度通道采用中值滤波替代均值滤波,中值滤波对脉冲噪声的抑制效果远优于均值滤波。

ESP8266连接不稳定

现象: WiFi频繁断连,MQTT发布超时。

解决方案:

  1. ESP8266供电必须独立,加100μF电解电容+100nF陶瓷电容滤波
  2. AT指令交互增加重试机制,每条指令最多重试3次
  3. MQTT心跳间隔设为60秒(keepalive=60),Broker端设为90秒
  4. 启用MQTT遗嘱消息(LWT),设备离线时自动通知上位机
c 复制代码
/* 遗嘱消息配置 */
snprintf(tx_buf, sizeof(tx_buf),
    "AT+MQTTUSERCFG=0,1,\"%s\",\"%s\",\"%s\",0,0,\"\"",
    MQTT_CLIENT_ID, MQTT_USERNAME, MQTT_PASSWORD);
AT_SendCmd(tx_buf, "OK", 2000);

/* 设置遗嘱主题和消息 */
snprintf(tx_buf, sizeof(tx_buf),
    "AT+MQTTCONNCFG=0,60,0,\"%s\",\"{\\\"status\\\":\\\"offline\\\"}\",1,0",
    TOPIC_STATUS);
AT_SendCmd(tx_buf, "OK", 2000);
5.2 性能优化
低功耗设计

对于电池供电的野外监测场景,可以通过以下策略降低功耗:

c 复制代码
/**
 * @brief  低功耗采集模式
 *         正常模式:5秒采集一次,WiFi常连
 *         省电模式:60秒采集一次,WiFi按需连接
 *         休眠模式:300秒采集一次,WiFi关闭,数据存SD卡
 */
typedef enum {
    POWER_MODE_NORMAL = 0,  // 功耗约120mA
    POWER_MODE_SAVING = 1,  // 功耗约40mA(平均)
    POWER_MODE_SLEEP  = 2   // 功耗约5mA(平均)
} Power_Mode_t;

void Power_EnterSleep(uint32_t seconds)
{
    /* 关闭ESP8266 */
    AT_SendCmd("AT+GSLP=0", "OK", 1000); // ESP8266深度睡眠
    
    /* 关闭不需要的外设时钟 */
    __HAL_RCC_ADC1_CLK_DISABLE();
    __HAL_RCC_SPI1_CLK_DISABLE();
    __HAL_RCC_I2C1_CLK_DISABLE();
    
    /* 配置RTC闹钟唤醒 */
    HAL_RTCEx_SetWakeUpTimer_IT(&hrtc, seconds, RTC_WAKEUPCLOCK_CK_SPRE_16BITS);
    
    /* 进入STOP模式 */
    HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);
    
    /* 唤醒后重新配置时钟 */
    SystemClock_Config();
    __HAL_RCC_ADC1_CLK_ENABLE();
    __HAL_RCC_SPI1_CLK_ENABLE();
    __HAL_RCC_I2C1_CLK_ENABLE();
}
数据压缩上报

当网络带宽有限时,可以对上报数据进行差值压缩:只有当参数变化超过设定阈值时才上报,减少70%以上的MQTT消息量。

c 复制代码
/* 差值压缩:只上报变化超过阈值的数据 */
static Sensor_Data_t last_reported;
static uint8_t first_report = 1;

uint8_t Should_Report(Sensor_Data_t *current)
{
    if (first_report) { first_report = 0; return 1; }
    
    /* 任一参数变化超过阈值则上报 */
    if (fabsf(current->ph - last_reported.ph) > 0.1f) return 1;
    if (fabsf(current->turbidity - last_reported.turbidity) > 5.0f) return 1;
    if (fabsf(current->tds - last_reported.tds) > 10.0f) return 1;
    if (fabsf(current->temperature - last_reported.temperature) > 0.5f) return 1;
    
    return 0; // 变化不大,不上报
}

六、项目总结与扩展

6.1 项目成果

本项目实现了一套完整的智能水质监测系统,涵盖从传感器采集到云端可视化的全链路设计:

  • 四参数实时监测:pH、浊度、TDS、水温,采样周期1秒,数据精度满足养殖和水处理场景需求
  • 多级阈值预警:三级告警机制(预警/报警/危险),带滞回控制,避免误报
  • 远程数据上报:基于MQTT协议,5秒上报周期,支持断线重连和遗嘱消息
  • 桌面端可视化:PyQt5上位机实现实时曲线、告警记录、历史查询
  • 传感器校准:两点校准法,校准参数Flash持久化,支持远程校准指令
6.2 关键技术点
技术点 实现方案 设计考量
ADC多通道采集 DMA连续转换+滑动平均滤波 减少CPU占用,抑制传感器噪声
pH温度补偿 Nernst方程温度修正 消除温度对电极电位的影响
TDS温度补偿 2%/°C线性补偿系数 归一化到25°C标准条件
阈值告警 滞回控制+多级判断 防止阈值附近频繁切换
校准存储 Flash末页+魔数校验 掉电不丢失,防误读
任务调度 FreeRTOS优先级抢占 采集>告警>通信>显示
数据上报 MQTT QoS1+JSON封装 至少一次送达,格式通用
低功耗 STOP模式+按需WiFi 电池场景续航优化
6.3 扩展方向
  1. 增加传感器: 溶解氧(DO)、氨氮、余氯等参数,扩展为全面水质分析仪
  2. 边缘AI: 在STM32H7或ESP32-S3上部署TensorFlow Lite模型,实现水质趋势预测和异常检测
  3. 多节点组网: 多个监测点通过LoRa/NB-IoT组网,实现大范围水域监测
  4. 移动端: 开发微信小程序或App,实现手机端实时查看和告警推送
  5. 数据分析: 接入时序数据库(InfluxDB),结合Grafana实现长期趋势分析和报表生成

如果这篇文章对您有帮助,请点赞支持!如有问题或建议,欢迎在评论区交流。关注我获取更多嵌入式物联网项目实战内容。

相关推荐
电化学仪器白超1 小时前
EC20CEHDLG-128-SNNS调试记录
python·单片机·嵌入式硬件·自动化
2501_918126911 小时前
stm32最级别的烧录解锁是什么?
stm32·单片机·嵌入式硬件·学习·个人开发
Once_day1 小时前
GCC编译(7)链接脚本LinkerScripts
c语言·c++·编译和链接·程序员自我修养
Volunteer Technology1 小时前
JVM之性能优化
jvm·python·性能优化
qq_241585611 小时前
jump_to_app
单片机·嵌入式硬件
你好,奋斗者!2 小时前
74HC595芯片原理及代码示例
嵌入式硬件·软件·电路设计
Qy_cm2 小时前
DAY0:3个基础概念——参数、梯度、训练的本质
python
云司科技codebuddy2 小时前
技术支持过硬Trae核心代理
大数据·运维·python·微服务
小刘爱玩单片机2 小时前
【stm32简单外设篇】- KY-025 干簧管(磁控)模块
c语言·stm32·单片机·嵌入式硬件