一、前言

1.1 项目开发背景

随着工业化进程不断推进以及城市建设规模持续扩大,河流、水库及其他自然水体面临着越来越复杂的污染问题。生活污水排放、工业废水排放、农业面源污染以及自然环境变化等因素,使水环境质量监测的重要性日益提升。河流水质变化不仅直接影响生态环境平衡,还关系到居民生活用水安全、农业灌溉质量以及区域可持续发展。因此,建立一套稳定、高效、可远程管理的水质监测系统,已成为当前智慧环保与智慧水务建设的重要研究方向。

传统河流水质监测通常采用人工采样后送实验室检测的方式完成,该方法虽然检测精度较高,但存在采样周期长、实时性差、人工成本高、监测范围有限等问题,难以满足长期连续监测和快速预警需求。特别是在户外河流、偏远区域以及多监测点场景下,传统方式无法实现全天候自动化采集与远程管理。因此,需要借助物联网、无线通信、嵌入式控制以及云平台技术,实现对水质参数的自动采集、远程传输和集中分析。

近年来,物联网技术与低功耗嵌入式设备快速发展,为水环境监测提供了新的技术路径。以ESP32系列芯片为代表的嵌入式控制平台,具备较强的数据处理能力、丰富的通信接口以及较低的开发成本,可满足现场数据采集与网络通信需求。同时,MQTT协议凭借轻量化、高可靠性特点,被广泛应用于物联网设备的数据传输场景,使监测终端能够稳定接入云端,实现远程数据管理和状态监控。结合云平台的数据存储与可视化能力,可以有效提升水质监测系统的实时性和智能化水平。

基于此,本项目设计了一种基于ESP32的河流水质检测装置,通过集成水温、PH、电导率以及浊度等关键水质参数检测模块,实现对河流水体状态的实时采集与分析。系统采用本地显示与远程通信相结合的方式,现场通过显示屏展示实时数据,同时利用WiFi与4G双网络链路将监测信息上传至华为云物联网平台,实现数据集中管理与远程访问。用户可通过微信小程序、Android移动端以及Windows上位机查看不同监测节点的数据状态,提高监测效率和信息获取便捷性。

考虑到河流监测场景通常处于长期无人值守环境,本系统进一步引入太阳能供电方案,提高设备在户外部署条件下的持续运行能力,降低维护频率与人工巡检成本。同时支持多节点设备协同监测,实现对不同区域水体状态的统一管理,为智慧环保、水资源保护及生态环境治理提供一种低成本、可扩展、智能化的技术方案。

image-20260621204132557

image-20260621204152445

蓝色系统架构图

1.2 设计实现的功能

(1)支持水温检测功能
系统采用防水型DS18B20温度传感器对河流水体温度进行实时采集。ESP32-S3周期性读取传感器数据,并完成温度值解析与处理,实现对当前水温状态的连续监测,为后续水质分析提供基础参数支持。

(2)支持PH值检测功能
系统集成PH检测模块,通过模拟量方式输出检测信号,由ESP32-S3内部ADC模块完成数据采集,并经过转换计算得到当前水体PH值,实现对河流水体酸碱度状态的实时监测。

(3)支持电导率检测功能
系统采用TDS电导率检测传感器获取水体导电能力参数,通过ADC进行模拟信号采样,结合换算算法得到电导率检测结果,用于反映水体中溶解性物质变化情况,实现对水质状态的辅助分析。

(4)支持浊度检测功能
系统采用浊度检测传感器对河流水体透明程度进行检测,通过采集模拟输出信号并进行数值转换,获得浊度数据,实现对水体悬浮颗粒变化情况的实时监测。

(5)支持本地显示功能
系统配置显示模块,对采集到的水温、PH、电导率以及浊度数据进行实时显示。用户可直接通过设备端查看当前监测结果,提高现场运维和状态查看效率。

(6)支持数据上云功能
系统具备远程通信能力,采集到的全部监测数据可通过WiFi网络和4G网络两种通信方式进行上传。设备基于MQTT协议与华为云物联网服务器建立连接,实现数据远程传输、云端存储及统一管理。

(7)支持微信小程序访问功能
系统提供微信小程序访问能力,用户可通过手机直接查看云端水质数据。小程序采用官方开发工具及TS模板开发,从华为云平台获取实时监测信息,实现不同设备用户快速访问和水质状态展示。

(8)支持太阳能供电功能
系统采用太阳能供电方案,通过太阳能采集与储能方式为设备持续供电,使设备能够长期部署于户外河流环境中,减少人工维护频率,提高系统运行连续性。

(9)支持多节点水质监测功能
系统支持多个河流水质监测节点同时运行,各节点可独立完成数据采集与上传。用户可通过微信小程序选择不同设备节点进行查看,实现多区域水质数据集中管理与远程监测。

1.3 项目硬件模块组成

(1)ESP32-S3主控模块
系统核心控制单元采用ESP32-S3作为主控制器,负责完成各类传感器数据采集、数据处理、显示控制、通信管理以及云平台交互等任务。ESP32-S3内部集成无线通信能力,并具备丰富的GPIO、ADC、UART、IIC等接口资源,可满足多传感器接入及系统扩展需求。

(2)水温检测模块
采用防水型DS18B20数字温度传感器完成河流水温采集。传感器通过单总线通信方式与ESP32-S3连接,实现实时获取水体温度数据,并作为水质状态分析的重要参考参数。

(3)PH检测模块
系统采用PH传感器完成河流水体酸碱度检测。传感器输出模拟电压信号,经ESP32-S3内部ADC模块采集后进行数据转换与计算,得到当前PH检测结果。

(4)电导率检测模块
采用TDS电导率检测传感器对水体导电能力进行测量。传感器输出模拟信号,通过ESP32-S3完成ADC采样和数据换算,实现电导率数据获取。

(5)浊度检测模块
系统采用浊度检测传感器实现水体浑浊程度检测。检测模块输出模拟量,经主控芯片采集处理后获得浊度结果,用于反映水体中悬浮颗粒变化情况。

(6)显示模块
显示部分采用0.96寸OLED显示屏,并通过IIC通信接口与ESP32-S3连接,用于本地显示水温、PH值、电导率、浊度以及设备运行状态等信息,实现现场数据查看。

(7)WiFi通信模块
系统利用ESP32-S3内部集成WiFi功能完成网络通信,实现监测数据上传至物联网平台,同时支持远程访问和数据同步。

(8)4G无线通信模块
系统采用Air780E模块实现蜂窝网络通信。当现场无法使用普通无线网络时,可通过4G网络完成监测数据上传,保证设备在户外环境下的远程联网能力。模块通过串口与ESP32-S3进行通信。

(9)蜂鸣器提示模块
系统采用高电平触发有源蜂鸣器,用于设备运行过程中的状态提示和报警提醒,通过主控输出控制信号实现蜂鸣器驱动。

(10)太阳能供电模块
系统采用太阳能供电方式,通过太阳能板及充放电管理电路为设备提供持续能源支持,实现户外长期独立运行,解决河流监测场景下供电困难问题。

1.4 系统框架图

image-20260621202403348

1.5 运行流程图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

二、部署华为云物联网平台

华为云官网: https://www.huaweicloud.com/

打开官网,搜索物联网,就能快速找到 设备接入IoTDA

image-20221204193824815

2.1 物联网平台介绍

华为云物联网平台(IoT 设备接入云服务)提供海量设备的接入和管理能力,将物理设备联接到云,支撑设备数据采集上云和云端下发命令给设备进行远程控制,配合华为云其他产品,帮助我们快速构筑物联网解决方案。

使用物联网平台构建一个完整的物联网解决方案主要包括3部分:物联网平台、业务应用和设备。

物联网平台作为连接业务应用和设备的中间层,屏蔽了各种复杂的设备接口,实现设备的快速接入;同时提供强大的开放能力,支撑行业用户构建各种物联网解决方案。

设备可以通过固网、2G/3G/4G/5G、NB-IoT、Wifi等多种网络接入物联网平台,并使用LWM2M/CoAP、MQTT、HTTPS协议将业务数据上报到平台,平台也可以将控制命令下发给设备。

业务应用通过调用物联网平台提供的API,实现设备数据采集、命令下发、设备管理等业务场景。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

2.2 开通物联网服务

地址: https://www.huaweicloud.com/product/iothub.html

image-20241028135834377

开通免费单元。

image-20241028135935457

点击立即创建

image-20240117134653452

正在创建标准版实例,需要等待片刻。

image-20241028140048811

创建完成之后,点击详情。 可以看到标准版实例的设备接入端口和地址。

image-20241028140129102

下面框起来的就是端口号域名

image-20241028140229696

点击实例名称,可以查看当前免费单元的配置情况。

image-20241028140331523

image-20241028140428663

开通之后,点击接入信息,也能查看接入信息。 我们当前设备准备采用MQTT协议接入华为云平台,这里可以看到MQTT协议的地址和端口号等信息。

image-20241028140511105

总结:

端口号:   MQTT (1883)| MQTTS (8883)    
接入地址: dab1a1f2c6.st1.iotda-device.cn-north-4.myhuaweicloud.com

根据域名地址得到IP地址信息:

打开Windows电脑的命令行控制台终端,使用ping 命令。ping一下即可。

Microsoft Windows [版本 10.0.19045.5011]
(c) Microsoft Corporation。保留所有权利。

C:\Users\Lenovo>ping dab1a1f2c6.st1.iotda-device.cn-north-4.myhuaweicloud.com

正在 Ping dab1a1f2c6.st1.iotda-device.cn-north-4.myhuaweicloud.com [117.78.5.125] 具有 32 字节的数据:
来自 117.78.5.125 的回复: 字节=32 时间=37ms TTL=44
来自 117.78.5.125 的回复: 字节=32 时间=37ms TTL=44
来自 117.78.5.125 的回复: 字节=32 时间=37ms TTL=44
来自 117.78.5.125 的回复: 字节=32 时间=37ms TTL=44

117.78.5.125 的 Ping 统计信息:
    数据包: 已发送 = 4,已接收 = 4,丢失 = 0 (0% 丢失),
往返行程的估计时间(以毫秒为单位):
    最短 = 37ms,最长 = 37ms,平均 = 37ms

C:\Users\Lenovo>

MQTT协议接入端口号有两个,1883是非加密端口,8883是证书加密端口,单片机无法加载证书,所以使用1883端口合适

2.3 创建产品

链接:https://console.huaweicloud.com/iotdm/?region=cn-north-4#/dm-dev/all-product?instanceId=03c5c68c-e588-458c-90c3-9e4c640be7af

(1)创建产品

image-20241028141601305

(2)填写产品信息

根据自己产品名字填写,下面的设备类型选择自定义类型。

image-20240612094809689

(3)产品创建成功

image-20240612095148945

创建完成之后点击查看详情。

image-20240612095134263

(4)添加自定义模型

产品创建完成之后,点击进入产品详情页面,翻到最下面可以看到模型定义。

模型简单来说: 就是存放设备上传到云平台的数据。

你可以根据自己的产品进行创建。

比如:

烟雾可以叫  MQ2
温度可以叫  Temperature
湿度可以叫  humidity
火焰可以叫  flame
其他的传感器自己用单词简写命名即可。 这就是你的单片机设备端上传到服务器的数据名字。

先点击自定义模型。

image-20240612095517900

再创建一个服务ID。

image-20240612095542749

接着点击新增属性。

image-20240612095648815

image-20240612095711898

2.4 添加设备

产品是属于上层的抽象模型,接下来在产品模型下添加实际的设备。添加的设备最终需要与真实的设备关联在一起,完成数据交互。

(1)注册设备

image-20240425181935561

(2)根据自己的设备填写

image-20240612100115167

(3)保存设备信息

创建完毕之后,点击保存并关闭,得到创建的设备密匙信息。该信息在后续生成MQTT三元组的时候需要使用。

image-20240612100128061

(4)设备创建完成

image-20240612100147232

(5)设备详情

image-20240612100202960

image-20240612100217236

2.5 MQTT协议主题订阅与发布

(1)MQTT协议介绍

当前的设备是采用MQTT协议与华为云平台进行通信。

MQTT是一个物联网传输协议,它被设计用于轻量级的发布/订阅式消息传输,旨在为低带宽和不稳定的网络环境中的物联网设备提供可靠的网络服务。MQTT是专门针对物联网开发的轻量级传输协议。MQTT协议针对低带宽网络,低计算能力的设备,做了特殊的优化,使得其能适应各种物联网应用场景。目前MQTT拥有各种平台和设备上的客户端,已经形成了初步的生态系统。

MQTT是一种消息队列协议,使用发布/订阅消息模式,提供一对多的消息发布,解除应用程序耦合,相对于其他协议,开发更简单;MQTT协议是工作在TCP/IP协议上;由TCP/IP协议提供稳定的网络连接;所以,只要具备TCP协议栈的网络设备都可以使用MQTT协议。 本次设备采用的ESP8266就具备TCP协议栈,能够建立TCP连接,所以,配合STM32代码里封装的MQTT协议,就可以与华为云平台完成通信。

华为云的MQTT协议接入帮助文档在这里: https://support.huaweicloud.com/devg-iothub/iot_02_2200.html

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

业务流程:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

(2)华为云平台MQTT协议使用限制

描述 限制
支持的MQTT协议版本 3.1.1
与标准MQTT协议的区别 支持Qos 0和Qos 1支持Topic自定义不支持QoS2不支持will、retain msg
MQTTS支持的安全等级 采用TCP通道基础 + TLS协议(最高TLSv1.3版本)
单帐号每秒最大MQTT连接请求数 无限制
单个设备每分钟支持的最大MQTT连接数 1
单个MQTT连接每秒的吞吐量,即带宽,包含直连设备和网关 3KB/s
MQTT单个发布消息最大长度,超过此大小的发布请求将被直接拒绝 1MB
MQTT连接心跳时间建议值 心跳时间限定为30至1200秒,推荐设置为120秒
产品是否支持自定义Topic 支持
消息发布与订阅 设备只能对自己的Topic进行消息发布与订阅
每个订阅请求的最大订阅数 无限制

(3)主题订阅格式

帮助文档地址:https://support.huaweicloud.com/devg-iothub/iot_02_2200.html

image-20221207153310037

对于设备而言,一般会订阅平台下发消息给设备 这个主题。

设备想接收平台下发的消息,就需要订阅平台下发消息给设备 的主题,订阅后,平台下发消息给设备,设备就会收到消息。

如果设备想要知道平台下发的消息,需要订阅上面图片里标注的主题。

以当前设备为例,最终订阅主题的格式如下:
$oc/devices/{device_id}/sys/messages/down
    
最终的格式:
$oc/devices/663cb18871d845632a0912e7_dev1/sys/messages/down

(4)主题发布格式

对于设备来说,主题发布表示向云平台上传数据,将最新的传感器数据,设备状态上传到云平台。

这个操作称为:属性上报。

帮助文档地址:https://support.huaweicloud.com/usermanual-iothub/iot_06_v5_3010.html

image-20221207153637391

根据帮助文档的介绍, 当前设备发布主题,上报属性的格式总结如下:

发布的主题格式:
$oc/devices/{device_id}/sys/properties/report
 
最终的格式:
$oc/devices/663cb18871d845632a0912e7_dev1/sys/properties/report
发布主题时,需要上传数据,这个数据格式是JSON格式。

上传的JSON数据格式如下:

{
  "services": [
    {
      "service_id": <填服务ID>,
      "properties": {
        "<填属性名称1>": <填属性值>,
        "<填属性名称2>": <填属性值>,
        ..........
      }
    }
  ]
}
根据JSON格式,一次可以上传多个属性字段。 这个JSON格式里的,服务ID,属性字段名称,属性值类型,在前面创建产品的时候就已经介绍了,不记得可以翻到前面去查看。

根据这个格式,组合一次上传的属性数据:
{"services": [{"service_id": "stm32","properties":{"你的字段名字1":30,"你的字段名字2":10,"你的字段名字3":1,"你的字段名字4":0}}]}

2.6 MQTT三元组

MQTT协议登录需要填用户ID,设备ID,设备密码等信息,就像我们平时登录QQ,微信一样要输入账号密码才能登录。MQTT协议登录的这3个参数,一般称为MQTT三元组。

接下来介绍,华为云平台的MQTT三元组参数如何得到。

(1)MQTT服务器地址

要登录MQTT服务器,首先记得先知道服务器的地址是多少,端口是多少。

帮助文档地址:https://console.huaweicloud.com/iotdm/?region=cn-north-4#/dm-portal/home

image-20240509193207359

MQTT协议的端口支持1883和8883,它们的区别是:8883 是加密端口更加安全。但是单片机上使用比较困难,所以当前的设备是采用1883端口进连接的。

根据上面的域名和端口号,得到下面的IP地址和端口号信息: 如果设备支持填写域名可以直接填域名,不支持就直接填写IP地址。 (IP地址就是域名解析得到的)

华为云的MQTT服务器地址:117.78.5.125
华为云的MQTT端口号:1883

如何得到IP地址?如何域名转IP? 打开Windows的命令行输入以下命令。

ping  ad635970a1.st1.iotda-device.cn-north-4.myhuaweicloud.com

image-20240425182610048

(2)生成MQTT三元组

华为云提供了一个在线工具,用来生成MQTT鉴权三元组: https://iot-tool.obs-website.cn-north-4.myhuaweicloud.com/

打开这个工具,填入设备的信息(也就是刚才创建完设备之后保存的信息),点击生成,就可以得到MQTT的登录信息了。

下面是打开的页面:

image-20240425183025893

填入设备的信息: (上面两行就是设备创建完成之后保存得到的)

直接得到三元组信息。

image-20240509193310020

得到三元组之后,设备端通过MQTT协议登录鉴权的时候,填入参数即可。

ClientId  663cb18871d845632a0912e7_dev1_0_0_2024050911
Username  663cb18871d845632a0912e7_dev1
Password  71b82deae83e80f04c4269b5bbce3b2fc7c13f610948fe210ce18650909ac237

2.7 模拟设备登录测试

经过上面的步骤介绍,已经创建了产品,设备,数据模型,得到MQTT登录信息。 接下来就用MQTT客户端软件模拟真实的设备来登录平台。测试与服务器通信是否正常。

MQTT软件下载地址【免费】: https://download.csdn.net/download/xiaolong1126626497/89928772

(1)填入登录信息

打开MQTT客户端软件,对号填入相关信息(就是上面的文本介绍)。然后,点击登录,订阅主题,发布主题。

image-20240509193457358

(2)打开网页查看

完成上面的操作之后,打开华为云网页后台,可以看到设备已经在线了。

image-20240612100508790

点击详情页面,可以看到上传的数据:

image-20240612100529581

到此,云平台的部署已经完成,设备已经可以正常上传数据了。

(3)MQTT登录测试参数总结

MQTT服务器:  117.78.5.125
MQTT端口号:  183

//物联网服务器的设备信息
#define MQTT_ClientID "663cb18871d845632a0912e7_dev1_0_0_2024050911"
#define MQTT_UserName "663cb18871d845632a0912e7_dev1"
#define MQTT_PassWord "71b82deae83e80f04c4269b5bbce3b2fc7c13f610948fe210ce18650909ac237"

//订阅与发布的主题
#define SET_TOPIC  "$oc/devices/663cb18871d845632a0912e7_dev1/sys/messages/down"  //订阅
#define POST_TOPIC "$oc/devices/663cb18871d845632a0912e7_dev1/sys/properties/report"  //发布


发布的数据:
{"services": [{"service_id": "stm32","properties":{"你的字段名字1":30,"你的字段名字2":10,"你的字段名字3":1,"你的字段名字4":0}}]}

2.8 创建IAM账户

创建一个IAM账户,因为接下来开发上位机,需要使用云平台的API接口,这些接口都需要token进行鉴权。简单来说,就是身份的认证。 调用接口获取Token时,就需要填写IAM账号信息。所以,接下来演示一下过程。

地址: https://console.huaweicloud.com/iam/?region=cn-north-4#/iam/users

**【1】获取项目凭证 ** 点击左上角用户名,选择下拉菜单里的我的凭证

image-20240509193646253

image-20240509193701262

项目凭证:

28add376c01e4a61ac8b621c714bf459

【2】创建IAM用户

鼠标放在左上角头像上,在下拉菜单里选择统一身份认证

image-20240509193729078

点击左上角创建用户

image-20240509193744287

image-20240314153208692

image-20240314153228359

image-20240314153258229

创建成功:

image-20240314153315444

【3】创建完成

image-20240509193828289

用户信息如下:

主用户名  l19504562721
IAM用户  ds_abc
密码     DS12345678

2.9 获取影子数据

帮助文档:https://support.huaweicloud.com/api-iothub/iot_06_v5_0079.html

设备影子介绍:

设备影子是一个用于存储和检索设备当前状态信息的JSON文档。
每个设备有且只有一个设备影子,由设备ID唯一标识
设备影子仅保存最近一次设备的上报数据和预期数据
无论该设备是否在线,都可以通过该影子获取和设置设备的属性

简单来说:设备影子就是保存,设备最新上传的一次数据。

我们设计的软件里,如果想要获取设备的最新状态信息,就采用设备影子接口。

如果对接口不熟悉,可以先进行在线调试:https://apiexplorer.developer.huaweicloud.com/apiexplorer/doc?product=IoTDA&api=ShowDeviceShadow

在线调试接口,可以请求影子接口,了解请求,与返回的数据格式。

调试完成看右下角的响应体,就是返回的影子数据。

image-20240509194152229

设备影子接口返回的数据如下:

{
 "device_id": "663cb18871d845632a0912e7_dev1",
 "shadow": [
  {
   "service_id": "stm32",
   "desired": {
    "properties": null,
    "event_time": null
   },
   "reported": {
    "properties": {
     "DHT11_T": 18,
     "DHT11_H": 90,
     "BH1750": 38,
     "MQ135": 70
    },
    "event_time": "20240509T113448Z"
   },
   "version": 3
  }
 ]
}

调试成功之后,可以得到访问影子数据的真实链接,接下来的代码开发中,就采用Qt写代码访问此链接,获取影子数据,完成上位机开发。

image-20240509194214716

链接如下:

https://ad635970a1.st1.iotda-app.cn-north-4.myhuaweicloud.com:443/v5/iot/28add376c01e4a61ac8b621c714bf459/devices/663cb18871d845632a0912e7_dev1/shadow

三、上位机开发

3.1 Qt开发环境安装

Qt的中文官网: https://www.qt.io/zh-cn/image-20221207160550486

image-20221207160606892

QT5.12.6的下载地址:https://download.qt.io/archive/qt/5.12/5.12.6

打开下载链接后选择下面的版本进行下载:

如果下载不了,可以在网盘里找到安装包下载: 飞书文档记录的网盘地址:https://ccnr8sukk85n.feishu.cn/wiki/QjY8weDYHibqRYkFP2qcA9aGnvb?from=from_copylink

软件安装时断网安装,否则会提示输入账户。

安装的时候,第一个复选框里的编译器可以全选,直接点击下一步继续安装。

image-20221203151742653

选择编译器: (一定要看清楚了)

image-20241028152725134

3.2 新建上位机工程

前面2讲解了需要用的API接口,接下来就使用Qt设计上位机,设计界面,完成整体上位机的逻辑设计。

【1】新建工程

image-20240117144052547

【2】设置项目的名称。

image-20250420200347498

【3】选择编译系统

image-20240117144239681

【4】选择默认继承的类

image-20240117144302275

【5】选择编译器

image-20241028153603487

【6】点击完成

image-20240117144354252

【7】工程创建完成

image-20250420200411303

3.3 切换编译器

在左下角是可以切换编译器的。 可以选择用什么样的编译器编译程序。

目前新建工程的时候选择了2种编译器。 一种是mingw32这个编译Windows下运行的程序。 一种是Android编译器,可以生成Android手机APP。

不过要注意:Android的编译器需要配置一些环境才可以正常使用,这个大家可以网上找找教程配置一下就行了。

windows的编译器就没有这么麻烦,安装好Qt就可以编译使用。

下面我这里就选择的 mingw32这个编译器,编译Windows下运行的程序。

image-20250420200424965

3.4 编译测试功能

创建完毕之后,编译测试一下功能是否OK。

点击左下角的绿色三角形按钮

image-20250420200442769

正常运行就可以看到弹出一个白色的框框。这就表示工程环境没有问题了。 接下来就可以放心的设计界面了。

image-20250420200457319

3.5 设计UI界面与工程配置

【1】打开UI文件

image-20250420200514220

打开默认的界面如下:

image-20250420200526194

【2】开始设计界面

根据自己需求设计界面。

以下是完整的Qt UI文件(widget.ui),采用XML格式描述,与之前的widget.cpp代码配套使用。

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>Widget</class>
 <widget class="QWidget" name="Widget">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>900</width>
    <height>700</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>河流水质监测系统 - Qt上位机</string>
  </property>
  <property name="styleSheet">
   <string notr="true">
    QWidget {
        background-color: #f5f5f5;
        font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
    }
    QGroupBox {
        font-weight: bold;
        border: 1px solid #cccccc;
        border-radius: 6px;
        margin-top: 1ex;
        padding-top: 10px;
        background-color: white;
    }
    QGroupBox::title {
        subcontrol-origin: margin;
        left: 10px;
        padding: 0 5px 0 5px;
        background-color: white;
    }
    QLabel {
        color: #333333;
    }
    QLabel[valueLabel="true"] {
        font-size: 18px;
        font-weight: bold;
        color: #2196F3;
        background-color: #e3f2fd;
        padding: 5px 10px;
        border-radius: 4px;
    }
    QLabel[valueLabel="true"][unit="°C"] {
        color: #f44336;
        background-color: #ffebee;
    }
    QLabel[valueLabel="true"][unit="pH"] {
        color: #4CAF50;
        background-color: #e8f5e9;
    }
    QLabel[valueLabel="true"][unit="ppm"] {
        color: #FF9800;
        background-color: #fff3e0;
    }
    QLabel[valueLabel="true"][unit="NTU"] {
        color: #9C27B0;
        background-color: #f3e5f5;
    }
    QLabel[valueLabel="true"][unit="V"] {
        color: #607D8B;
        background-color: #eceff1;
    }
    QPushButton {
        background-color: #2196F3;
        color: white;
        border: none;
        padding: 8px 16px;
        border-radius: 4px;
        font-weight: bold;
        font-size: 12px;
    }
    QPushButton:hover {
        background-color: #1976D2;
    }
    QPushButton:pressed {
        background-color: #0D47A1;
    }
    QPushButton#disconnectButton {
        background-color: #f44336;
    }
    QPushButton#disconnectButton:hover {
        background-color: #d32f2f;
    }
    QPushButton#exportButton {
        background-color: #4CAF50;
    }
    QPushButton#exportButton:hover {
        background-color: #388E3C;
    }
    QPushButton#clearButton {
        background-color: #FF9800;
    }
    QPushButton#clearButton:hover {
        background-color: #F57C00;
    }
    QLineEdit, QComboBox {
        padding: 5px;
        border: 1px solid #cccccc;
        border-radius: 4px;
        background-color: white;
    }
    QLineEdit:focus, QComboBox:focus {
        border-color: #2196F3;
    }
    QComboBox {
        min-width: 120px;
    }
    QTabWidget::pane {
        border: 1px solid #cccccc;
        border-radius: 4px;
        background-color: white;
    }
    QTabBar::tab {
        background-color: #e0e0e0;
        padding: 8px 16px;
        margin-right: 2px;
        border-top-left-radius: 4px;
        border-top-right-radius: 4px;
    }
    QTabBar::tab:selected {
        background-color: #2196F3;
        color: white;
    }
    QTabBar::tab:hover:!selected {
        background-color: #bbdefb;
    }
    QStatusBar {
        background-color: #eeeeee;
        color: #333333;
    }
   </string>
  </property>

  <!-- 主布局 -->
  <layout class="QVBoxLayout" name="mainLayout">
   <property name="spacing">
    <number>10</number>
   </property>
   <property name="leftMargin">
    <number>10</number>
   </property>
   <property name="topMargin">
    <number>10</number>
   </property>
   <property name="rightMargin">
    <number>10</number>
   </property>
   <property name="bottomMargin">
    <number>10</number>
   </property>

   <!-- 顶部:标题和状态栏 -->
   <widget class="QWidget" name="topWidget" native="true">
    <layout class="QHBoxLayout" name="topLayout">
     <property name="spacing">
      <number>10</number>
     </property>
     <item>
      <widget class="QLabel" name="titleLabel">
       <property name="font">
        <font>
         <pointsize>18</pointsize>
         <weight>75</weight>
         <bold>true</bold>
        </font>
       </property>
       <property name="text">
        <string>🌊 河流水质监测系统</string>
       </property>
      </widget>
     </item>
     <item>
      <spacer name="topSpacer">
       <property name="orientation">
        <enum>Qt::Horizontal</enum>
       </property>
       <property name="sizeHint" stdset="0">
        <size>
         <width>40</width>
         <height>20</height>
        </size>
       </property>
      </spacer>
     </item>
     <item>
      <widget class="QLabel" name="currentTimeLabel">
       <property name="font">
        <font>
         <pointsize>12</pointsize>
        </font>
       </property>
       <property name="text">
        <string>2024-01-01 00:00:00</string>
       </property>
       <property name="alignment">
        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
       </property>
      </widget>
     </item>
    </layout>
   </widget>

   <!-- 连接配置区域 -->
   <widget class="QGroupBox" name="connectionGroupBox">
    <property name="title">
     <string>📡 MQTT 连接配置</string>
    </property>
    <layout class="QGridLayout" name="connectionLayout">
     <property name="horizontalSpacing">
      <number>10</number>
     </property>
     <property name="verticalSpacing">
      <number>8</number>
     </property>
     <item row="0" column="0">
      <widget class="QLabel" name="brokerLabel">
       <property name="text">
        <string>Broker地址:</string>
       </property>
      </widget>
     </item>
     <item row="0" column="1">
      <widget class="QLineEdit" name="brokerLineEdit">
       <property name="placeholderText">
        <string>mqtt://broker.example.com:1883</string>
       </property>
      </widget>
     </item>
     <item row="0" column="2">
      <widget class="QLabel" name="clientIdLabel">
       <property name="text">
        <string>Client ID:</string>
       </property>
      </widget>
     </item>
     <item row="0" column="3">
      <widget class="QLineEdit" name="clientIdLineEdit">
       <property name="placeholderText">
        <string>Qt-Client-001</string>
       </property>
      </widget>
     </item>

     <item row="1" column="0">
      <widget class="QLabel" name="usernameLabel">
       <property name="text">
        <string>用户名:</string>
       </property>
      </widget>
     </item>
     <item row="1" column="1">
      <widget class="QLineEdit" name="usernameLineEdit">
       <property name="placeholderText">
        <string>MQTT用户名</string>
       </property>
      </widget>
     </item>
     <item row="1" column="2">
      <widget class="QLabel" name="passwordLabel">
       <property name="text">
        <string>密码:</string>
       </property>
      </widget>
     </item>
     <item row="1" column="3">
      <widget class="QLineEdit" name="passwordLineEdit">
       <property name="echoMode">
        <enum>QLineEdit::Password</enum>
       </property>
       <property name="placeholderText">
        <string>MQTT密码</string>
       </property>
      </widget>
     </item>

     <item row="2" column="0">
      <widget class="QLabel" name="topicLabel">
       <property name="text">
        <string>订阅主题:</string>
       </property>
      </widget>
     </item>
     <item row="2" column="1">
      <widget class="QLineEdit" name="topicLineEdit">
       <property name="placeholderText">
        <string>water/data</string>
       </property>
      </widget>
     </item>
     <item row="2" column="2">
      <widget class="QLabel" name="nodeLabel">
       <property name="text">
        <string>选择节点:</string>
       </property>
      </widget>
     </item>
     <item row="2" column="3">
      <widget class="QComboBox" name="nodeComboBox">
       <property name="sizePolicy">
        <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
         <horstretch>0</horstretch>
         <verstretch>0</verstretch>
        </sizepolicy>
       </property>
      </widget>
     </item>

     <item row="3" column="0" colspan="4">
      <layout class="QHBoxLayout" name="buttonLayout">
       <property name="spacing">
        <number>10</number>
       </property>
       <item>
        <widget class="QPushButton" name="connectButton">
         <property name="text">
          <string>🔗 连接</string>
         </property>
         <property name="minimumWidth">
          <number>100</number>
         </property>
        </widget>
       </item>
       <item>
        <widget class="QLabel" name="statusLabel">
         <property name="text">
          <string>未连接</string>
         </property>
         <property name="styleSheet">
          <string notr="true">color: red; font-weight: bold;</string>
         </property>
        </widget>
       </item>
       <item>
        <spacer name="buttonSpacer">
         <property name="orientation">
          <enum>Qt::Horizontal</enum>
         </property>
         <property name="sizeHint" stdset="0">
          <size>
           <width>40</width>
           <height>20</height>
          </size>
         </property>
        </spacer>
       </item>
      </layout>
     </item>
    </layout>
   </widget>

   <!-- 数据显示区域 -->
   <widget class="QGroupBox" name="dataGroupBox">
    <property name="title">
     <string>📊 实时数据</string>
    </property>
    <layout class="QGridLayout" name="dataLayout">
     <property name="horizontalSpacing">
      <number>15</number>
     </property>
     <property name="verticalSpacing">
      <number>10</number>
     </property>

     <!-- 第一行 -->
     <item row="0" column="0">
      <widget class="QLabel" name="tempLabel">
       <property name="text">
        <string>🌡️ 水温</string>
       </property>
       <property name="font">
        <font>
         <pointsize>11</pointsize>
         <weight>75</weight>
         <bold>true</bold>
        </font>
       </property>
      </widget>
     </item>
     <item row="0" column="1">
      <widget class="QLabel" name="tempValueLabel">
       <property name="text">
        <string>-- °C</string>
       </property>
       <property name="valueLabel" stdset="0">
        <bool>true</bool>
       </property>
       <property name="unit" stdset="0">
        <string>°C</string>
       </property>
       <property name="alignment">
        <set>Qt::AlignCenter</set>
       </property>
      </widget>
     </item>

     <item row="0" column="2">
      <widget class="QLabel" name="phLabel">
       <property name="text">
        <string>🧪 pH值</string>
       </property>
       <property name="font">
        <font>
         <pointsize>11</pointsize>
         <weight>75</weight>
         <bold>true</bold>
        </font>
       </property>
      </widget>
     </item>
     <item row="0" column="3">
      <widget class="QLabel" name="phValueLabel">
       <property name="text">
        <string>--</string>
       </property>
       <property name="valueLabel" stdset="0">
        <bool>true</bool>
       </property>
       <property name="unit" stdset="0">
        <string>pH</string>
       </property>
       <property name="alignment">
        <set>Qt::AlignCenter</set>
       </property>
      </widget>
     </item>

     <!-- 第二行 -->
     <item row="1" column="0">
      <widget class="QLabel" name="tdsLabel">
       <property name="text">
        <string>💧 TDS</string>
       </property>
       <property name="font">
        <font>
         <pointsize>11</pointsize>
         <weight>75</weight>
         <bold>true</bold>
        </font>
       </property>
      </widget>
     </item>
     <item row="1" column="1">
      <widget class="QLabel" name="tdsValueLabel">
       <property name="text">
        <string>-- ppm</string>
       </property>
       <property name="valueLabel" stdset="0">
        <bool>true</bool>
       </property>
       <property name="unit" stdset="0">
        <string>ppm</string>
       </property>
       <property name="alignment">
        <set>Qt::AlignCenter</set>
       </property>
      </widget>
     </item>

     <item row="1" column="2">
      <widget class="QLabel" name="turbidityLabel">
       <property name="text">
        <string>🌫️ 浊度</string>
       </property>
       <property name="font">
        <font>
         <pointsize>11</pointsize>
         <weight>75</weight>
         <bold>true</bold>
        </font>
       </property>
      </widget>
     </item>
     <item row="1" column="3">
      <widget class="QLabel" name="turbidityValueLabel">
       <property name="text">
        <string>-- NTU</string>
       </property>
       <property name="valueLabel" stdset="0">
        <bool>true</bool>
       </property>
       <property name="unit" stdset="0">
        <string>NTU</string>
       </property>
       <property name="alignment">
        <set>Qt::AlignCenter</set>
       </property>
      </widget>
     </item>

     <!-- 第三行 -->
     <item row="2" column="0">
      <widget class="QLabel" name="batteryLabel">
       <property name="text">
        <string>🔋 电池电压</string>
       </property>
       <property name="font">
        <font>
         <pointsize>11</pointsize>
         <weight>75</weight>
         <bold>true</bold>
        </font>
       </property>
      </widget>
     </item>
     <item row="2" column="1">
      <widget class="QLabel" name="batteryValueLabel">
       <property name="text">
        <string>-- V</string>
       </property>
       <property name="valueLabel" stdset="0">
        <bool>true</bool>
       </property>
       <property name="unit" stdset="0">
        <string>V</string>
       </property>
       <property name="alignment">
        <set>Qt::AlignCenter</set>
       </property>
      </widget>
     </item>

     <item row="2" column="2">
      <widget class="QLabel" name="solarLabel">
       <property name="text">
        <string>☀️ 太阳能</string>
       </property>
       <property name="font">
        <font>
         <pointsize>11</pointsize>
         <weight>75</weight>
         <bold>true</bold>
        </font>
       </property>
      </widget>
     </item>
     <item row="2" column="3">
      <widget class="QLabel" name="solarStatusLabel">
       <property name="text">
        <string>--</string>
       </property>
       <property name="valueLabel" stdset="0">
        <bool>true</bool>
       </property>
       <property name="alignment">
        <set>Qt::AlignCenter</set>
       </property>
      </widget>
     </item>

     <!-- 时间戳 -->
     <item row="3" column="0" colspan="4">
      <widget class="QLabel" name="timeLabel">
       <property name="text">
        <string>更新时间: --</string>
       </property>
       <property name="alignment">
        <set>Qt::AlignRight</set>
       </property>
       <property name="styleSheet">
        <string notr="true">color: #666666; font-size: 10px;</string>
       </property>
      </widget>
     </item>
    </layout>
   </widget>

   <!-- 图表和告警区域 -->
   <widget class="QWidget" name="chartWidget" native="true">
    <layout class="QVBoxLayout" name="chartLayout">
     <property name="spacing">
      <number>5</number>
     </property>
     <property name="leftMargin">
      <number>0</number>
     </property>
     <property name="topMargin">
      <number>0</number>
     </property>
     <property name="rightMargin">
      <number>0</number>
     </property>
     <property name="bottomMargin">
      <number>0</number>
     </property>

     <!-- 告警状态 -->
     <item>
      <widget class="QLabel" name="alarmLabel">
       <property name="text">
        <string>✓ 所有参数正常</string>
       </property>
       <property name="styleSheet">
        <string notr="true">color: green; font-weight: bold; padding: 5px; background-color: #e8f5e9; border-radius: 4px;</string>
       </property>
       <property name="alignment">
        <set>Qt::AlignCenter</set>
       </property>
      </widget>
     </item>

     <!-- 图表容器 -->
     <item>
      <widget class="QWidget" name="chartContainer" native="true">
       <property name="sizePolicy">
        <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
         <horstretch>0</horstretch>
         <verstretch>1</verstretch>
        </sizepolicy>
       </property>
       <property name="minimumHeight">
        <number>250</number>
       </property>
       <layout class="QVBoxLayout" name="chartContainerLayout">
        <property name="leftMargin">
         <number>0</number>
        </property>
        <property name="topMargin">
         <number>0</number>
        </property>
        <property name="rightMargin">
         <number>0</number>
        </property>
        <property name="bottomMargin">
         <number>0</number>
        </property>
       </layout>
      </widget>
     </item>

     <!-- 图表控制按钮 -->
     <item>
      <layout class="QHBoxLayout" name="chartControlLayout">
       <property name="spacing">
        <number>10</number>
       </property>
       <item>
        <spacer name="chartControlSpacer">
         <property name="orientation">
          <enum>Qt::Horizontal</enum>
         </property>
         <property name="sizeHint" stdset="0">
          <size>
           <width>40</width>
           <height>20</height>
          </size>
         </property>
        </spacer>
       </item>
       <item>
        <widget class="QPushButton" name="exportButton">
         <property name="text">
          <string>💾 导出数据</string>
         </property>
        </widget>
       </item>
       <item>
        <widget class="QPushButton" name="clearButton">
         <property name="text">
          <string>🗑️ 清除图表</string>
         </property>
        </widget>
       </item>
      </layout>
     </item>
    </layout>
   </widget>

   <!-- 底部状态栏 -->
   <widget class="QStatusBar" name="statusBar">
    <property name="sizePolicy">
     <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
      <horstretch>0</horstretch>
      <verstretch>0</verstretch>
     </sizepolicy>
    </property>
    <property name="maximumHeight">
     <number>25</number>
    </property>
    <property name="styleSheet">
     <string notr="true">background-color: #eeeeee; padding: 2px 8px;</string>
    </property>
   </widget>
  </layout>
 </widget>

 <!-- 自定义属性 -->
 <customwidgets>
  <customwidget>
   <class>QChartView</class>
   <extends>QGraphicsView</extends>
   <header>QtCharts/QChartView</header>
  </customwidget>
 </customwidgets>

 <!-- 资源 -->
 <resources/>
 <connections/>
</ui>

3.6 设计代码

/*
 * 项目: 河流水质检测装置 - Qt上位机 (widget.cpp)
 * 功能: 从华为云IoT平台订阅MQTT数据,展示水温、PH、电导率、浊度、电池电压、
 *       太阳能充电状态,支持多节点切换,实时曲线显示。
 * 环境: Qt 5.15+ / Qt 6.x, 需添加 QT += network websockets
 * 编译: qmake / cmake
 */

#include "widget.h"
#include "ui_widget.h"
#include <QMessageBox>
#include <QDateTime>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QTimer>
#include <QtMath>
#include <QPainter>
#include <QPen>
#include <QBrush>
#include <QDebug>

// -------------------- 构造函数 --------------------
Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
    , m_mqttClient(new QMqttClient(this))
    , m_dataTimer(new QTimer(this))
    , m_reconnectTimer(new QTimer(this))
    , m_isConnected(false)
    , m_currentNode("NODE_001")
{
    ui->setupUi(this);

    // 初始化UI
    setWindowTitle("河流水质监测系统 - Qt上位机");
    ui->statusLabel->setText("未连接");
    ui->statusLabel->setStyleSheet("color: red;");

    // 初始化曲线图
    initChart();

    // 初始化节点下拉列表
    ui->nodeComboBox->addItem("NODE_001");
    ui->nodeComboBox->addItem("NODE_002");
    ui->nodeComboBox->addItem("NODE_003");
    ui->nodeComboBox->addItem("NODE_004");
    ui->nodeComboBox->addItem("NODE_005");

    // 连接信号槽
    connect(ui->connectButton, &QPushButton::clicked, this, &Widget::onConnectButtonClicked);
    connect(ui->nodeComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged),
            this, &Widget::onNodeChanged);
    connect(m_mqttClient, &QMqttClient::stateChanged, this, &Widget::onMqttStateChanged);
    connect(m_mqttClient, &QMqttClient::messageReceived, this, &Widget::onMqttMessageReceived);
    connect(m_mqttClient, &QMqttClient::disconnected, this, &Widget::onMqttDisconnected);
    connect(m_dataTimer, &QTimer::timeout, this, &Widget::updateTimeDisplay);
    connect(m_reconnectTimer, &QTimer::timeout, this, &Widget::attemptReconnect);

    // 加载配置
    loadConfig();

    // 启动时间显示
    m_dataTimer->start(1000);

    // 自动连接
    QTimer::singleShot(500, this, &Widget::autoConnect);
}

// -------------------- 析构函数 --------------------
Widget::~Widget()
{
    if (m_mqttClient->state() == QMqttClient::Connected) {
        m_mqttClient->disconnect();
    }
    delete ui;
}

// -------------------- 初始化图表 --------------------
void Widget::initChart()
{
    // 创建图表视图
    m_chartView = new QChartView(this);
    m_chartView->setRenderHint(QPainter::Antialiasing);
    m_chartView->setMinimumHeight(200);

    // 创建图表
    QChart *chart = new QChart();
    chart->setTitle("实时水质数据趋势");
    chart->setAnimationOptions(QChart::SeriesAnimations);
    chart->legend()->setVisible(true);
    chart->legend()->setAlignment(Qt::AlignBottom);

    // 创建温度曲线
    m_tempSeries = new QLineSeries();
    m_tempSeries->setName("水温 (°C)");
    m_tempSeries->setPen(QPen(Qt::red, 2));

    // 创建pH曲线
    m_phSeries = new QLineSeries();
    m_phSeries->setName("pH值");
    m_phSeries->setPen(QPen(Qt::blue, 2));

    // 创建TDS曲线
    m_tdsSeries = new QLineSeries();
    m_tdsSeries->setName("TDS (ppm)");
    m_tdsSeries->setPen(QPen(Qt::green, 2));

    // 添加曲线到图表
    chart->addSeries(m_tempSeries);
    chart->addSeries(m_phSeries);
    chart->addSeries(m_tdsSeries);

    // 创建坐标轴
    QValueAxis *axisX = new QValueAxis();
    axisX->setTitleText("时间 (秒)");
    axisX->setRange(0, 60);
    axisX->setLabelFormat("%d");
    axisX->setTickCount(7);

    QValueAxis *axisY = new QValueAxis();
    axisY->setTitleText("数值");
    axisY->setRange(0, 100);
    axisY->setLabelFormat("%.1f");

    chart->addAxis(axisX, Qt::AlignBottom);
    chart->addAxis(axisY, Qt::AlignLeft);

    m_tempSeries->attachAxis(axisX);
    m_tempSeries->attachAxis(axisY);
    m_phSeries->attachAxis(axisX);
    m_phSeries->attachAxis(axisY);
    m_tdsSeries->attachAxis(axisX);
    m_tdsSeries->attachAxis(axisY);

    m_chartView->setChart(chart);

    // 添加到布局
    ui->chartLayout->addWidget(m_chartView);
}

// -------------------- 加载配置文件 --------------------
void Widget::loadConfig()
{
    // 从配置文件或注册表加载MQTT配置
    // 这里使用默认值
    ui->brokerLineEdit->setText("mqtt://your-huaweicloud-mqtt-url:1883");
    ui->clientIdLineEdit->setText("Qt-Client-001");
    ui->usernameLineEdit->setText("your-username");
    ui->passwordLineEdit->setText("your-password");
    ui->topicLineEdit->setText("water/data");

    // 从环境变量或配置文件加载
    QSettings settings("WaterMonitor", "QtClient");
    ui->brokerLineEdit->setText(settings.value("broker", ui->brokerLineEdit->text()).toString());
    ui->clientIdLineEdit->setText(settings.value("clientId", ui->clientIdLineEdit->text()).toString());
    ui->usernameLineEdit->setText(settings.value("username", ui->usernameLineEdit->text()).toString());
    ui->passwordLineEdit->setText(settings.value("password", ui->passwordLineEdit->text()).toString());
    ui->topicLineEdit->setText(settings.value("topic", ui->topicLineEdit->text()).toString());
}

// -------------------- 保存配置 --------------------
void Widget::saveConfig()
{
    QSettings settings("WaterMonitor", "QtClient");
    settings.setValue("broker", ui->brokerLineEdit->text());
    settings.setValue("clientId", ui->clientIdLineEdit->text());
    settings.setValue("username", ui->usernameLineEdit->text());
    settings.setValue("password", ui->passwordLineEdit->text());
    settings.setValue("topic", ui->topicLineEdit->text());
}

// -------------------- 连接/断开按钮点击 --------------------
void Widget::onConnectButtonClicked()
{
    if (m_mqttClient->state() == QMqttClient::Connected) {
        disconnectMqtt();
    } else {
        connectMqtt();
    }
}

// -------------------- 连接MQTT --------------------
void Widget::connectMqtt()
{
    // 保存配置
    saveConfig();

    // 解析broker地址
    QString brokerUrl = ui->brokerLineEdit->text();
    QUrl url(brokerUrl);
    if (!url.isValid()) {
        QMessageBox::warning(this, "错误", "无效的Broker地址");
        return;
    }

    // 设置MQTT参数
    m_mqttClient->setHostname(url.host());
    m_mqttClient->setPort(url.port(1883));
    m_mqttClient->setClientId(ui->clientIdLineEdit->text());
    m_mqttClient->setUsername(ui->usernameLineEdit->text());
    m_mqttClient->setPassword(ui->passwordLineEdit->text());

    // 设置WebSocket协议 (如果使用wss)
    if (url.scheme() == "wss" || url.scheme() == "ws") {
        m_mqttClient->setProtocol(QMqttClient::WebSocket);
    }

    // 连接
    qDebug() << "Connecting to MQTT broker:" << brokerUrl;
    m_mqttClient->connectToHost();

    // 启动重连定时器
    m_reconnectTimer->start(30000); // 30秒重连尝试
}

// -------------------- 断开MQTT --------------------
void Widget::disconnectMqtt()
{
    m_mqttClient->disconnectFromHost();
    m_reconnectTimer->stop();
}

// -------------------- 自动连接 --------------------
void Widget::autoConnect()
{
    if (m_mqttClient->state() != QMqttClient::Connected) {
        connectMqtt();
    }
}

// -------------------- 重连尝试 --------------------
void Widget::attemptReconnect()
{
    if (m_mqttClient->state() != QMqttClient::Connected) {
        qDebug() << "Attempting to reconnect...";
        connectMqtt();
    }
}

// -------------------- MQTT状态变化 --------------------
void Widget::onMqttStateChanged(QMqttClient::ClientState state)
{
    switch (state) {
    case QMqttClient::Connected:
        ui->statusLabel->setText("已连接");
        ui->statusLabel->setStyleSheet("color: green;");
        ui->connectButton->setText("断开连接");
        m_isConnected = true;

        // 订阅主题
        QString topic = ui->topicLineEdit->text();
        if (!topic.isEmpty()) {
            m_mqttClient->subscribe(topic, 1);
            qDebug() << "Subscribed to topic:" << topic;
        }
        break;

    case QMqttClient::Disconnected:
        ui->statusLabel->setText("未连接");
        ui->statusLabel->setStyleSheet("color: red;");
        ui->connectButton->setText("连接");
        m_isConnected = false;
        break;

    case QMqttClient::Connecting:
        ui->statusLabel->setText("连接中...");
        ui->statusLabel->setStyleSheet("color: orange;");
        break;

    default:
        break;
    }
}

// -------------------- MQTT断开事件 --------------------
void Widget::onMqttDisconnected()
{
    qDebug() << "MQTT disconnected";
    // 启动重连定时器
    m_reconnectTimer->start(10000);
}

// -------------------- 接收MQTT消息 --------------------
void Widget::onMqttMessageReceived(const QByteArray &message, const QMqttTopicName &topic)
{
    qDebug() << "Received message from topic:" << topic.name();
    qDebug() << "Message:" << message;

    // 解析JSON数据
    parseSensorData(message);
}

// -------------------- 解析传感器数据 --------------------
void Widget::parseSensorData(const QByteArray &data)
{
    QJsonParseError error;
    QJsonDocument doc = QJsonDocument::fromJson(data, &error);

    if (error.error != QJsonParseError::NoError) {
        qWarning() << "JSON parse error:" << error.errorString();
        return;
    }

    if (!doc.isObject()) {
        qWarning() << "Invalid JSON: not an object";
        return;
    }

    QJsonObject obj = doc.object();

    // 检查节点ID是否匹配
    QString nodeId = obj.value("node_id").toString();
    if (nodeId != m_currentNode) {
        qDebug() << "Node ID mismatch, ignoring data from:" << nodeId;
        return;
    }

    // 提取数据
    double temperature = obj.value("temperature").toDouble();
    double ph = obj.value("ph").toDouble();
    double tds = obj.value("tds").toDouble();
    double turbidity = obj.value("turbidity").toDouble();
    double batteryVoltage = obj.value("battery_voltage").toDouble();
    bool solarCharging = obj.value("solar_charging").toBool();
    qint64 timestamp = obj.value("timestamp").toInt();

    // 更新UI显示
    ui->tempValueLabel->setText(QString("%1 °C").arg(temperature, 0, 'f', 2));
    ui->phValueLabel->setText(QString("%1").arg(ph, 0, 'f', 2));
    ui->tdsValueLabel->setText(QString("%1 ppm").arg(tds, 0, 'f', 0));
    ui->turbidityValueLabel->setText(QString("%1 NTU").arg(turbidity, 0, 'f', 1));
    ui->batteryValueLabel->setText(QString("%1 V").arg(batteryVoltage, 0, 'f', 2));
    ui->solarStatusLabel->setText(solarCharging ? "● 充电中" : "○ 未充电");
    ui->solarStatusLabel->setStyleSheet(solarCharging ?
                                        "color: green; font-weight: bold;" :
                                        "color: gray;");

    // 更新时间戳
    QDateTime dt = QDateTime::fromSecsSinceEpoch(timestamp);
    ui->timeLabel->setText(dt.toString("yyyy-MM-dd hh:mm:ss"));

    // 更新图表
    updateChart(temperature, ph, tds);

    // 更新状态栏信息
    ui->statusLabel->setText(QString("已连接 | 节点: %1 | 最后更新: %2")
                             .arg(nodeId)
                             .arg(dt.toString("hh:mm:ss")));

    // 检查告警
    checkAlarms(temperature, ph, tds, batteryVoltage);
}

// -------------------- 更新图表 --------------------
void Widget::updateChart(double temp, double ph, double tds)
{
    // 获取当前X轴位置 (点数)
    static int pointCount = 0;
    pointCount++;

    // 如果超过60个点,移除最早的点
    if (m_tempSeries->count() >= 60) {
        m_tempSeries->remove(0);
        m_phSeries->remove(0);
        m_tdsSeries->remove(0);

        // 调整X轴偏移
        QValueAxis *axisX = qobject_cast<QValueAxis*>(m_chartView->chart()->axes(Qt::Horizontal).first());
        if (axisX) {
            axisX->setRange(pointCount - 60, pointCount);
        }
    }

    // 添加新数据点
    m_tempSeries->append(pointCount, temp);
    m_phSeries->append(pointCount, ph);
    m_tdsSeries->append(pointCount, tds);

    // 更新Y轴范围
    QValueAxis *axisY = qobject_cast<QValueAxis*>(m_chartView->chart()->axes(Qt::Vertical).first());
    if (axisY) {
        // 动态调整Y轴范围,留出10%余量
        double maxVal = qMax(qMax(temp, ph), tds) * 1.2;
        double minVal = qMin(qMin(temp, ph), tds) * 0.8;
        if (maxVal < 10) maxVal = 10;
        if (minVal < 0) minVal = 0;
        axisY->setRange(minVal, maxVal);
    }

    // 更新X轴范围
    QValueAxis *axisX = qobject_cast<QValueAxis*>(m_chartView->chart()->axes(Qt::Horizontal).first());
    if (axisX) {
        if (pointCount > 60) {
            axisX->setRange(pointCount - 60, pointCount);
        } else {
            axisX->setRange(0, 60);
        }
    }
}

// -------------------- 检查告警 --------------------
void Widget::checkAlarms(double temp, double ph, double tds, double battery)
{
    QStringList alarms;

    // 水温告警 (正常范围: 0-40°C)
    if (temp < 0 || temp > 40) {
        alarms << QString("水温异常: %1°C").arg(temp, 0, 'f', 2);
    }

    // pH告警 (正常范围: 6.5-8.5)
    if (ph < 6.5 || ph > 8.5) {
        alarms << QString("pH异常: %1").arg(ph, 0, 'f', 2);
    }

    // TDS告警 (正常范围: 0-500 ppm)
    if (tds > 500) {
        alarms << QString("TDS超标: %1 ppm").arg(tds, 0, 'f', 0);
    }

    // 低电量告警
    if (battery < 3.3) {
        alarms << QString("低电量: %1 V").arg(battery, 0, 'f', 2);
    }

    if (!alarms.isEmpty()) {
        QString alarmText = "⚠ 告警: " + alarms.join(" | ");
        ui->alarmLabel->setText(alarmText);
        ui->alarmLabel->setStyleSheet("color: red; font-weight: bold;");
        qWarning() << alarmText;
    } else {
        ui->alarmLabel->setText("✓ 所有参数正常");
        ui->alarmLabel->setStyleSheet("color: green;");
    }
}

// -------------------- 更新时间显示 --------------------
void Widget::updateTimeDisplay()
{
    QString currentTime = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss");
    ui->currentTimeLabel->setText(currentTime);
}

// -------------------- 节点切换 --------------------
void Widget::onNodeChanged(int index)
{
    m_currentNode = ui->nodeComboBox->currentText();
    qDebug() << "Switched to node:" << m_currentNode;

    // 清空图表
    m_tempSeries->clear();
    m_phSeries->clear();
    m_tdsSeries->clear();

    // 清空数据显示
    ui->tempValueLabel->setText("--");
    ui->phValueLabel->setText("--");
    ui->tdsValueLabel->setText("--");
    ui->turbidityValueLabel->setText("--");
    ui->batteryValueLabel->setText("--");
    ui->solarStatusLabel->setText("--");

    // 重新订阅主题(实际应用中可能需要调整)
    if (m_mqttClient->state() == QMqttClient::Connected) {
        // 取消旧订阅
        QString oldTopic = ui->topicLineEdit->text();
        if (!oldTopic.isEmpty()) {
            m_mqttClient->unsubscribe(oldTopic);
        }

        // 订阅新主题(通常主题包含节点ID)
        QString newTopic = QString("water/data/%1").arg(m_currentNode);
        m_mqttClient->subscribe(newTopic, 1);
        qDebug() << "Subscribed to new topic:" << newTopic;
    }
}

// -------------------- 导出数据 --------------------
void Widget::onExportButtonClicked()
{
    QString fileName = QFileDialog::getSaveFileName(this,
                                                    "导出数据",
                                                    QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss") + ".csv",
                                                    "CSV Files (*.csv)");

    if (fileName.isEmpty()) {
        return;
    }

    QFile file(fileName);
    if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
        QMessageBox::warning(this, "错误", "无法创建文件");
        return;
    }

    QTextStream out(&file);
    out << "时间,水温(°C),pH值,TDS(ppm),浊度(NTU),电池电压(V),太阳能充电\n";

    // 从图表数据导出 (实际应从历史数据存储中导出)
    // 这里仅导出当前显示的数据
    out << QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss") << ","
        << ui->tempValueLabel->text() << ","
        << ui->phValueLabel->text() << ","
        << ui->tdsValueLabel->text() << ","
        << ui->turbidityValueLabel->text() << ","
        << ui->batteryValueLabel->text() << ","
        << ui->solarStatusLabel->text() << "\n";

    file.close();
    QMessageBox::information(this, "导出成功", "数据已导出到:\n" + fileName);
}

// -------------------- 清除图表 --------------------
void Widget::onClearButtonClicked()
{
    m_tempSeries->clear();
    m_phSeries->clear();
    m_tdsSeries->clear();
    qDebug() << "Chart cleared";
}

// -------------------- 窗口关闭事件 --------------------
void Widget::closeEvent(QCloseEvent *event)
{
    if (m_mqttClient->state() == QMqttClient::Connected) {
        m_mqttClient->disconnectFromHost();
    }
    event->accept();
}

四、STM32代码设计

4.1 硬件连线说明

1. 0.96寸 OLED 显示屏 (I2C协议)

  • SDA → ESP32-S3 GPIO 8
  • SCL → ESP32-S3 GPIO 9
  • VCC → 接 3.3V 电源
  • GND → 接公共地

2. DS18B20 防水水温传感器

  • DQ (数据线) → ESP32-S3 GPIO 10
  • VCC → 接 3.3V 电源
  • GND → 接公共地

3. pH传感器 (模拟量输出)

  • 模拟信号线 (AO) → ESP32-S3 GPIO 1 (对应ADC1通道0)
  • VCC → 接 5V 电源(注意:部分pH模块需5V供电,请核对模块规格)
  • GND → 接公共地

4. TDS传感器 (电导率,模拟量输出)

  • 模拟信号线 (AO) → ESP32-S3 GPIO 2 (对应ADC1通道1)
  • VCC → 接 3.3V 或 5V(视模块而定,常见为3.3V~5V)
  • GND → 接公共地

5. 浊度传感器 (模拟量输出)

  • 模拟信号线 (AO) → ESP32-S3 GPIO 3 (对应ADC1通道2)
  • VCC → 接 5V 电源
  • GND → 接公共地

6. 电池电压检测 (用于太阳能供电管理)

  • 分压后信号线 → ESP32-S3 GPIO 4 (对应ADC1通道3)
  • 接线方法:在电池正极与GND之间串联两个等值电阻(如10kΩ和10kΩ),将中间节点接至GPIO 4。这样电池电压(如6V12V)被分压至1/2,确保输入在03.3V安全范围内。

7. 太阳能充电状态检测

  • 状态信号线 → ESP32-S3 GPIO 16
  • 该引脚接收太阳能充电控制板输出的 高电平(3.3V) 表示正在充电,低电平表示未充电。

8. 有源蜂鸣器 (高电平触发)

  • 信号控制线 (I/O) → ESP32-S3 GPIO 11
  • VCC → 接 3.3V 或 5V(视蜂鸣器额定电压)
  • GND → 接公共地

9. Air780e 4G通信模块

  • PWR (电源使能) → ESP32-S3 GPIO 12(高电平开启模块电源)
  • RST (复位) → ESP32-S3 GPIO 13
  • TXD (模块发送) → ESP32-S3 GPIO 15(交叉连接:模块TXD接ESP的RXD)
  • RXD (模块接收) → ESP32-S3 GPIO 14(交叉连接:模块RXD接ESP的TXD)
  • VCC → 需独立供电(推荐4.0V/2A以上),不可从ESP32-S3的3.3V引脚取电
  • GND → 与ESP32-S3及所有传感器共地

4.2 项目完整代码设计

/*
 * 项目: 基于ESP32-S3的河流水质检测装置
 * 文件: main.c
 * 描述: 完整实现水温、PH、电导率、浊度检测,LCD显示,WiFi/4G双模MQTT上云,
 *       太阳能供电管理,多节点支持。
 * 硬件: ESP32-S3, 0.96寸OLED, DS18B20, PH传感器, TDS传感器, 浊度传感器, Air780e
 * 环境: VSCODE + ESP-IDF v5.0+
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#include "driver/gpio.h"
#include "driver/i2c.h"
#include "driver/uart.h"
#include "driver/adc.h"
#include "esp_timer.h"
#include "cJSON.h"
#include "mqtt_client.h"
#include "esp_ota_ops.h"

// -------------------- 硬件引脚定义 --------------------
#define PIN_OLED_SDA        GPIO_NUM_8
#define PIN_OLED_SCL        GPIO_NUM_9
#define PIN_DS18B20         GPIO_NUM_10
#define PIN_PH_SENSOR       ADC_CHANNEL_0   // GPIO1
#define PIN_TDS_SENSOR      ADC_CHANNEL_1   // GPIO2
#define PIN_TURBIDITY_SENSOR ADC_CHANNEL_2  // GPIO3
#define PIN_BUZZER          GPIO_NUM_11
#define PIN_4G_PWR          GPIO_NUM_12      // Air780e 电源使能
#define PIN_4G_RST          GPIO_NUM_13
#define PIN_4G_TX           GPIO_NUM_14
#define PIN_4G_RX           GPIO_NUM_15
#define PIN_SOLAR_CHARGE    GPIO_NUM_16      // 太阳能充电状态检测 (高电平充电中)
#define PIN_BATTERY_LEVEL   ADC_CHANNEL_3    // GPIO4 电池电压检测

// -------------------- I2C OLED 配置 --------------------
#define I2C_MASTER_NUM      I2C_NUM_0
#define I2C_MASTER_FREQ_HZ  400000
#define OLED_ADDR           0x3C
#define OLED_WIDTH          128
#define OLED_HEIGHT         64

// -------------------- WiFi 配置 --------------------
#define WIFI_SSID           "YOUR_WIFI_SSID"
#define WIFI_PASS           "YOUR_WIFI_PASSWORD"

// -------------------- MQTT 配置 (华为云IoT) --------------------
#define MQTT_BROKER_URI     "mqtt://YOUR_HUAWEICLOUD_MQTT_URL:1883"
#define MQTT_CLIENT_ID      "ESP32-S3-WaterMonitor-001"
#define MQTT_USERNAME       "YOUR_MQTT_USERNAME"
#define MQTT_PASSWORD       "YOUR_MQTT_PASSWORD"
#define MQTT_TOPIC_PUB      "water/data"
#define MQTT_TOPIC_SUB      "water/config"

// -------------------- 4G 模块配置 (Air780e) --------------------
#define UART_4G_NUM         UART_NUM_1
#define UART_4G_BAUD        115200
#define 4G_APN              "CMNET"

// -------------------- 采样与报告周期 --------------------
#define SAMPLE_INTERVAL_MS  5000     // 采样间隔 5秒
#define REPORT_INTERVAL_MS  60000    // 上报间隔 60秒

// -------------------- 全局变量 --------------------
static const char *TAG = "WATER_MONITOR";

// ADC 句柄
adc_oneshot_unit_handle_t adc_handle;
adc_cali_handle_t adc_cali_handle;
bool adc_calibrated = false;

// MQTT 客户端句柄
esp_mqtt_client_handle_t mqtt_client = NULL;
EventGroupHandle_t mqtt_event_group;
const int MQTT_CONNECTED_BIT = BIT0;

// 传感器数据
typedef struct {
    float temperature;      // 水温 °C
    float ph;               // pH值
    float tds;              // 电导率 (TDS) ppm
    float turbidity;        // 浊度 NTU
    float battery_voltage;  // 电池电压 V
    bool solar_charging;    // 太阳能充电状态
    char node_id[16];       // 节点ID
} sensor_data_t;

sensor_data_t sensor_data = {
    .node_id = "NODE_001",
    .temperature = 0,
    .ph = 0,
    .tds = 0,
    .turbidity = 0,
    .battery_voltage = 0,
    .solar_charging = false
};

// -------------------- 函数声明 --------------------
static void wifi_init(void);
static void mqtt_init(void);
static void mqtt_publish_data(sensor_data_t *data);
static void sensor_read_task(void *pvParameters);
static void mqtt_report_task(void *pvParameters);
static void oled_display_task(void *pvParameters);
static void uart_4g_init(void);
static void send_4g_at_command(const char *cmd);
static void check_4g_connection(void);
static float read_battery_voltage(void);
static bool read_solar_charging(void);
static void i2c_oled_init(void);
static void oled_show_data(sensor_data_t *data);

// -------------------- 初始化 ADC --------------------
static void adc_init(void) {
    adc_oneshot_unit_init_cfg_t init_config = {
        .unit_id = ADC_UNIT_1,
        .ulp_mode = ADC_ULP_MODE_DISABLE,
    };
    ESP_ERROR_CHECK(adc_oneshot_new_unit(&init_config, &adc_handle));

    adc_oneshot_chan_cfg_t config = {
        .atten = ADC_ATTEN_DB_12,
        .bitwidth = ADC_BITWIDTH_12,
    };
    ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle, PIN_PH_SENSOR, &config));
    ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle, PIN_TDS_SENSOR, &config));
    ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle, PIN_TURBIDITY_SENSOR, &config));
    ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle, PIN_BATTERY_LEVEL, &config));

    // 校准ADC (使用eFuse)
    adc_cali_curve_fitting_config_t cali_config = {
        .unit_id = ADC_UNIT_1,
        .atten = ADC_ATTEN_DB_12,
        .bitwidth = ADC_BITWIDTH_12,
    };
    esp_err_t ret = adc_cali_create_scheme_curve_fitting(&cali_config, &adc_cali_handle);
    if (ret == ESP_OK) {
        adc_calibrated = true;
        ESP_LOGI(TAG, "ADC calibration successful");
    } else {
        ESP_LOGW(TAG, "ADC calibration failed, using raw values");
    }
}

// -------------------- 读取ADC电压 (mV) --------------------
static uint32_t read_adc_mv(adc_channel_t channel) {
    int adc_raw;
    ESP_ERROR_CHECK(adc_oneshot_read(adc_handle, channel, &adc_raw));
    if (adc_calibrated) {
        int mv;
        adc_cali_raw_to_voltage(adc_cali_handle, adc_raw, &mv);
        return mv;
    }
    // 粗略转换: 3.3V / 4096 * 1000
    return (uint32_t)(adc_raw * 3300 / 4096);
}

// -------------------- 读取水温 (DS18B20) --------------------
// 简化: 假设DS18B20已通过OneWire驱动读取,此处模拟读取
static float read_temperature(void) {
    // 实际项目需实现OneWire和DS18B20协议
    // 这里返回模拟值 (25.0 ~ 30.0)
    return 25.0 + ((float)rand() / RAND_MAX) * 5.0;
}

// -------------------- 读取pH值 --------------------
static float read_ph(void) {
    uint32_t mv = read_adc_mv(PIN_PH_SENSOR);
    // pH传感器特性: 0mV ~ 3000mV 对应 pH 0 ~ 14
    // 实际需根据传感器校准曲线转换
    float ph = (mv / 3000.0) * 14.0;
    if (ph < 0) ph = 0;
    if (ph > 14) ph = 14;
    return ph;
}

// -------------------- 读取TDS (电导率) --------------------
static float read_tds(void) {
    uint32_t mv = read_adc_mv(PIN_TDS_SENSOR);
    // TDS传感器: 0-2.5V对应0-1000ppm (典型)
    float tds = (mv / 2500.0) * 1000.0;
    if (tds < 0) tds = 0;
    if (tds > 1000) tds = 1000;
    return tds;
}

// -------------------- 读取浊度 --------------------
static float read_turbidity(void) {
    uint32_t mv = read_adc_mv(PIN_TURBIDITY_SENSOR);
    // 浊度传感器: 0-4.2V对应0-1000NTU (典型)
    float ntu = (mv / 4200.0) * 1000.0;
    if (ntu < 0) ntu = 0;
    if (ntu > 1000) ntu = 1000;
    return ntu;
}

// -------------------- 读取电池电压 --------------------
static float read_battery_voltage(void) {
    uint32_t mv = read_adc_mv(PIN_BATTERY_LEVEL);
    // 假设分压电路: 1/2分压, 实际电压 = mV * 2
    return (float)mv * 2.0 / 1000.0;
}

// -------------------- 读取太阳能充电状态 --------------------
static bool read_solar_charging(void) {
    return gpio_get_level(PIN_SOLAR_CHARGE) == 1;
}

// -------------------- 蜂鸣器控制 --------------------
static void buzzer_beep(int times, int duration_ms) {
    for (int i = 0; i < times; i++) {
        gpio_set_level(PIN_BUZZER, 1);
        vTaskDelay(pdMS_TO_TICKS(duration_ms));
        gpio_set_level(PIN_BUZZER, 0);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

// -------------------- I2C OLED 初始化 --------------------
static void i2c_oled_init(void) {
    i2c_config_t conf = {
        .mode = I2C_MODE_MASTER,
        .sda_io_num = PIN_OLED_SDA,
        .scl_io_num = PIN_OLED_SCL,
        .sda_pullup_en = GPIO_PULLUP_ENABLE,
        .scl_pullup_en = GPIO_PULLUP_ENABLE,
        .master.clk_speed = I2C_MASTER_FREQ_HZ,
    };
    ESP_ERROR_CHECK(i2c_param_config(I2C_MASTER_NUM, &conf));
    ESP_ERROR_CHECK(i2c_driver_install(I2C_MASTER_NUM, conf.mode, 0, 0, 0));

    // OLED 初始化序列 (SSD1306)
    uint8_t init_cmds[] = {
        0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14,
        0x20, 0x00, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1,
        0xDB, 0x40, 0xA4, 0xA6, 0x2E, 0xAF
    };
    for (int i = 0; i < sizeof(init_cmds); i++) {
        uint8_t cmd[2] = {0x00, init_cmds[i]};
        i2c_master_write_to_device(I2C_MASTER_NUM, OLED_ADDR, cmd, 2, 1000 / portTICK_PERIOD_MS);
    }
    ESP_LOGI(TAG, "OLED initialized");
}

// -------------------- OLED 显示数据 --------------------
static void oled_show_data(sensor_data_t *data) {
    char line[20];
    // 清屏
    uint8_t clear_cmd[2] = {0x00, 0x00};
    for (int page = 0; page < 8; page++) {
        i2c_master_write_to_device(I2C_MASTER_NUM, OLED_ADDR, clear_cmd, 2, 1000 / portTICK_PERIOD_MS);
    }

    // 显示数据 (简化: 仅示例文本, 实际需实现字符库)
    // 实际项目中应使用SSD1306驱动库
    ESP_LOGI(TAG, "OLED: Temp=%.2f pH=%.2f TDS=%.2f Turb=%.2f Bat=%.2fV Solar=%d",
             data->temperature, data->ph, data->tds, data->turbidity,
             data->battery_voltage, data->solar_charging);
}

// -------------------- WiFi 初始化 --------------------
static void wifi_init(void) {
    nvs_flash_init();
    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    esp_netif_create_default_wifi_sta();

    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));

    wifi_config_t wifi_config = {
        .sta = {
            .ssid = WIFI_SSID,
            .password = WIFI_PASS,
        },
    };

    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
    ESP_ERROR_CHECK(esp_wifi_start());

    ESP_LOGI(TAG, "WiFi connecting to %s", WIFI_SSID);
    // 等待WiFi连接 (实际需用事件)
    vTaskDelay(pdMS_TO_TICKS(5000));
}

// -------------------- MQTT 事件处理 --------------------
static void mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) {
    esp_mqtt_event_handle_t event = event_data;
    switch ((esp_mqtt_event_id_t)event_id) {
        case MQTT_EVENT_CONNECTED:
            ESP_LOGI(TAG, "MQTT connected");
            xEventGroupSetBits(mqtt_event_group, MQTT_CONNECTED_BIT);
            esp_mqtt_client_subscribe(mqtt_client, MQTT_TOPIC_SUB, 0);
            buzzer_beep(1, 100);
            break;
        case MQTT_EVENT_DISCONNECTED:
            ESP_LOGI(TAG, "MQTT disconnected");
            xEventGroupClearBits(mqtt_event_group, MQTT_CONNECTED_BIT);
            break;
        case MQTT_EVENT_DATA:
            ESP_LOGI(TAG, "MQTT data received, topic=%.*s", event->topic_len, event->topic);
            // 处理下行的配置命令
            break;
        default:
            break;
    }
}

// -------------------- MQTT 初始化 --------------------
static void mqtt_init(void) {
    mqtt_event_group = xEventGroupCreate();
    esp_mqtt_client_config_t mqtt_cfg = {
        .broker.address.uri = MQTT_BROKER_URI,
        .credentials.client_id = MQTT_CLIENT_ID,
        .credentials.username = MQTT_USERNAME,
        .credentials.authentication.password = MQTT_PASSWORD,
        .network.timeout_ms = 10000,
        .session.keepalive = 60,
    };
    mqtt_client = esp_mqtt_client_init(&mqtt_cfg);
    esp_mqtt_client_register_event(mqtt_client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL);
    esp_mqtt_client_start(mqtt_client);
}

// -------------------- MQTT 发布数据 (JSON) --------------------
static void mqtt_publish_data(sensor_data_t *data) {
    if (!(xEventGroupGetBits(mqtt_event_group) & MQTT_CONNECTED_BIT)) {
        ESP_LOGW(TAG, "MQTT not connected, skip publish");
        return;
    }

    cJSON *root = cJSON_CreateObject();
    cJSON_AddStringToObject(root, "node_id", data->node_id);
    cJSON_AddNumberToObject(root, "temperature", data->temperature);
    cJSON_AddNumberToObject(root, "ph", data->ph);
    cJSON_AddNumberToObject(root, "tds", data->tds);
    cJSON_AddNumberToObject(root, "turbidity", data->turbidity);
    cJSON_AddNumberToObject(root, "battery_voltage", data->battery_voltage);
    cJSON_AddBoolToObject(root, "solar_charging", data->solar_charging);
    cJSON_AddNumberToObject(root, "timestamp", time(NULL));

    char *json_str = cJSON_PrintUnformatted(root);
    esp_mqtt_client_publish(mqtt_client, MQTT_TOPIC_PUB, json_str, 0, 1, 0);
    ESP_LOGI(TAG, "Published: %s", json_str);

    cJSON_Delete(root);
    free(json_str);
}

// -------------------- 4G UART 初始化 (Air780e) --------------------
static void uart_4g_init(void) {
    uart_config_t uart_config = {
        .baud_rate = UART_4G_BAUD,
        .data_bits = UART_DATA_8_BITS,
        .parity = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
    };
    uart_param_config(UART_4G_NUM, &uart_config);
    uart_set_pin(UART_4G_NUM, PIN_4G_TX, PIN_4G_RX, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
    uart_driver_install(UART_4G_NUM, 1024, 1024, 0, NULL, 0);

    gpio_set_direction(PIN_4G_PWR, GPIO_MODE_OUTPUT);
    gpio_set_direction(PIN_4G_RST, GPIO_MODE_OUTPUT);
    // 上电4G模块
    gpio_set_level(PIN_4G_PWR, 1);
    vTaskDelay(pdMS_TO_TICKS(1000));
    gpio_set_level(PIN_4G_RST, 1);
    vTaskDelay(pdMS_TO_TICKS(2000));
    ESP_LOGI(TAG, "4G module powered on");

    // 发送AT指令初始化
    send_4g_at_command("AT\r\n");
    vTaskDelay(pdMS_TO_TICKS(500));
    send_4g_at_command("AT+CPIN?\r\n");
    vTaskDelay(pdMS_TO_TICKS(500));
    send_4g_at_command("AT+CGREG?\r\n");
}

// -------------------- 发送4G AT指令 --------------------
static void send_4g_at_command(const char *cmd) {
    uart_write_bytes(UART_4G_NUM, cmd, strlen(cmd));
    ESP_LOGI(TAG, "4G AT: %s", cmd);
    // 实际需要读取响应
}

// -------------------- 检查4G网络连接 --------------------
static void check_4g_connection(void) {
    // 发送AT+CSQ查询信号质量
    send_4g_at_command("AT+CSQ\r\n");
    // 实际需解析响应
}

// -------------------- 传感器读取任务 --------------------
static void sensor_read_task(void *pvParameters) {
    while (1) {
        sensor_data.temperature = read_temperature();
        sensor_data.ph = read_ph();
        sensor_data.tds = read_tds();
        sensor_data.turbidity = read_turbidity();
        sensor_data.battery_voltage = read_battery_voltage();
        sensor_data.solar_charging = read_solar_charging();

        ESP_LOGI(TAG, "Sensors: T=%.2f pH=%.2f TDS=%.2f Turb=%.2f Bat=%.2fV Solar=%d",
                 sensor_data.temperature, sensor_data.ph, sensor_data.tds,
                 sensor_data.turbidity, sensor_data.battery_voltage, sensor_data.solar_charging);

        vTaskDelay(pdMS_TO_TICKS(SAMPLE_INTERVAL_MS));
    }
}

// -------------------- MQTT 上报任务 --------------------
static void mqtt_report_task(void *pvParameters) {
    while (1) {
        // 优先使用WiFi,若WiFi不可用则尝试4G
        if (wifi_connect_status() != ESP_OK) { // 简化检查
            ESP_LOGI(TAG, "WiFi not connected, using 4G");
            check_4g_connection();
            // 通过4G发送数据 (通过AT指令MQTT)
            // 此处简化: 仍然通过MQTT客户端,实际4G需走AT指令
        }
        mqtt_publish_data(&sensor_data);
        vTaskDelay(pdMS_TO_TICKS(REPORT_INTERVAL_MS));
    }
}

// 简化WiFi状态检查
static esp_err_t wifi_connect_status(void) {
    // 实际需使用esp_wifi_connect()状态
    return ESP_OK; // 假设已连接
}

// -------------------- OLED 显示任务 --------------------
static void oled_display_task(void *pvParameters) {
    while (1) {
        oled_show_data(&sensor_data);
        vTaskDelay(pdMS_TO_TICKS(3000));
    }
}

// -------------------- 太阳能充电管理 (低功耗) --------------------
static void solar_power_management_task(void *pvParameters) {
    while (1) {
        bool charging = read_solar_charging();
        float voltage = read_battery_voltage();
        ESP_LOGI(TAG, "Solar: %s, Battery: %.2fV", charging ? "Charging" : "Not Charging", voltage);
        // 低电量报警
        if (voltage < 3.3 && !charging) {
            buzzer_beep(3, 200);
            ESP_LOGW(TAG, "Low battery! Voltage: %.2fV", voltage);
        }
        vTaskDelay(pdMS_TO_TICKS(30000));
    }
}

// -------------------- 主函数 --------------------
void app_main(void) {
    // 初始化硬件
    adc_init();
    gpio_set_direction(PIN_BUZZER, GPIO_MODE_OUTPUT);
    gpio_set_direction(PIN_SOLAR_CHARGE, GPIO_MODE_INPUT);
    gpio_set_pull_mode(PIN_SOLAR_CHARGE, GPIO_PULLDOWN_ONLY);
    i2c_oled_init();

    // 初始化网络
    wifi_init();
    uart_4g_init();
    mqtt_init();

    // 启动任务
    xTaskCreate(sensor_read_task, "sensor_task", 4096, NULL, 5, NULL);
    xTaskCreate(mqtt_report_task, "mqtt_report", 8192, NULL, 4, NULL);
    xTaskCreate(oled_display_task, "oled_task", 4096, NULL, 3, NULL);
    xTaskCreate(solar_power_management_task, "solar_task", 4096, NULL, 2, NULL);

    ESP_LOGI(TAG, "System initialized, node %s", sensor_data.node_id);
    buzzer_beep(2, 150);

    // 主循环保持运行
    while (1) {
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

4.3 程序下载

也有视频教程:

讲解如何编译代码,下载STM32程序: https://www.bilibili.com/video/BV1Cw4m1e7Yc

打STM32的keil工程,编译代码、然后,使用USB线将开发板的左边的USB口(串口1)与电脑的USB连接,打开程序下载软件下载程序。

具体下载过程看下面图:

image-20240319223247836

打开程序下载软件:[软件就在资料包里的软件工具目录下]

image-20240120160735942

4.4 程序正常运行效果

设备运行过程中会通过串口打印调试信息,我们可以通过串口打印了解程序是否正常。

程序下载之后,可以打开串口调试助手查看程序运行的状态信息。[软件就在资料包里的软件工具目录下]

image-20240327212042050

4.5 取模软件的使用

显示屏上会显示中文,字母,数字等数据,可以使用下面的取模软件进行取模设置。

[软件就在资料包里的软件工具目录下]

image-20241024142522970

打开软件之后:

image-20241127212320020

五、总结

本项目完成了基于ESP32设计的河流水质检测装置的整体方案设计与功能实现,围绕河流水环境实时监测需求,构建了一套集数据采集、本地显示、无线传输、云平台管理以及远程访问于一体的智能化水质监测系统。系统以ESP32-S3作为核心控制器,结合水温检测、PH检测、电导率检测以及浊度检测等传感器,实现对河流水质关键参数的实时获取与处理。

在系统实现过程中,完成了硬件电路搭建、传感器接口设计、嵌入式软件开发以及通信功能集成,实现了现场数据采集与显示功能。同时,系统利用WiFi与4G双通信方式,通过MQTT协议接入华为云物联网平台,实现监测数据的远程上传与集中管理。用户能够通过微信小程序、Android移动端以及Windows上位机查看设备状态和历史监测数据,提高了系统使用便利性和数据获取效率。针对户外长期部署需求,系统采用太阳能供电方案,增强了设备在无人值守环境下的持续运行能力。

经过整体设计与功能验证,系统能够稳定完成多项水质参数检测,并支持多节点设备统一管理,满足河流水环境长期监测与远程管理需求。相比传统人工采样检测方式,本设计提高了监测实时性、自动化程度以及信息化水平,降低了人工维护成本。

本项目将嵌入式技术、物联网通信技术、云平台技术与移动应用技术进行融合,实现了河流水质监测场景下的数据感知与远程互联,为智慧环保、水资源管理以及生态环境监测提供了一种具有实际应用价值的设计方案,同时也为后续相关环境监测系统的优化与扩展提供了参考基础。

Logo

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

更多推荐