• 正文
  • 相关推荐
申请入驻 产业图谱

STM32+华为云IoT+Qt:手把手教你打造跨平台智能家居控制系统

6小时前
270
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

本文从零开始,完整讲解一套基于 STM32F103C8T6 主控、ESP8266 联网、华为云 IoTDA 数据中转、Qt 跨平台上位机智能家居控制系统。硬件涵盖温湿度、光照、烟雾、火焰、人体感应、语音识别、红外空调、步进电机窗帘、继电器灯光风扇、OLED 本地显示等十余个模块;软件涵盖 STM32 裸机/FreeRTOS 驱动、MQTT 上报下发、Qt 上位机(Windows exe + Android APK)。全文代码注释逐行解释,硬件连线表格化,零基础读者可照着重现。

一、前言

1.1 项目开发背景

近年来,智能家居已从概念验证迈向大规模落地。以米家、华为全屋智能、Apple HomeKit 为代表的成熟生态体系,在用户体验层面表现日趋完善,但其封闭性亦日益显著:设备深度绑定特定云平台,跨品牌协同受制于生态壁垒;同时,二次开发接口受限,用户难以接入自定义传感器或开发私有控制界面,限制了系统的可扩展性与个性化空间。

针对电子工程、物联网嵌入式方向的开发者与高阶爱好者,自主构建一套完整的智能家居系统,是一项兼具工程价值与教学意义的综合实训项目。该项目能够系统性地覆盖单片机外设驱动、传感器数据采集、Wi-Fi 网络接入、MQTT 消息传输、云平台资源管理,以及上位机/移动端应用开发等关键技术环节,完整贯穿“端—管—云—应用”四层物联网架构。通过全流程实践,开发者可形成对物联网系统级设计的直观认知与深入理解。

本设计选用 STM32F103C8T6 为主控芯片。其 Cortex-M3 内核主频达 72 MHz,内置 64 KB Flash 与 20 KB SRAM,外设资源充沛,足以驱动本系统所涉及的各功能模块。网络通信方面,采用 ESP8266 作为 Wi-Fi 透传模块,通过 AT 指令集实现与路由器的连接,并基于 MQTT 协议对接华为云物联网设备接入服务(IoTDA)。云端平台承载设备注册管理、属性数据持久化及下行命令分发等核心职责。应用层采用 Qt 框架进行开发,同一套源码可编译部署于 Windows 桌面端,亦可交叉编译生成 Android 移动端应用,真正实现业务逻辑与界面层的高效复用与跨平台交付。

1.2 设计思路

项目启动阶段,开发者需要先把整体技术路线摸清楚。这一步如果直接闷头画板子、写代码,后期大概率返工。比较高效的做法是先找几套成熟的开源方案做参照,了解别人是怎么选型、怎么组织代码、怎么处理云平台通信的。

整体设计思路可以概括成三句话:

1. 端侧:STM32 轮询/中断采集各路传感器,驱动执行器,通过 ESP8266 以 MQTT 协议把 JSON 数据上报到华为云,同时订阅下行主题接收远程命令。

2. 云侧:华为云 IoTDA 维护产品模型和设备影子,提供 HTTPS API 给上位机查询影子、下发命令。

3. 应用侧:Qt 上位机定时调用云端 API 拉取设备影子刷新界面,用户点击按钮时通过 API 下发命令,命令经云端 MQTT 转发到设备。

语音控制走本地链路:海凌科 V20 模块识别到口令后通过串口把编码发给 STM32,STM32 直接驱动对应执行器,不依赖网络,断网也能用。

1.3 设计实现的功能

本项目实现以下 9 项功能:

1. 语音指令控制:海凌科 V20 语音识别模块识别"打开风扇""关闭空调"等口令,通过串口返回编码,STM32 解析后控制风扇、空调、窗帘、卧室灯、客厅灯、电视的开关

2. 远程命令控制:Android 手机 APP 和 Windows 电脑上位机通过华为云 API 下发命令,远程控制上述 6 路设备。

3. SHT30 温湿度检测I2C 总线采集环境温度、湿度,数据本地 OLED 显示并上报云端。

4. BH1750 光照强度检测:I2C 总线采集环境光强(单位 lx),本地显示并上报。

5. MQ2 烟雾浓度检测与报警ADC 采集模拟量,超过阈值时蜂鸣器报警,同时上报报警状态,APP 弹窗提醒。

6. 火焰检测与报警:数字 DO 引脚检测火光,触发时蜂鸣器报警,上报报警状态,APP 弹窗提醒。

7. OLED 本地显示:0.96 寸 SSD1306 OLED 分屏显示传感器数据和 6 路电器开关状态。

8. ESP8266 云端通信:通过 AT 指令连接 WiFi,MQTT 连接华为云 IoTDA,定时上报 JSON 数据,订阅下行主题接收命令。

9. SR602 人体感应:红外热释电检测人体移动,可用于联动灯光或安防上报。

1.4 项目硬件模块组成

全套硬件共 16 种,清单如下:

序号 模块 型号/说明 通信方式 用途
1 主控板 STM32F103C8T6 最小系统 核心控制
2 OLED 显示屏 0.96 寸 I2C,SSD1306,地址 0x3C 软件 I2C(PB6/PB7) 本地数据显示
3 空调控制 红外学习模块(成品) 串口 UART3(PB10/PB11) 学习并发送空调红外码
4 风扇 5V 小风扇 + 继电器模块 GPIO(PA4) 继电器控制通断
5 窗帘 28BYJ48 步进电机 + ULN2003 驱动板 4 路 GPIO(PB12~PB15) 正反转开关窗帘
6 卧室灯 USB 接口白色 LED 灯 + 继电器 GPIO(PA6) 继电器控制
7 客厅灯 USB 接口白色 LED 灯 + 继电器 GPIO(PA7) 继电器控制
8 光强传感器 BH1750,地址 0x23 软件 I2C(PB6/PB7) 环境光强采集
9 WiFi 模块 ESP8266(ESP-01 或 ESP-12F) 串口 UART1(PA9/PA10) MQTT 联网
10 电视模拟 OLED 屏幕指定区域 矩形区域显示 TV ON/OFF
11 温湿度传感器 SHT30,地址 0x44 软件 I2C(PB6/PB7) 温度湿度采集
12 语音识别 海凌科 V20 串口 UART2(PA2/PA3) 识别口令返回编码
13 烟雾传感器 MQ2 气体传感器 ADC1_IN0(PA0) 烟雾浓度采集
14 蜂鸣器 高电平触发有源蜂鸣器 GPIO(PB5) 报警提示
15 火焰传感器 火光传感器模块(DO/AO) DO 接 PA1(低电平触发) 火焰检测
16 人体感应 SR602 红外热释电 GPIO(PA5) 人体移动检测

说明:I2C 总线上挂了 OLED(0x3C)、BH1750(0x23)、SHT30(0x44)三个设备,地址互不冲突,可以共用 PB6/PB7 一组软件 I2C,节省引脚。

1.5 系统架构图

整套系统分为设备层、网络层、云平台层、应用层四个部分。

数据流向:

上行:传感器 → STM32 → ESP8266 → MQTT → 华为云 → 设备影子 → Qt 上位机/APP(HTTPS GET)。•

下行(远程):Qt 按钮 → HTTPS POST 命令下发 API → 华为云 → MQTT 下行主题 → ESP8266 → STM32 → 执行器。•

下行(本地语音):人说话 → V20 识别 → 串口 → STM32 → 执行器(不经过云端)。

1.6 运行流程图

设备上电后的运行流程:

[图片:运行流程图,建议流程:上电→初始化时钟/GPIO/I2C/ADC/串口/OLED→ESP8266 复位→AT 指令连 WiFi→MQTT 配置鉴权→MQTT 连接华为云→订阅下行主题→进入主循环→采集传感器→刷新 OLED→构造 JSON 上报→检测烟雾/火焰报警→解析串口语音命令→解析 MQTT 下行命令→驱动执行器→延时回到循环起点]

主循环以 1 秒为节拍,每秒完成一次传感器采集、OLED 刷新和数据上报;语音命令和 MQTT 下行命令在串口接收中断/轮询中实时处理,保证响应速度。

1.7 源码与开发工具下载

项目开发使用的全部软件工具已经上传到网盘:https://ccnr8sukk85n.feishu.cn/wiki/QjY8weDYHibqRYkFP2qcA9aGnvb?from=from_copylink

网盘资料共享【1】「STM32传感器源码和文档【带完整源码+文档】」
链接:https://pan.quark.cn/s/fe4b3ad8ee10

网盘资料共享【2】「STM32开发项目源码和入门到精通的学习教程」
链接:https://pan.quark.cn/s/0e22b7ded687

网盘资料共享【3】「零基础复刻STM32项目视频讲解」
链接:https://pan.quark.cn/s/ab37d44bf729

网盘资料共享【4】STM32、物联网开发项目案例与视频教程:
链接:https://pan.quark.cn/s/145a9b3f7f53


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

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

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

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

开通免费单元。

点击立即创建

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

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

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

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

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

总结:

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

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

打开 Windows 电脑的命令行控制台终端,使用 ping 命令解析域名。

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

C:UsersLenovo>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:UsersLenovo>

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

本项目最终使用的接入信息:

MQTT 服务器地址:117.78.5.125
MQTT 端口号:    1883

2.3 创建产品

链接:https://console.huaweicloud.com/iotdm/?region=cn-north-4#/dm-dev/all-product

(1)创建产品

(2)填写产品信息

根据自己产品名字填写,设备类型选择自定义类型。本项目产品名称填 STM32智能家居,协议类型选 MQTT,数据格式选 JSON

(3)产品创建成功

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

(4)添加自定义模型

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

模型简单来说就是存放设备上传到云平台的数据结构。比如烟雾可以叫 MQ2,温度可以叫 SHT30_T,湿度可以叫 SHT30_H,火焰可以叫 FLAME。这些字段名就是单片机设备端上传到服务器的数据名字,必须和代码里 JSON 的 key 完全一致。

先点击自定义模型。

再创建一个服务 ID。本项目服务 ID 填 stm32

接着点击新增属性,逐个添加 12 个属性。

2.4 添加设备

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

(1)注册设备

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

设备名称填 dev1,所属产品选择刚才创建的 STM32智能家居,设备标识码填 dev1

(3)保存设备信息

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

(4)设备创建完成

(5)设备详情

本项目的设备 ID 为 69194919bf22cc5a8c0707ce_dev1,设备密钥为 12345678

2.5 MQTT 协议主题订阅与发布

(1)MQTT 协议介绍

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

MQTT 是一个物联网传输协议,被设计用于轻量级的发布/订阅式消息传输,旨在为低带宽和不稳定的网络环境中的物联网设备提供可靠的网络服务。MQTT 针对低带宽网络、低计算能力的设备做了特殊优化,能适应各种物联网应用场景。

MQTT 使用发布/订阅消息模式,提供一对多的消息发布,解除应用程序耦合,相对于其他协议开发更简单;MQTT 工作在 TCP/IP 协议之上,由 TCP/IP 提供稳定的网络连接。本次设备采用的 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 进行消息发布与订阅
每个订阅请求的最大订阅数 无限制

工程提示:心跳时间不要设得太短。ESP8266 在弱网下重连频繁,心跳设为 120 秒比较稳妥;吞吐量限制 3KB/s 对本项目每秒一条几百字节的 JSON 完全够用。

(3)主题订阅格式

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

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

本项目最终订阅主题:

$oc/devices/69194919bf22cc5a8c0707ce_dev1/sys/messages/down

(4)主题发布格式

主题发布表示向云平台上传数据,将最新的传感器数据、设备状态上传到云平台,这个操作称为属性上报。

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

本项目发布主题:

$oc/devices/69194919bf22cc5a8c0707ce_dev1/sys/properties/report

发布主题时需要上传数据,数据格式是 JSON。格式如下:

{
  "services": [
    {
      "service_id": "stm32",
      "properties": {
        "LED1": 0,
        "LED2": 0,
        "BH1750": 24.5,
        "SW1": 0,
        "SW2": 0,
        "SW3": 0,
        "SW4": 0,
        "SHT30_T": 30.2,
        "SHT30_H": 24.5,
        "MQ2": 10.1,
        "BEEP": 0,
        "FLAME": 0
      }
    }
  ]
}

字段名、类型必须与产品模型中定义的 12 个属性完全一致,否则平台会丢弃或报属性不匹配。

2.6 MQTT 三元组

MQTT 协议登录需要填客户端 ID、用户名、密码等信息,类似登录 QQ、微信要输入账号密码。MQTT 协议登录的这 3 个参数一般称为 MQTT 三元组。

(1)MQTT 服务器地址

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

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

本项目最终的服务器信息:

MQTT 服务器地址:117.78.5.125
MQTT 端口号:    1883

(2)生成 MQTT 三元组

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

打开工具,填入设备信息(设备创建完成后保存得到的 device_id 和密钥),点击生成即可得到 MQTT 登录信息。

下面是打开的页面:

填入设备信息后得到三元组:

本项目真实三元组如下,设备端代码里直接使用:

ClientId : 69194919bf22cc5a8c0707ce_dev1_0_0_2025111603
UserName : 69194919bf22cc5a8c0707ce_dev1
Password : ab0de61dafe7741d44d0fef981a941bf8e2c297a2698fd438abcbaf107d75ce4

时间戳后缀 2025111603 是三元组生成时填入的时间,开发者重新生成时后缀会变化,以自己工具生成的为准。本项目代码使用上面给出的这一组。

2.7 模拟设备登录测试

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

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

(1)填入登录信息

打开 MQTT 客户端软件,填入服务器地址、端口、ClientId、UserName、Password,点击登录,订阅下行主题,向发布主题上报一条 JSON。

(2)打开网页查看

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

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

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

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

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

// 物联网服务器的设备信息
#define MQTT_ClientID "69194919bf22cc5a8c0707ce_dev1_0_0_2025111603"
#define MQTT_UserName "69194919bf22cc5a8c0707ce_dev1"
#define MQTT_PassWord "ab0de61dafe7741d44d0fef981a941bf8e2c297a2698fd438abcbaf107d75ce4"

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

// 上报的数据
{"services": [{"service_id": "stm32","properties":{"LED1":0,"LED2":0,"BH1750":24.5,"SW1":0,"SW2":0,"SW3":0,"SW4":0,"SHT30_T":30.2,"SHT30_H":24.5,"MQ2":10.1,"BEEP":0,"FLAME":0}}]}

2.8 创建 IAM 账户

开发上位机需要调用云平台的 API 接口,这些接口需要 token 鉴权。调用接口获取 Token 时需要填写 IAM 账号信息,下面演示创建过程。

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

【1】获取项目凭证

点击左上角用户名,选择下拉菜单里的我的凭证

本项目的项目凭证(project id):

27f7663400f145449d295ef6ee1eb5ab

【2】创建 IAM 用户

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

点击左上角创建用户

创建成功:

【3】创建完成

本项目用户信息如下:

主用户名 : hid_zay6txvrbcku-74
IAM 用户 : ds_abc
密码     : DS12345678

安全提示:上述账号密码仅用于本教程演示,实际项目请使用自己的 IAM 账号,不要把主账号密码硬编码进上位机。Token 有效期一般 24 小时,代码里要做过期刷新。

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

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

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

本项目设备影子返回的数据格式如下(reported 即设备最新上报):

{
  "device_id": "69194919bf22cc5a8c0707ce_dev1",
  "shadow": [
    {
      "service_id": "stm32",
      "desired": {
        "properties": null,
        "event_time": null
      },
      "reported": {
        "properties": {
          "LED1": 0,
          "LED2": 0,
          "BH1750": 123.5,
          "SW1": 0,
          "SW2": 1,
          "SW3": 0,
          "SW4": 0,
          "SHT30_T": 28.6,
          "SHT30_H": 55.2,
          "MQ2": 10.1,
          "BEEP": 0,
          "FLAME": 0
        },
        "event_time": "20251116T030000Z"
      },
      "version": 3
    }
  ]
}

调试成功后可得到访问影子数据的真实链接,Qt 代码中访问该链接获取影子数据。

本项目影子查询链接:

https://dab1a1f2c6.st1.iotda-app.cn-north-4.myhuaweicloud.com:443/v5/iot/27f7663400f145449d295ef6ee1eb5ab/devices/69194919bf22cc5a8c0707ce_dev1/shadow

其中 27f7663400f145449d295ef6ee1eb5ab 是项目凭证,69194919bf22cc5a8c0707ce_dev1 是设备 ID。

2.10 产品模型属性定义

本项目产品模型共定义 12 个属性,服务 ID 统一为 stm32。在产品详情页"模型定义"中逐个新增,配置如下:

属性名 说明 数据类型 取值范围/单位 访问权限
LED1 卧室灯 布尔(boolean) 0/1 可读可写
LED2 客厅灯 布尔(boolean) 0/1 可读可写
SW1 风扇开关 布尔(boolean) 0/1 可读可写
SW2 空调开关 布尔(boolean) 0/1 可读可写
SW3 窗帘开关 布尔(boolean) 0/1 可读可写
SW4 电视开关 布尔(boolean) 0/1 可读可写
BEEP 蜂鸣器 布尔(boolean) 0/1 可读可写
FLAME 火焰监测 布尔(boolean) 0 正常/1 报警 可读
BH1750 环境光强 浮点数(decimal) 0~65535 lx 可读
SHT30_T 环境温度 浮点数(decimal) -40~125 ℃ 可读
SHT30_H 环境湿度 浮点数(decimal) 0~100 %RH 可读
MQ2 烟雾检测 浮点数(decimal) 0~100 % 可读

新增属性时需要注意几点:

1. 属性名大小写敏感。代码 JSON 里写的是 SHT30_T,模型里就必须是 SHT30_T,不能写成 sht30_t

2.布尔类型取值 0/1。华为云 JSON 里布尔型可以写 true/false,也可以写 0/1,本项目统一用整型 0/1,单片机拼接字符串更方便。

3. 读写权限。LED1、LED2、SW1~SW4、BEEP 是执行器,需要云端下发控制,设为"可读可写";BH1750、SHT30_T、SHT30_H、MQ2、FLAME 是传感器数据,只需要上报,设为"只读"即可。

4. 单位。温度单位填 ℃,湿度填 %RH,光强填 lx,方便后台图表显示。

属性全部添加完成后,产品模型页面会列出 12 行属性,截图保存,方便后续对照代码检查。


三、硬件选型与电路设计

硬件选型决定了项目 70% 的难度。选得好,驱动成熟、资料多、引脚不冲突;选得偏,光调时序就能耗掉一周。本章逐个介绍本项目用到的 16 种硬件,给出关键参数、接线思路和选型理由。

3.1 主控 STM32F103C8T6

STM32F103C8T6 是意法半导体(ST)推出的一款入门级 Cortex-M3 单片机,也是国内嵌入式学习最流行的型号之一,淘宝上的"蓝色药丸"最小系统板只要十块钱左右。关键参数:

    • 内核:ARM 32-bit Cortex-M3,最高主频 72MHz• Flash:64KB(实际多数芯片可超频到 128KB)• RAM:20KB• 封装:LQFP48,共 48 个引脚,可用 GPIO 约 37 个• 供电:2.0~3.6V,常用 3.3V• 外设:2 个 12 位 ADC(最多 16 个外部通道)、3 个通用定时器、1 个高级定时器、2 个 I2C、3 个 USART、2 个 SPI、USB 2.0 全速、CAN• 调试:SWD 接口(PA13/PA14)

本项目需要 3 个串口(ESP8266、V20、红外学习)、1 路 ADC(MQ2)、1 组 I2C(3 个设备)、10 个左右普通 GPIO(继电器、蜂鸣器、火焰、人体、步进电机 4 路),F103C8T6 完全够用。

选型理由:价格低、资料多、Keil/STM32CubeMX 支持成熟,适合教学和 DIY。如果后续想跑更复杂的 UI 或加摄像头,可以升级到 F4 或 H7 系列,引脚兼容的 F103RCT6 是最省事的升级路径。

3.2 SHT30 温湿度传感器

SHT30 是盛思锐(Sensirion)推出的第三代数字温湿度传感器,I2C 接口,精度和稳定性远超老式 DHT11。关键参数(来自数据手册,工程师在 Datasheet5 上核对过):

    • 供电电压:2.15~5.5V,本项目接 3.3V• 温度精度:±0.2℃(典型值,0~60℃ 范围)• 湿度精度:±2%RH(典型值,10~90%RH 范围)• 温度范围:-40~125℃• I2C 地址:0x44(ADDR 引脚接 GND)或 0x45(ADDR 接 VDD)• 通信速率:最高 1MHz• 完全校准、数字输出、内置线性化和温度补偿

SHT30 的 I2C 时序比较简单:主机发送 16 位测量命令(单次测量模式常用 0x2C06),等待约 15ms,再读取 6 个字节(温度 MSB/LSB/CRC、湿度 MSB/LSB/CRC),按手册公式换算即可。代码里带 CRC 校验更稳妥。

Datasheet5 上能直接搜到 SHT30 的英文数据手册和应用笔记,还列出了 SHT31、SHT35、AHT20 等替代料及其参数差异,对做成本优化或找国产替代很有帮助。

3.3 BH1750 光强传感器

BH1750 是罗姆(ROHM)推出的数字光强传感器,I2C 接口,直接输出照度值(单位 lx),不需要自己换算。关键参数:

    • 供电电压:2.4~3.6V,接 3.3V• 测量范围:1~65535 lx• 分辨率:1 lx(高分辨率模式)• I2C 地址:0x23(ADDR 引脚接 GND 或悬空)或 0x5C(ADDR 接 VDD)• 测量模式:连续高分辨率模式(0x10)、一次高分辨率模式(0x20)等• 功耗:掉电模式 0.01μA,测量时约 195μA

驱动流程:上电 → 发送连续测量命令 0x10 → 等待 180ms → 读取 2 字节 → 照度 = (MSB<<8 | LSB) / 1.2。

BH1750 与 SHT30、OLED 共用一组 I2C,三者地址 0x23、0x44、0x3C 互不冲突,这是选这组传感器的一个重要原因——省引脚、省布线。

3.4 MQ2 烟雾传感器

MQ2 是汉威电子生产的金属氧化物半导体(MOS)气体传感器,对液化气、丙烷、氢气、甲烷、烟雾等可燃气体都有响应,是做火灾烟雾报警最常用的廉价传感器。关键参数:

    • 供电电压:5.0V±0.2V(加热丝必须 5V)• 负载电阻:可调,模块板载电位器• 加热功耗:约 800mW• 响应时间:≤10s• 恢复时间:≤30s• 输出:AO 模拟电压(0~5V)、DO 数字开关量(阈值由板载电位器调节)• 预热时间:约 20s~3min(金属氧化物传感器需要预热才能稳定)

本项目用 ADC 采集 AO 模拟量,软件里换算成百分比浓度。MQ2 的 AO 输出电压 05V,而 STM32 的 ADC 输入范围是 03.3V,不能直接接!需要加分压电路(两个电阻把 5V 降到 3.3V 以内),或者买带板载分压的模块。本项目用的模块已做分压,AO 直接接 PA0。

工程提示:MQ2 上电后必须预热,预热期间读数不准。代码里上电后延时 20 秒再开始采集,或者在 OLED 上显示"传感器预热中"。阈值不要写死,可通过云端下发或按键调节,因为不同环境基线差异很大。

3.5 火焰传感器

火焰传感器(火光传感器)模块基于红外接收管,对 760~1100nm 波长的红外光敏感,能探测火焰发出的红外辐射。模块一般有两个输出:

    • • DO(数字量):板载比较器判断,当火焰强度超过电位器设定阈值时输出电平翻转。绝大多数模块是

检测到火焰时 DO 输出低电平

    ,平时为高电平。代码里要注意极性。• AO(模拟量):输出与红外强度成比例的电压,可接 ADC 做更精细判断。

关键参数:

    • 供电电压:3.3~5V• 探测角度:约 60 度• 探测距离:约 0.5~1.5m(取决于火焰大小和电位器灵敏度)• 输出:DO 数字量、AO 模拟量

本项目把 DO 接到 PA1,GPIO 输入下拉/上拉根据模块极性配置。代码检测到 PA1 为低电平时判定有火焰,触发蜂鸣器并上报 FLAME=1。AO 可按需接到另一个 ADC 通道做强度显示,本项目未使用。

注意:火焰传感器对太阳光、白炽灯、电焊弧光等也会响应,实际部署要避开强光直射,或者软件里结合 MQ2 烟雾做双重判定减少误报。

3.6 SR602 人体感应传感器

SR602 是一款小型红外热释电人体感应模块,比常见的 HC-SR501 体积更小、接线更简单。关键参数:

    • 供电电压:2.7~3.3V(SR602 是 3.3V 版本,HC-SR501 是 5V,不要买错)• 静态电流:<50μA• 延时时间:约 2~3 秒(不可调版本)• 触发方式:可重复触发• 输出:检测到人体移动输出高电平 3.3V,延时后变低• 感应距离:约 3~5m• 感应角度:约 100 度

SR602 只有 3 个引脚:VCC、GND、OUT。OUT 接 STM32 的 PA5,GPIO 配置为输入。检测到高电平表示有人移动。本项目把人体感应状态上报云端,可用于回家自动开灯、离家布防等场景联动。

PIR 传感器上电后有约 1 分钟初始化时间,期间会频繁触发,属正常现象。代码里上电后屏蔽 60 秒再开始判断。

3.7 0.96 寸 OLED 显示屏

本项目用 0.96 寸 OLED 做本地显示,控制器 SSD1306,分辨率 128×64,I2C 接口,地址 0x3C。OLED 自发光、对比度高、不需要背光,3.3V 供电就能工作,非常适合嵌入式小屏。

显示内容较多(标题、3 个传感器数据、火焰/报警状态、6 路设备状态),一屏 64 像素高度放不下全部中文。本项目采用紧凑布局:字号 8×16 显示中文,6×8 显示英文数字,分两行标题区+五行数据区,必要时两页轮播(第 1 页传感器数据,第 2 页设备状态),每 3 秒切换。

中文显示需要取模。使用 PCtoLCD2002 或取模软件,把用到的汉字("智能家居控制系统环境温度湿度烟雾光强火焰报警卧室灯客厅灯风扇空调电视窗帘开启关闭正常")取成 16×16 点阵数组,存到 Flash 里。取模方式:阴码、逐行式、顺向、C51 格式。具体使用方法见 4.9 节。

3.8 ESP8266 WiFi 模块

ESP8266 是乐鑫信息科技推出的 WiFi 芯片,本项目用 ESP-01S 或 ESP-12F 模块,通过 AT 指令与 STM32 通信,负责连接路由器和 MQTT 服务器。关键参数:

    • • 供电电压:3.3V(

峰值电流可达 300mA 以上,必须用能输出 500mA 以上的 3.3V LDO,不能直接从 STM32 最小系统板的 3.3V 取电,否则会反复重启

    )• 串口:AT 指令串口默认 115200 8N1• 固件:建议刷官方 AT 固件(V1.7 以上支持 MQTT 指令)• 工作模式:STA 模式(连接家里路由器)

ESP8266 与 STM32 的接线:

ESP8266 STM32 说明
VCC 3.3V(独立 LDO) 电源,电流要够
GND GND 共地
TX PA10(USART1_RX) ESP8266 发,STM32 收
RX PA9(USART1_TX) STM32 发,ESP8266 收,注意电平 3.3V
EN/CH_PC 3.3V(上拉) 使能,必须拉高
RST 可选接 GPIO 硬件复位,不接可软件复位

很多初学者踩坑:STM32 的 TX 直接接 ESP8266 的 RX,电平是 3.3V 没问题;但如果用 5V  Arduino 就要分压。另外 ESP8266 的 GND 必须和 STM32 共地,否则串口乱码。

AT 指令连接 MQTT 的完整序列在第四章 esp8266.c 里给出,核心步骤:AT+RST 复位 → AT+CWMODE=1 设 STA 模式 → AT+CWJAP 连 WiFi → AT+MQTTUSERCFG 配置 MQTT 客户端 ID/用户名/密码 → AT+MQTTCONN 连接华为云 → AT+MQTTSUB 订阅下行主题 → AT+MQTTPUB 发布 JSON。

3.9 海凌科 V20 语音识别模块

海凌科 V20 是一款离线语音识别模块,内置识别引擎,不需要联网、不需要训练,出厂预设了多条语音命令,支持串口输出识别结果。模块自带麦克风,识别率在安静环境下不错。关键参数:

    • 供电电压:3.3~5V• 串口波特率:默认 9600(部分版本 115200,以模块手册为准)• 识别距离:约 1~3m• 输出方式:串口输出识别编码• 工作电流:约 40mA

V20 模块通常有两种工作模式:一种是模块自带命令词(如"打开风扇""关闭风扇"),识别后通过串口发送对应编码;另一种是支持自定义命令词。本项目使用预设命令模式,模块通过 USART2(PA2/PA3)与 STM32 通信。

语音命令编码映射表(具体编码以模块手册为准,不同固件版本可能不同,下表为示例映射):

语音命令 模块返回编码(示例) STM32 执行动作
打开风扇 0x01 SW1=1,继电器吸合
关闭风扇 0x02 SW1=0,继电器断开
打开空调 0x03 SW2=1,红外模块发送开机码
关闭空调 0x04 SW2=0,红外模块发送关机码
打开窗帘 0x05 SW3=1,步进电机正转
关闭窗帘 0x06 SW3=0,步进电机反转
打开卧室灯 0x07 LED1=1,继电器吸合
关闭卧室灯 0x08 LED1=0,继电器断开
打开客厅灯 0x09 LED2=1,继电器吸合
关闭客厅灯 0x0A LED2=0,继电器断开
打开电视 0x0B SW4=1,OLED TV 区显示 ON
关闭电视 0x0C SW4=0,OLED TV 区显示 OFF

V20 模块的 TX 接 STM32 的 PA3(USART2_RX),RX 可不接(本项目只需要模块上报编码,不需要 STM32 给模块发命令)。串口接收采用中断方式,收到一帧后在主循环解析。

3.10 红外学习模块(空调控制)

空调红外控制是智能家居里最麻烦的环节之一。空调的红外协议不像电视那样简单的 NEC 编码,而是带状态的长编码(温度、模式、风向、风速都在一帧里),不同品牌协议各异。本项目偷个懒,直接用成品的"红外学习模块"。

这种模块自带单片机和红外接收/发射管,通过串口控制:先让模块学习空调遥控器按键的红外波形(学习模式),模块把波形存到内部 Flash;需要控制时,STM32 通过串口发送命令,模块回放对应的红外波形。模块通常支持多路按键学习(如开机、关机、升温、降温、切换模式等)。

关键参数:

    • 供电电压:5V(部分支持 3.3V)• 串口波特率:9600• 学习路数:常见 10~30 路• 载波频率:38kHz• 掉电保存:学习的波形存在 Flash,断电不丢失

本项目红外学习模块接到 USART3(PB10/PB11),与 V20 语音模块各占一个串口,互不干扰。STM32 通过 TX(PB10)向模块发送"发送第 N 路红外码"的指令。代码里在打开/关闭空调时,调用 ir_remote_send(1) 或 ir_remote_send(2) 即可,模块会自动发射红外波形。

学习过程在首次使用时通过按键触发,学习成功后模块会闪灯或串口回复 ACK。具体串口协议格式以购买模块的手册为准。

3.11 28BYJ48 步进电机与 ULN2003(窗帘控制)

28BYJ48 是一款 5 线 4 相 5V 减速步进电机,配合 ULN2003 驱动板使用,价格便宜、控制简单,非常适合做窗帘、小云台这类低速大扭矩应用。关键参数:

    • 供电电压:5V DC• 相数:4 相• 减速比:1:64(实际约 1:63.68)• 步进角:5.625°/64(即每个脉冲输出轴转 5.625/64 ≈ 0.0879°)• 牵入转矩:≥300 gf·cm• 直流电阻:约 50Ω/相(5V 版本)• 绝缘电阻:≥10MΩ

ULN2003 驱动板上有 4 个 LED 指示各相状态、1 个红色跳线帽用于选择是否给电机单独供电。4 个输入引脚 IN1IN4 接 STM32 的 PB12PB15。

驱动方式采用 4 相 8 拍(半步驱动),相序如下,比 4 拍更平稳、扭矩更大:

A-AB-B-BC-C-CD-D-DA  (8 步一个循环)
对应 IN1~IN4:
0x01, 0x03, 0x02, 0x06, 0x04, 0x0C, 0x08, 0x09

正转按上面顺序循环,反转逆序循环。每步之间延时 2ms 左右(速度与扭矩的折中)。窗帘全开/全关需要的步数根据实际行程标定,代码里用宏定义 CURTAIN_FULL_STEPS,假设电机转 5 圈完成开合:5 圈 × 64 步/圈 × 63.68 减速比 ≈ 20378 步,实际用限位开关或时间标定更可靠。

注意:28BYJ48 不要长时间堵转,否则 ULN2003 和电机都会发烫。窗帘行程末端建议加限位开关,或者用步数估算+超时保护。

3.12 继电器模块与 5V 风扇、LED 灯

本项目有 3 路继电器负载:5V 小风扇(SW1)、USB 卧室 LED 灯(LED1)、USB 客厅 LED 灯(LED2)。空调开关用红外模块控制,不走继电器,但 SW2 状态仍需上报。

继电器模块选用常见的 5V 低电平触发或高电平触发继电器模块(本项目以高电平触发为例,购买时注意标识)。每个模块带光耦隔离和指示灯,输入端用 3.3V 或 5V 逻辑都能驱动。关键参数:

    输入电压:5V• 触发方式:高电平触发(IN 输入高电平吸合)或低电平触发(看模块标识)• 触点容量:10A 250VAC / 10A 30VDC• 每路静态电流约 20mA,吸合时约 70mA

引脚分配:

负载 继电器 IN 接 STM32
5V 风扇(SW1) PA4
卧室灯 LED1 PA6
客厅灯 LED2 PA7

接线时,USB 灯和 5V 风扇的正极接到继电器的公共端(COM),常开端(NO)接电源正极,负极接电源负极。STM32 引脚输出高电平时继电器吸合,负载通电。

安全提示:继电器控制强电(220V)时有触电风险,本项目为了安全全部用 5V USB 灯和 5V 风扇演示。如果要控制真实 220V 灯具,必须由有电工资质的人操作,强弱电之间做好绝缘隔离。

3.13 有源蜂鸣器

蜂鸣器分有源和无源两种。有源蜂鸣器内部自带振荡电路,给电就响,控制简单;无源蜂鸣器需要外部给一定频率的 PWM 才能发声。本项目用高电平触发的有源蜂鸣器模块(板载三极管驱动),STM32 一个 GPIO 就能控制。

    • 供电电压:3.3~5V• 触发方式:高电平触发(I/O 输出高电平响,低电平停)• 控制引脚:PB5

报警函数里让 PB5 输出高电平持续响,或者用定时器做"嘀——嘀——"间歇报警。烟雾或火焰触发时调用 buzzer_alarm_start(),报警解除后调用 buzzer_alarm_stop()

3.14 完整硬件接线表

综合以上分析,STM32F103C8T6 的完整引脚分配如下。0 基础读者照着这张表接线即可。

STM32 引脚 引脚功能 连接模块 模块引脚 方向 说明
PA0 ADC1_IN0 MQ2 AO 输入 烟雾模拟量采集
PA1 GPIO输入 火焰传感器 DO 输入 低电平表示有火焰
PA2 USART2_TX 海凌科 V20 RX 输出 可接可不接
PA3 USART2_RX 海凌科 V20 TX 输入 接收语音编码
PA4 GPIO输出 继电器1 IN 输出 风扇开关(SW1)
PA5 GPIO输入 SR602 OUT 输入 人体感应高电平
PA6 GPIO输出 继电器2 IN 输出 卧室灯(LED1)
PA7 GPIO输出 继电器3 IN 输出 客厅灯(LED2)
PA9 USART1_TX ESP8266 RX 输出 接 ESP8266 RX
PA10 USART1_RX ESP8266 TX 输入 接 ESP8266 TX
PA13 SWDIO 调试器 SWDIO 下载调试
PA14 SWCLK 调试器 SWCLK 下载调试
PB0 GPIO输出 继电器4 IN 输出 空调总控(SW2,配合红外)
PB5 GPIO输出 蜂鸣器 I/O 输出 高电平响
PB6 软件I2C_SCL OLED/BH1750/SHT30 SCL 输出 I2C 时钟线(3 设备共用)
PB7 软件I2C_SDA OLED/BH1750/SHT30 SDA 开漏输出 I2C 数据线
PB10 USART3_TX 红外学习模块 RX 输出 发送红外控制命令
PB11 USART3_RX 红外学习模块 TX 输入 接收 ACK(可选)
PB12 GPIO输出 ULN2003 IN1 输出 步进电机 A 相
PB13 GPIO输出 ULN2003 IN2 输出 步进电机 B 相
PB14 GPIO输出 ULN2003 IN3 输出 步进电机 C 相
PB15 GPIO输出 ULN2003 IN4 输出 步进电机 D 相
3.3V 电源 各模块 VCC OLED、SHT30、BH1750、SR602、ESP8266
5V 电源 继电器、风扇、LED灯、电机、红外模块、MQ2、蜂鸣器 VCC 5V 外设供电
GND 所有模块 GND 必须共地

I2C 总线接线说明:OLED、BH1750、SHT30 的 SCL 全部并联到 PB6,SDA 全部并联到 PB7。SCL 和 SDA 线上各加一个 4.7kΩ 上拉电阻到 3.3V(部分模块板载上拉,如果通信不稳定再外加)。

3.15 电源方案设计

本项目外设较多,电源设计不能马虎。推荐方案:

输入:外部 5V/2A 电源适配器(或 USB 充电头)供电到面包板/电源模块。•

5V 干线:给继电器模块、5V 风扇、USB LED 灯、28BYJ48 步进电机(经 ULN2003)、红外学习模块、MQ2、蜂鸣器供电。•

3.3V 干线:用 AMS1117-3.3 或独立 DC-DC 模块从 5V 降压得到,给 STM32、OLED、SHT30、BH1750、SR602、ESP8266 供电。•

ESP8266 单独供电:ESP8266 发射时峰值电流 300mA+,建议用独立的 3.3V LDO(如 AMS1117-3.3 最大输出 1A),不要和 OLED、传感器共用一个小电流 LDO,否则 WiFi 发射瞬间电压跌落会导致模块重启或串口乱码。•

共地:所有模块的 GND 必须连在一起,这是串口通信、ADC 采集准确的前提。•

滤波电容:在每个模块 VCC 与 GND 之间并联一个 100nF 陶瓷电容,ESP8266 和步进电机驱动附近再并联 100μF 电解电容,吸收瞬态电流。

电源设计不好出现的典型症状:ESP8266 反复重启、MQ2 读数跳动、OLED 花屏、步进电机失步。遇到这些问题,先查电源。

3.16 原理图与 PCB 设计(嘉立创 EDA)

面包板验证通过后,可以画一块 PCB 让项目更稳定、更美观。推荐用嘉立创 EDA(国产、免费、上手快、可直接下单打样)。设计要点:

先画原理图:按上述接线表在 EDA 里放置 STM32F103C8T6 最小系统、各模块接口座(排针/端子),连线,标注网络名。2.

电源分区:5V 和 3.3V 区域分开,电源线宽至少 30mil,步进电机和继电器的电源线宽 50~80mil。3.

晶振和复位:8MHz 外部晶振靠近 STM32 的 OSC_IN/OSC_OUT,负载电容 20pF 紧贴晶振;NRST 引脚上拉 10kΩ + 100nF 电容。4.

去耦电容:每个 IC 的 VCC 引脚旁放 100nF 电容,越近越好。5.

走线:信号线 10~15mil 即可,I2C 和 ADC 走线尽量短、远离步进电机和继电器驱动线,避免干扰。6.

接口座:所有外设用 2.54mm 排针或 XH2.54 端子引出,方便接插。7.

打样:嘉立创每月有免费打样额度,导出 Gerber 下单即可。

如果只是学习用,不画 PCB 也完全可以,面包板+杜邦线就能跑完全部功能。

四、STM32 软件设计

本章是全文代码量最大的部分。工程基于 STM32 标准外设库(SPL)开发,使用 Keil MDK-ARM 5.x 编译。代码按模块拆分成独立的 .h/.c 文件,每个模块职责单一,方便移植。零基础读者按顺序把代码加入工程、对照接线表接线即可复现。

4.1 Keil5 工程搭建

Keil MDK-ARM 是 STM32 开发最常用的 IDE,安装包和 ST-Link 驱动在网上都能找到。安装完成后还需要安装 STM32F1 系列的器件支持包(Keil.STM32F1xx_DFP),通过 Pack Installer 在线安装即可。

工程搭建步骤:

1. 新建一个文件夹 SmartHome,在里面建立 5 个子目录:UserStdPeriph_DriverCMSISHardwareOutput

2. 从 ST 标准外设库(STSW-STM32054)中拷贝启动文件 startup_stm32f10x_md.s、内核文件 core_cm3.c/.h、系统文件 system_stm32f10x.c/.h、stm32f10x.h 等到对应目录。

3. 打开 Keil,新建工程,芯片选择 STMicroelectronics → STM32F103C8

4. 在 Manage Project Items 里添加分组:User、StdPeriph_Driver、Hardware,把源码加进去。

5. 在 Options for Target → C/C++ → Define 里填 STM32F10X_MD,USE_STDPERIPH_DRIVER;Include Paths 里把所有头文件目录加进去。

6. 在 Debug 选项里选择 ST-Link Debugger,Settings 里选 SWD 接口,Flash Download 勾选 Reset and Run。

7. 新建 main.c,写一个 LED 闪烁程序测试工程,能正常下载运行说明环境 OK。

标准外设库需要编译的源文件有:misc.cstm32f10x_gpio.cstm32f10x_rcc.cstm32f10x_usart.cstm32f10x_i2c.c(本项目用软件 I2C,可以不加)、stm32f10x_adc.cstm32f10x_tim.cstm32f10x_dma.cstm32f10x_flash.c 等。

本项目使用软件 I2C,不占用硬件 I2C 外设,原因是硬件 I2C 在 F103 上有名的"卡死在 BUSY 标志"bug,软件模拟更稳定可靠,也方便引脚任意分配。

4.2 系统架构

本项目代码采用裸机超级循环(Super Loop)+ 中断架构,没有用 FreeRTOS。原因:F103C8T6 的 RAM 只有 20KB,FreeRTOS 本身要占几 KB 栈空间;本项目任务数量不多、实时性要求不高,裸机架构完全够用,代码也更容易理解。

如果读者想练习 FreeRTOS,可以把以下逻辑改写成任务:

    • 传感器采集任务(周期 1s):读 SHT30、BH1750、MQ2、火焰、人体• OLED 刷新任务(周期 500ms):把最新数据画到屏幕• ESP8266 通信任务(周期 1s):上报 JSON、接收并解析下行命令• 语音命令处理任务(事件触发):解析 V20 串口编码• 报警检测任务(周期 200ms):判断烟雾/火焰阈值,控制蜂鸣器• 步进电机控制任务(事件触发):开/关窗帘时走步进序列

裸机主循环结构如下:

int main(void)
{
    SystemInit();          // 系统时钟初始化,72MHz
    Hardware_Init();       // 所有外设和模块初始化
    ESP8266_Connect();     // ESP8266 连 WiFi、连 MQTT、订阅主题

    while(1)
    {
        Sensor_Update();   // 采集所有传感器
        Alarm_Check();     // 检测烟雾和火焰报警
        Voice_Process();   // 处理语音命令(从接收缓冲区取数据)
        MQTT_Process();    // 处理 MQTT 下行命令
        OLED_Show();       // 刷新 OLED 显示
        MQTT_Report();     // 构造 JSON 并上报
        Delay_ms(1000);    // 1 秒一个节拍
    }
}

串口接收采用中断:USART1(ESP8266)和 USART2(V20)各开接收中断,把收到的字节填进环形缓冲区;USART3(红外模块)只发不收,用查询方式。主循环里从缓冲区取数据解析,避免在中断里做复杂处理。

4.3 硬件连线说明(引脚定义)

代码中所有引脚用宏定义集中管理,方便改引脚。下面是 pin_config.h

#ifndef __PIN_CONFIG_H
#define __PIN_CONFIG_H

#include "stm32f10x.h"

/* ========== 软件 I2C(OLED/BH1750/SHT30 共用) ========== */
#define I2C_SCL_PORT    GPIOB
#define I2C_SCL_PIN     GPIO_Pin_6
#define I2C_SCL_RCC     RCC_APB2Periph_GPIOB
#define I2C_SDA_PORT    GPIOB
#define I2C_SDA_PIN     GPIO_Pin_7
#define I2C_SDA_RCC     RCC_APB2Periph_GPIOB

/* ========== ESP8266 (USART1: PA9 TX / PA10 RX) ========== */
#define ESP_USART       USART1
#define ESP_USART_RCC   RCC_APB2Periph_USART1
#define ESP_BAUDRATE    115200

/* ========== 海凌科 V20 (USART2: PA2 TX / PA3 RX) ========== */
#define VOICE_USART     USART2
#define VOICE_USART_RCC RCC_APB1Periph_USART2
#define VOICE_BAUDRATE  9600

/* ========== 红外学习模块 (USART3: PB10 TX / PB11 RX) ========== */
#define IR_USART        USART3
#define IR_USART_RCC    RCC_APB1Periph_USART3
#define IR_BAUDRATE     9600

/* ========== MQ2 烟雾 ADC (PA0 - ADC1_IN0) ========== */
#define MQ2_ADC_PORT    GPIOA
#define MQ2_ADC_PIN     GPIO_Pin_0
#define MQ2_ADC         ADC1
#define MQ2_ADC_CH      ADC_Channel_0

/* ========== 火焰传感器 DO (PA1) ========== */
#define FLAME_PORT      GPIOA
#define FLAME_PIN       GPIO_Pin_1

/* ========== SR602 人体感应 (PA5) ========== */
#define PIR_PORT        GPIOA
#define PIR_PIN         GPIO_Pin_5

/* ========== 继电器(高电平触发) ========== */
#define RELAY_FAN_PORT  GPIOA   // 风扇 SW1
#define RELAY_FAN_PIN   GPIO_Pin_4
#define RELAY_LED1_PORT GPIOA   // 卧室灯 LED1
#define RELAY_LED1_PIN  GPIO_Pin_6
#define RELAY_LED2_PORT GPIOA   // 客厅灯 LED2
#define RELAY_LED2_PIN  GPIO_Pin_7
#define RELAY_AC_PORT   GPIOB   // 空调总控 SW2
#define RELAY_AC_PIN    GPIO_Pin_0

/* ========== 蜂鸣器 (PB5,高电平响) ========== */
#define BUZZER_PORT     GPIOB
#define BUZZER_PIN      GPIO_Pin_5

/* ========== ULN2003 步进电机 (PB12~PB15) ========== */
#define STEPPER_PORT    GPIOB
#define STEP_IN1_PIN    GPIO_Pin_12
#define STEP_IN2_PIN    GPIO_Pin_13
#define STEP_IN3_PIN    GPIO_Pin_14
#define STEP_IN4_PIN    GPIO_Pin_15
#define STEP_PINS       (STEP_IN1_PIN|STEP_IN2_PIN|STEP_IN3_PIN|STEP_IN4_PIN)

#endif

4.4 完整代码

下面按模块给出完整代码。每个模块都包含 .h 和 .c,注释尽量详细到零基础能看懂。

4.4.1 公共延时与位带操作

delay.h

#ifndef __DELAY_H
#define __DELAY_H
#include "stm32f10x.h"

void Delay_init(void);          // 初始化延时(用 SysTick)
void Delay_ms(uint16_t ms);     // 毫秒级延时(阻塞)
void Delay_us(uint32_t us);     // 微秒级延时

#endif

delay.c

#include "delay.h"

static uint8_t  fac_us = 0;     // us 倍频系数
static uint16_t fac_ms = 0;     // ms 倍频系数

/* 初始化延时函数,配置 SysTick 的时钟源为 HCLK/8 = 9MHz */
void Delay_init(void)
{
    SysTick_CLKSourceConfig(SysTick_CLKSource_HCLK_Div8);
    fac_us = SystemCoreClock / 8000000;   // 72M/8M = 9
    fac_ms = (uint16_t)fac_us * 1000;     // 9000
}

/* 微秒级延时,nus 不要超过 1864000(24 位计数器最大值/9) */
void Delay_us(uint32_t nus)
{
    uint32_t temp;
    SysTick->LOAD = nus * fac_us;          // 装载计数值
    SysTick->VAL  = 0x00;                  // 清空计数器
    SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk; // 开始计数
    do {
        temp = SysTick->CTRL;
    } while((temp & 0x01) && !(temp & (1 << 16))); // 等待计数到 0
    SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk;     // 关闭计数器
    SysTick->VAL = 0x00;
}

/* 毫秒级延时 */
void Delay_ms(uint16_t nms)
{
    for(; nms > 0; nms--) Delay_us(1000);
}

为了让 GPIO 操作像 51 单片机一样简单,引入位带操作(可选,代码中已用库函数为主):

// bitband.h,把一个 GPIO 引脚映射成一个位变量,可直接赋值
#define BITBAND(addr, bitnum) ((addr & 0xF0000000)+0x2000000+((addr&0xFFFFF)<<5)+(bitnum<<2))
#define MEM_ADDR(addr)  *((volatile unsigned long  *)(addr))
#define BIT_ADDR(addr, bitnum)   MEM_ADDR(BITBAND(addr, bitnum))

#define GPIOA_ODR_Addr (GPIOA_BASE+12)
#define GPIOB_ODR_Addr (GPIOB_BASE+12)
#define PAout(n) BIT_ADDR(GPIOA_ODR_Addr,n)
#define PBout(n) BIT_ADDR(GPIOB_ODR_Addr,n)
#define PAin(n)  BIT_ADDR(GPIOA_IDR_Addr,n)

4.4.2 软件 I2C 驱动

i2c.h

#ifndef __I2C_SW_H
#define __I2C_SW_H
#include "stm32f10x.h"

void I2C_Init(void);
void I2C_Start(void);
void I2C_Stop(void);
void I2C_SendByte(uint8_t data);
uint8_t I2C_ReadByte(uint8_t ack);
uint8_t I2C_WaitAck(void);

#endif

i2c.c

#include "i2c.h"
#include "pin_config.h"
#include "delay.h"

/* SCL 和 SDA 拉高/拉低宏 */
#define SCL_H()  GPIO_SetBits(I2C_SCL_PORT, I2C_SCL_PIN)
#define SCL_L()  GPIO_ResetBits(I2C_SCL_PORT, I2C_SCL_PIN)
#define SDA_H()  GPIO_SetBits(I2C_SDA_PORT, I2C_SDA_PIN)
#define SDA_L()  GPIO_ResetBits(I2C_SDA_PORT, I2C_SDA_PIN)
#define SDA_READ() GPIO_ReadInputDataBit(I2C_SDA_PORT, I2C_SDA_PIN)

/* 初始化软件 I2C:PB6/PB7 推挽输出(SDA 在读取时切为输入) */
void I2C_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    RCC_APB2PeriphClockCmd(I2C_SCL_RCC | I2C_SDA_RCC, ENABLE);

    GPIO_InitStructure.GPIO_Pin   = I2C_SCL_PIN | I2C_SDA_PIN;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_PP;  // 推挽输出
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(I2C_SCL_PORT, &GPIO_InitStructure);

    SCL_H();
    SDA_H();
}

/* 把 SDA 切为输入模式,用于读取从机应答/数据 */
static void SDA_IN(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    GPIO_InitStructure.GPIO_Pin   = I2C_SDA_PIN;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_IPU;  // 上拉输入
    GPIO_Init(I2C_SDA_PORT, &GPIO_InitStructure);
}

/* 把 SDA 切回推挽输出 */
static void SDA_OUT(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    GPIO_InitStructure.GPIO_Pin   = I2C_SDA_PIN;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(I2C_SDA_PORT, &GPIO_InitStructure);
}

/* I2C 起始信号:SCL 高电平时,SDA 从高变低 */
void I2C_Start(void)
{
    SDA_OUT();
    SDA_H(); SCL_H(); Delay_us(5);
    SDA_L(); Delay_us(5);
    SCL_L();
}

/* I2C 停止信号:SCL 高电平时,SDA 从低变高 */
void I2C_Stop(void)
{
    SDA_OUT();
    SCL_L(); SDA_L(); Delay_us(5);
    SCL_H(); Delay_us(5);
    SDA_H(); Delay_us(5);
}

/* 主机发送一个字节,高位在前 */
void I2C_SendByte(uint8_t data)
{
    uint8_t i;
    SDA_OUT();
    SCL_L();
    for(i = 0; i < 8; i++)
    {
        if(data & 0x80) SDA_H();
        else            SDA_L();
        data <<= 1;
        Delay_us(2);
        SCL_H(); Delay_us(5);
        SCL_L(); Delay_us(2);
    }
}

/* 主机读取一个字节,ack=1 发送应答继续读,ack=0 发送非应答结束读 */
uint8_t I2C_ReadByte(uint8_t ack)
{
    uint8_t i, data = 0;
    SDA_IN();
    for(i = 0; i < 8; i++)
    {
        SCL_L(); Delay_us(5);
        SCL_H(); Delay_us(2);
        data <<= 1;
        if(SDA_READ()) data |= 0x01;
        Delay_us(3);
    }
    SCL_L();
    if(ack) { SDA_OUT(); SDA_L(); }  // 应答
    else    { SDA_OUT(); SDA_H(); }  // 非应答
    Delay_us(2);
    SCL_H(); Delay_us(5);
    SCL_L();
    return data;
}

/* 等待从机应答,返回 0 收到应答,返回 1 未收到 */
uint8_t I2C_WaitAck(void)
{
    uint8_t ack;
    SDA_IN();
    SDA_H(); Delay_us(2);
    SCL_H(); Delay_us(5);
    ack = SDA_READ() ? 1 : 0;
    SCL_L();
    return ack;
}

4.4.3 SHT30 温湿度驱动

sht30.h

#ifndef __SHT30_H
#define __SHT30_H
#include "stm32f10x.h"

#define SHT30_ADDR  0x44   // ADDR 接 GND 时地址为 0x44

uint8_t SHT30_Init(void);
uint8_t SHT30_ReadData(float *temperature, float *humidity);

#endif

sht30.c

#include "sht30.h"
#include "i2c.h"
#include "delay.h"

/* CRC-8 校验,多项式 0x31,SHT30 手册要求 */
static uint8_t SHT30_CRC8(uint8_t *data, uint8_t len)
{
    uint8_t i, j, crc = 0xFF;
    for(j = 0; j < len; j++)
    {
        crc ^= data[j];
        for(i = 0; i < 8; i++)
        {
            if(crc & 0x80) crc = (crc << 1) ^ 0x31;
            else           crc <<= 1;
        }
    }
    return crc;
}

/* 初始化 SHT30:发一个测量命令看是否有应答 */
uint8_t SHT30_Init(void)
{
    I2C_Start();
    I2C_SendByte(SHT30_ADDR << 1);   // 写地址
    if(I2C_WaitAck()) { I2C_Stop(); return 1; } // 无应答
    I2C_SendByte(0x2C);              // 单次测量命令高字节
    I2C_SendByte(0x06);              // 高重复度、时钟拉伸
    I2C_Stop();
    Delay_ms(20);
    return 0;
}

/* 读取温湿度,返回 0 成功,1 失败 */
uint8_t SHT30_ReadData(float *temperature, float *humidity)
{
    uint8_t buf[6];

    /* 1. 发送测量命令 0x2C06 */
    I2C_Start();
    I2C_SendByte(SHT30_ADDR << 1);
    if(I2C_WaitAck()) { I2C_Stop(); return 1; }
    I2C_SendByte(0x2C);
    I2C_SendByte(0x06);
    I2C_Stop();
    Delay_ms(20);   // 等待测量完成,高重复度最大 15ms

    /* 2. 读取 6 字节:温度 MSB/LSB/CRC,湿度 MSB/LSB/CRC */
    I2C_Start();
    I2C_SendByte((SHT30_ADDR << 1) | 0x01); // 读地址
    if(I2C_WaitAck()) { I2C_Stop(); return 1; }
    buf[0] = I2C_ReadByte(1);  // 温度 MSB,应答
    buf[1] = I2C_ReadByte(1);  // 温度 LSB,应答
    buf[2] = I2C_ReadByte(1);  // 温度 CRC,应答
    buf[3] = I2C_ReadByte(1);  // 湿度 MSB,应答
    buf[4] = I2C_ReadByte(1);  // 湿度 LSB,应答
    buf[5] = I2C_ReadByte(0);  // 湿度 CRC,非应答结束
    I2C_Stop();

    /* 3. CRC 校验 */
    if(SHT30_CRC8(buf, 2) != buf[2]) return 2;
    if(SHT30_CRC8(buf + 3, 2) != buf[5]) return 3;

    /* 4. 换算:温度 T = -45 + 175*raw/65535;湿度 RH = 100*raw/65535 */
    uint16_t raw_t = ((uint16_t)buf[0] << 8) | buf[1];
    uint16_t raw_h = ((uint16_t)buf[3] << 8) | buf[4];
    *temperature = -45.0f + 175.0f * raw_t / 65535.0f;
    *humidity    = 100.0f * raw_h / 65535.0f;
    return 0;
}

4.4.4 BH1750 光强驱动

bh1750.h

#ifndef __BH1750_H
#define __BH1750_H
#include "stm32f10x.h"

#define BH1750_ADDR  0x23   // ADDR 接 GND 时地址为 0x23

uint8_t BH1750_Init(void);
uint8_t BH1750_ReadLight(float *lux);

#endif

bh1750.c

#include "bh1750.h"
#include "i2c.h"
#include "delay.h"

/* 初始化 BH1750:上电 + 连续高分辨率模式 */
uint8_t BH1750_Init(void)
{
    I2C_Start();
    I2C_SendByte(BH1750_ADDR << 1);
    if(I2C_WaitAck()) { I2C_Stop(); return 1; }
    I2C_SendByte(0x01);   // Power On 上电
    I2C_Stop();
    Delay_ms(1);

    I2C_Start();
    I2C_SendByte(BH1750_ADDR << 1);
    I2C_SendByte(0x10);   // Continuously H-Resolution Mode,分辨率 1 lx
    I2C_Stop();
    Delay_ms(180);        // 高分辨率模式首次测量最长 180ms
    return 0;
}

/* 读取光强,单位 lx */
uint8_t BH1750_ReadLight(float *lux)
{
    uint8_t buf[2];

    I2C_Start();
    I2C_SendByte((BH1750_ADDR << 1) | 0x01); // 读地址
    if(I2C_WaitAck()) { I2C_Stop(); return 1; }
    buf[0] = I2C_ReadByte(1);  // 高字节,应答
    buf[1] = I2C_ReadByte(0);  // 低字节,非应答
    I2C_Stop();

    /* 公式:lux = (MSB<<8 | LSB) / 1.2 */
    uint16_t raw = ((uint16_t)buf[0] << 8) | buf[1];
    *lux = raw / 1.2f;
    return 0;
}

4.4.5 MQ2 烟雾 ADC 采集

mq2.h

#ifndef __MQ2_H
#define __MQ2_H
#include "stm32f10x.h"

void MQ2_Init(void);
uint16_t MQ2_ReadADC(void);      // 返回原始 ADC 值 0~4095
float MQ2_GetPercent(void);     // 换算成 0~100% 浓度百分比

#endif

mq2.c

#include "mq2.h"
#include "pin_config.h"

/* 初始化 ADC1 通道 0(PA0),软件触发、单次转换 */
void MQ2_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    ADC_InitTypeDef  ADC_InitStructure;

    /* 1. 开启 GPIOA 和 ADC1 时钟 */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE);
    /* ADC 时钟分频,72M/6=12MHz,不超过 14MHz */
    RCC_ADCCLKConfig(RCC_PCLK2_Div6);

    /* 2. PA0 配置为模拟输入 */
    GPIO_InitStructure.GPIO_Pin  = MQ2_ADC_PIN;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
    GPIO_Init(MQ2_ADC_PORT, &GPIO_InitStructure);

    /* 3. 复位 ADC1 */
    ADC_DeInit(ADC1);
    ADC_InitStructure.ADC_Mode               = ADC_Mode_Independent; // 独立模式
    ADC_InitStructure.ADC_ScanConvMode       = DISABLE;             // 单通道
    ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;             // 单次转换
    ADC_InitStructure.ADC_ExternalTrigConv   = ADC_ExternalTrigConv_None; // 软件触发
    ADC_InitStructure.ADC_DataAlign          = ADC_DataAlign_Right;  // 数据右对齐
    ADC_InitStructure.ADC_NbrOfChannel       = 1;
    ADC_Init(ADC1, &ADC_InitStructure);

    /* 4. 使能 ADC1,并校准 */
    ADC_Cmd(ADC1, ENABLE);
    ADC_ResetCalibration(ADC1);
    while(ADC_GetResetCalibrationStatus(ADC1));
    ADC_StartCalibration(ADC1);
    while(ADC_GetCalibrationStatus(ADC1));
}

/* 读取一次 ADC 值,返回 0~4095 */
uint16_t MQ2_ReadADC(void)
{
    /* 配置通道 0,采样时间 239.5 周期(烟雾传感器输出阻抗高,用长采样时间更稳) */
    ADC_RegularChannelConfig(ADC1, MQ2_ADC_CH, 1, ADC_SampleTime_239Cycles5);
    ADC_SoftwareStartConvCmd(ADC1, ENABLE);                  // 启动转换
    while(!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC));           // 等待转换完成
    return ADC_GetConversionValue(ADC1);                     // 读取结果
}

/* 把 ADC 原始值换算成百分比
   原理:MQ2 在洁净空气中 AO 输出较低电压,在烟雾中升高。
   这里做线性映射:ADC 值 0~3000 映射到 0~100%。
   实际应用应根据洁净空气基线和报警点标定,阈值可由云端下发。 */
float MQ2_GetPercent(void)
{
    uint16_t adc = MQ2_ReadADC();
    float p = adc * 100.0f / 3000.0f;
    if(p > 100.0f) p = 100.0f;
    return p;
}

进阶:MQ2 更准确的浓度需要根据灵敏度曲线用对数公式换算(Rs/R0),需要在已知洁净空气中测 R0。本项目作为开关量报警用途,线性百分比已够用。

4.4.6 火焰传感器驱动

flame.h

#ifndef __FLAME_H
#define __FLAME_H
#include "stm32f10x.h"

void Flame_Init(void);
uint8_t Flame_Detected(void);   // 返回 1 表示检测到火焰,0 正常

#endif

flame.c

#include "flame.h"
#include "pin_config.h"

/* PA1 配置为上拉输入。模块 DO 检测到火焰时输出低电平 */
void Flame_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    GPIO_InitStructure.GPIO_Pin  = FLAME_PIN;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;  // 上拉输入
    GPIO_Init(FLAME_PORT, &GPIO_InitStructure);
}

/* 火焰传感器 DO 低电平表示检测到火焰(以模块标识为准,部分模块为高电平) */
uint8_t Flame_Detected(void)
{
    if(GPIO_ReadInputDataBit(FLAME_PORT, FLAME_PIN) == Bit_RESET)
        return 1;
    return 0;
}

4.4.7 SR602 人体感应驱动

sr602.h

#ifndef __SR602_H
#define __SR602_H
#include "stm32f10x.h"

void SR602_Init(void);
uint8_t SR602_HumanDetected(void);  // 返回 1 有人,0 无人

#endif

sr602.c

#include "sr602.h"
#include "pin_config.h"

/* PA5 配置为下拉输入,模块检测到人输出 3.3V 高电平 */
void SR602_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    GPIO_InitStructure.GPIO_Pin  = PIR_PIN;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPD;  // 下拉输入
    GPIO_Init(PIR_PORT, &GPIO_InitStructure);
}

uint8_t SR602_HumanDetected(void)
{
    return GPIO_ReadInputDataBit(PIR_PORT, PIR_PIN) ? 1 : 0;
}

4.4.8 继电器驱动

relay.h

#ifndef __RELAY_H
#define __RELAY_H
#include "stm32f10x.h"

/* 6 路设备状态,全局共用,OLED/MQTT 都会读写 */
typedef struct {
    uint8_t LED1;   // 卧室灯
    uint8_t LED2;   // 客厅灯
    uint8_t SW1;    // 风扇
    uint8_t SW2;    // 空调
    uint8_t SW3;    // 窗帘(1 开 0 关)
    uint8_t SW4;    // 电视(OLED 模拟)
} DeviceStatus_t;

extern DeviceStatus_t g_dev;

void Relay_Init(void);
void Relay_SetFan(uint8_t on);
void Relay_SetLed1(uint8_t on);
void Relay_SetLed2(uint8_t on);
void Relay_SetAc(uint8_t on);

#endif

relay.c

#include "relay.h"
#include "pin_config.h"

/* 全局设备状态,所有模块共享这一份 */
DeviceStatus_t g_dev = {0,0,0,0,0,0};

/* 初始化 4 路继电器控制引脚为推挽输出,全部默认断开(低电平) */
void Relay_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE);

    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_InitStructure.GPIO_Pin   = RELAY_FAN_PIN | RELAY_LED1_PIN | RELAY_LED2_PIN;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    GPIO_InitStructure.GPIO_Pin   = RELAY_AC_PIN;
    GPIO_Init(GPIOB, &GPIO_InitStructure);

    Relay_SetFan(0);
    Relay_SetLed1(0);
    Relay_SetLed2(0);
    Relay_SetAc(0);
}

/* 控制风扇继电器,on=1 吸合,on=0 断开(高电平触发模块) */
void Relay_SetFan(uint8_t on)
{
    g_dev.SW1 = on ? 1 : 0;
    if(on) GPIO_SetBits(RELAY_FAN_PORT, RELAY_FAN_PIN);
    else   GPIO_ResetBits(RELAY_FAN_PORT, RELAY_FAN_PIN);
}

void Relay_SetLed1(uint8_t on)
{
    g_dev.LED1 = on ? 1 : 0;
    if(on) GPIO_SetBits(RELAY_LED1_PORT, RELAY_LED1_PIN);
    else   GPIO_ResetBits(RELAY_LED1_PORT, RELAY_LED1_PIN);
}

void Relay_SetLed2(uint8_t on)
{
    g_dev.LED2 = on ? 1 : 0;
    if(on) GPIO_SetBits(RELAY_LED2_PORT, RELAY_LED2_PIN);
    else   GPIO_ResetBits(RELAY_LED2_PORT, RELAY_LED2_PIN);
}

/* 空调继电器只作总电源指示,实际开关由红外模块发码,这里保留 */
void Relay_SetAc(uint8_t on)
{
    g_dev.SW2 = on ? 1 : 0;
    if(on) GPIO_SetBits(RELAY_AC_PORT, RELAY_AC_PIN);
    else   GPIO_ResetBits(RELAY_AC_PORT, RELAY_AC_PIN);
}

4.4.9 蜂鸣器驱动

buzzer.h

#ifndef __BUZZER_H
#define __BUZZER_H
#include "stm32f10x.h"

void Buzzer_Init(void);
void Buzzer_On(void);
void Buzzer_Off(void);
void Buzzer_Beep(uint16_t ms);   // 响指定毫秒(阻塞式,用于提示音)

#endif

buzzer.c

#include "buzzer.h"
#include "pin_config.h"
#include "delay.h"

void Buzzer_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    GPIO_InitStructure.GPIO_Pin   = BUZZER_PIN;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(BUZZER_PORT, &GPIO_InitStructure);
    Buzzer_Off();
}

void Buzzer_On(void)  { GPIO_SetBits(BUZZER_PORT, BUZZER_PIN); }
void Buzzer_Off(void) { GPIO_ResetBits(BUZZER_PORT, BUZZER_PIN); }

/* 短促提示音,阻塞式,用在按键/语音确认 */
void Buzzer_Beep(uint16_t ms)
{
    Buzzer_On();
    Delay_ms(ms);
    Buzzer_Off();
}

4.4.10 28BYJ48 步进电机驱动

stepper.h

#ifndef __STEPPER_H
#define __STEPPER_H
#include "stm32f10x.h"

void Stepper_Init(void);
void Stepper_OpenCurtain(void);   // 正转开窗帘
void Stepper_CloseCurtain(void);  // 反转关窗帘
void Stepper_Stop(void);
uint8_t Stepper_IsBusy(void);     // 返回 1 正在转动

#endif

stepper.c

#include "stepper.h"
#include "pin_config.h"
#include "delay.h"

/* 4 相 8 拍相序(半步驱动):A-AB-B-BC-C-CD-D-DA
   IN1=A, IN2=B, IN3=C, IN4=D */
static const uint8_t step_seq[8] = {0x01,0x03,0x02,0x06,0x04,0x0C,0x08,0x09};

/* 窗帘全开/全关需要的步数,根据实际机械结构标定
   28BYJ48 减速比约 64,每圈 4096 步(8拍×512),假设 5 圈开合 */
#define CURTAIN_FULL_STEPS  20480

static volatile uint8_t s_busy = 0;

void Stepper_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    GPIO_InitStructure.GPIO_Pin   = STEP_PINS;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(STEPPER_PORT, &GPIO_InitStructure);
    Stepper_Stop();
}

/* 输出一相状态到 PB12~PB15
   低 4 位对应 IN1~IN4 */
static void Stepper_SetPhase(uint8_t phase)
{
    /* 先清零 4 个引脚,再按位设置 */
    GPIO_ResetBits(STEPPER_PORT, STEP_PINS);
    if(phase & 0x01) GPIO_SetBits(STEPPER_PORT, STEP_IN1_PIN);
    if(phase & 0x02) GPIO_SetBits(STEPPER_PORT, STEP_IN2_PIN);
    if(phase & 0x04) GPIO_SetBits(STEPPER_PORT, STEP_IN3_PIN);
    if(phase & 0x08) GPIO_SetBits(STEPPER_PORT, STEP_IN4_PIN);
}

/* 正转:按数组顺序走 8 拍。dir=1 正转(开窗帘),dir=0 反转(关窗帘) */
static void Stepper_Run(uint8_t dir, uint32_t steps)
{
    uint32_t i;
    uint8_t idx;
    s_busy = 1;
    for(i = 0; i < steps; i++)
    {
        if(dir) idx = i % 8;          // 正转
        else    idx = 7 - (i % 8);    // 反转
        Stepper_SetPhase(step_seq[idx]);
        Delay_ms(2);                  // 每步 2ms,速度与扭矩折中
    }
    Stepper_Stop();
}

void Stepper_Stop(void)
{
    GPIO_ResetBits(STEPPER_PORT, STEP_PINS);  // 全相断电,减少发热
    s_busy = 0;
}

void Stepper_OpenCurtain(void)
{
    Stepper_Run(1, CURTAIN_FULL_STEPS);
}

void Stepper_CloseCurtain(void)
{
    Stepper_Run(0, CURTAIN_FULL_STEPS);
}

uint8_t Stepper_IsBusy(void) { return s_busy; }

注意:上面 Stepper_Run 是阻塞式的,转动期间主循环卡住。如果想边转边处理其他事情,可以改成用定时器中断每 2ms 推进一步,状态机驱动。本项目为了代码简单用阻塞式,窗帘开合约 40 秒,期间不响应远程命令属可接受范围。

4.4.11 电视模拟模块

电视状态用 OLED 上的一块矩形区域模拟,不占用 GPIO。tv_sim.h

#ifndef __TV_SIM_H
#define __TV_SIM_H
#include "stm32f10x.h"

void TV_SetState(uint8_t on);   // 设置电视开关状态
uint8_t TV_GetState(void);

#endif

tv_sim.c

#include "tv_sim.h"
#include "relay.h"

static uint8_t s_tv_on = 0;

void TV_SetState(uint8_t on)
{
    s_tv_on = on ? 1 : 0;
    g_dev.SW4 = s_tv_on;     // 同步到全局状态,OLED 显示和上报都读这个
}

uint8_t TV_GetState(void) { return s_tv_on; }

OLED 模块在画电视区域时,根据 g_dev.SW4 决定画"TV ON"还是"TV OFF"。

4.4.12 OLED 显示驱动(SSD1306 + 中文字库)

OLED 驱动代码较长,分三部分:底层 I2C/SSD1306 命令、ASCII 字模、中文字模。为节省篇幅,中文字库只列出本项目用到的汉字,读者可用取模软件自行扩展。

oled.h

#ifndef __OLED_H
#define __OLED_H
#include "stm32f10x.h"

void OLED_Init(void);
void OLED_Clear(void);
void OLED_Refresh(void);              // 把显存刷到屏幕
void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t t);
void OLED_ShowChar(uint8_t x, uint8_t y, char ch, uint8_t size);
void OLED_ShowString(uint8_t x, uint8_t y, const char *str, uint8_t size);
void OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t idx); // 显示一个中文
void OLED_ShowNum(uint8_t x, uint8_t y, int32_t num, uint8_t len, uint8_t size);

#endif

oled.c 核心部分:

#include "oled.h"
#include "i2c.h"
#include "delay.h"
#include "oledfont.h"   // 字模数据

#define OLED_ADDR  0x3C   // SSD1306 地址
#define OLED_CMD   0x00   // 命令标识
#define OLED_DATA  0x40   // 数据标识

/* 128x64 显存,8 页,每页 128 字节,每字节 8 个垂直像素 */
static uint8_t OLED_GRAM[8][128];

/* 通过 I2C 向 OLED 写一个字节 */
static void OLED_WR_Byte(uint8_t dat, uint8_t cmd)
{
    I2C_Start();
    I2C_SendByte(OLED_ADDR << 1);
    I2C_WaitAck();
    I2C_SendByte(cmd ? OLED_DATA : OLED_CMD);
    I2C_WaitAck();
    I2C_SendByte(dat);
    I2C_WaitAck();
    I2C_Stop();
}

void OLED_Init(void)
{
    Delay_ms(200);
    OLED_WR_Byte(0xAE, 0); // 关闭显示
    OLED_WR_Byte(0x20, 0); OLED_WR_Byte(0x10, 0); // 页寻址模式
    OLED_WR_Byte(0xB0, 0);
    OLED_WR_Byte(0xC8, 0); // COM 扫描方向
    OLED_WR_Byte(0x00, 0); OLED_WR_Byte(0x10, 0);
    OLED_WR_Byte(0x40, 0);
    OLED_WR_Byte(0x81, 0); OLED_WR_Byte(0xFF, 0); // 对比度
    OLED_WR_Byte(0xA1, 0); // 段重映射
    OLED_WR_Byte(0xA6, 0); // 正常显示(非反色)
    OLED_WR_Byte(0xA8, 0); OLED_WR_Byte(0x3F, 0); // 复用率 64
    OLED_WR_Byte(0xA4, 0);
    OLED_WR_Byte(0xD3, 0); OLED_WR_Byte(0x00, 0);
    OLED_WR_Byte(0xD5, 0); OLED_WR_Byte(0xF0, 0);
    OLED_WR_Byte(0xD9, 0); OLED_WR_Byte(0x22, 0);
    OLED_WR_Byte(0xDA, 0); OLED_WR_Byte(0x12, 0);
    OLED_WR_Byte(0xDB, 0); OLED_WR_Byte(0x20, 0);
    OLED_WR_Byte(0x8D, 0); OLED_WR_Byte(0x14, 0); // 电荷泵开启
    OLED_WR_Byte(0xAF, 0); // 开启显示
    OLED_Clear();
}

void OLED_Clear(void)
{
    uint8_t i, j;
    for(i = 0; i < 8; i++)
        for(j = 0; j < 128; j++) OLED_GRAM[i][j] = 0;
    OLED_Refresh();
}

void OLED_Refresh(void)
{
    uint8_t i, j;
    for(i = 0; i < 8; i++)
    {
        OLED_WR_Byte(0xB0 + i, 0);        // 页地址
        OLED_WR_Byte(0x00, 0);            // 列低地址
        OLED_WR_Byte(0x10, 0);            // 列高地址
        for(j = 0; j < 128; j++) OLED_WR_Byte(OLED_GRAM[i][j], 1);
    }
}

/* 在显存画点,t=1 亮,t=0 灭 */
void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t t)
{
    uint8_t page = y / 8;
    uint8_t bit  = y % 8;
    if(x >= 128 || y >= 64) return;
    if(t) OLED_GRAM[page][x] |= (1 << bit);
    else  OLED_GRAM[page][x] &= ~(1 << bit);
}

/* 显示一个 ASCII 字符,size 取 8(6x8)或 16(8x16) */
void OLED_ShowChar(uint8_t x, uint8_t y, char ch, uint8_t size)
{
    uint8_t i, j;
    const uint8_t *font;
    uint8_t w = (size == 16) ? 8 : 6;
    ch -= ' ';
    if(size == 16) font = asc2_1608[(uint8_t)ch];
    else           font = asc2_0806[(uint8_t)ch];
    for(i = 0; i < size; i++)
    {
        uint8_t line = font[i];
        for(j = 0; j < w; j++)
        {
            OLED_DrawPoint(x + j, y + i, (line >> j) & 0x01);
        }
    }
}

/* 显示字符串 */
void OLED_ShowString(uint8_t x, uint8_t y, const char *str, uint8_t size)
{
    while(*str)
    {
        OLED_ShowChar(x, y, *str, size);
        x += (size == 16) ? 8 : 6;
        str++;
    }
}

/* 显示一个 16x16 汉字,idx 是汉字在中文字库数组中的下标 */
void OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t idx)
{
    uint8_t i, j;
    for(i = 0; i < 16; i++)
    {
        uint8_t line = Hzk16[idx][i];
        for(j = 0; j < 8; j++)
            OLED_DrawPoint(x + j, y + i, (line >> j) & 0x01);
    }
    for(i = 0; i < 16; i++)
    {
        uint8_t line = Hzk16[idx][i + 16];
        for(j = 0; j < 8; j++)
            OLED_DrawPoint(x + 8 + j, y + i, (line >> j) & 0x01);
    }
}

中文字模在 oledfont.h 里。开发者用取模软件把用到的汉字按"智能家居控制系统环境温度湿度烟雾光强火焰报警卧室灯客厅灯风扇空调电视窗帘开启关闭正常"顺序取模,生成数组:

/* oledfont.h 片段:本项目用 16x16 字库,阴码逐行顺向 */
const unsigned char Hzk16[][32] = {
{0x00,0x00,0x7F,0x7F,...}, // "智"
{0x00,0x00,...},           // "能"
// ... 其余汉字由取模软件生成
};
/* ASCII 字库 8x16 */
const unsigned char asc2_1608[][16] = { /* ... */ };
/* ASCII 字库 6x8 */
const unsigned char asc2_0806[][6]  = { /* ... */ };

为了让显示内容可维护,写一个应用层 app_display.c,专门负责把传感器数据和设备状态排版到屏幕:

#include "oled.h"
#include "relay.h"
#include "tv_sim.h"

/* 传感器数据由 main 模块填充,这里用 extern 引用 */
extern float g_temp, g_humi, g_light, g_smoke;
extern uint8_t g_flame_alarm, g_beep_state;

/* 中文字库下标,按取模顺序排列 */
enum {
    HZ_ZHI=0, HZ_NENG, HZ_JIA, HZ_KONG, HZ_XI, HZ_TONG,
    HZ_HUAN, HZ_JING, HZ_WEN, HZ_DU, HZ_SHI, HZ_DU2,
    HZ_YAN, HZ_WU, HZ_GUANG, HZ_QIANG, HZ_HUO, HZ_YAN2,
    HZ_BAO, HZ_JING2, HZ_WO, HZ_SHI3, HZ_DENG, HZ_KE,
    HZ_TING, HZ_FENG, HZ_SHAN, HZ_KONG2, HZ_TIAO,
    HZ_DIAN, HZ_SHI4, HZ_CHUANG, HZ_LIAN, HZ_KAI,
    HZ_GUAN, HZ_ZHENG, HZ_CHANG
};

/* 画电视模拟区域:右上角 40x24 矩形,内部显示状态 */
static void ShowTVArea(void)
{
    uint8_t i;
    /* 画外框 (x:88~127, y:0~23) */
    for(i = 88; i < 128; i++) { OLED_DrawPoint(i, 0, 1); OLED_DrawPoint(i, 23, 1); }
    for(i = 0; i < 24; i++)  { OLED_DrawPoint(88, i, 1); OLED_DrawPoint(127, i, 1); }
    if(g_dev.SW4) OLED_ShowString(96, 8, "TV ON", 16);
    else          OLED_ShowString(94, 8, "TV OFF", 16);
}

/* 第 1 页:传感器数据 */
void Display_Page1(void)
{
    char buf[24];
    OLED_Clear();
    /* 标题:智能家居 */
    OLED_ShowChinese(0, 0, HZ_ZHI); OLED_ShowChinese(16, 0, HZ_NENG);
    OLED_ShowChinese(32, 0, HZ_JIA); OLED_ShowChinese(48, 0, HZ_KONG);
    /* 环境温度: xx.x C */
    OLED_ShowChinese(0, 16, HZ_HUAN); OLED_ShowChinese(16, 16, HZ_JING);
    OLED_ShowChinese(32, 16, HZ_WEN); OLED_ShowChinese(48, 16, HZ_DU);
    OLED_ShowString(64, 16, ":", 16);
    sprintf(buf, "%.1f", g_temp); OLED_ShowString(72, 16, buf, 16);
    /* 环境湿度 */
    OLED_ShowChinese(0, 32, HZ_HUAN); OLED_ShowChinese(16, 32, HZ_JING);
    OLED_ShowChinese(32, 32, HZ_SHI); OLED_ShowChinese(48, 32, HZ_DU2);
    OLED_ShowString(64, 32, ":", 16);
    sprintf(buf, "%.1f%%", g_humi); OLED_ShowString(72, 32, buf, 16);
    /* 烟雾浓度 */
    OLED_ShowChinese(0, 48, HZ_YAN); OLED_ShowChinese(16, 48, HZ_WU);
    OLED_ShowString(32, 48, ":", 16);
    sprintf(buf, "%.1f", g_smoke); OLED_ShowString(40, 48, buf, 16);
    /* 光强在右侧 */
    OLED_ShowString(72, 48, "L:", 16);
    sprintf(buf, "%.0f", g_light); OLED_ShowString(88, 48, buf, 16);
    ShowTVArea();
    OLED_Refresh();
}

/* 第 2 页:设备状态 */
void Display_Page2(void)
{
    OLED_Clear();
    OLED_ShowChinese(0, 0, HZ_WO); OLED_ShowChinese(16, 0, HZ_SHI3);
    OLED_ShowString(32, 0, g_dev.LED1 ? ":ON " : ":OFF", 16);
    OLED_ShowChinese(72, 0, HZ_KE); OLED_ShowChinese(88, 0, HZ_TING);
    OLED_ShowString(104, 0, g_dev.LED2 ? "ON" : "OFF", 16);

    OLED_ShowChinese(0, 16, HZ_FENG); OLED_ShowChinese(16, 16, HZ_SHAN);
    OLED_ShowString(32, 16, g_dev.SW1 ? ":ON " : ":OFF", 16);
    OLED_ShowChinese(72, 16, HZ_KONG2); OLED_ShowChinese(88, 16, HZ_TIAO);
    OLED_ShowString(104, 16, g_dev.SW2 ? "ON" : "OFF", 16);

    OLED_ShowChinese(0, 32, HZ_CHUANG); OLED_ShowChinese(16, 32, HZ_LIAN);
    OLED_ShowString(32, 32, g_dev.SW3 ? ":ON " : ":OFF", 16);
    OLED_ShowChinese(72, 32, HZ_DIAN); OLED_ShowChinese(88, 32, HZ_SHI4);
    OLED_ShowString(104, 32, g_dev.SW4 ? "ON" : "OFF", 16);

    /* 火焰与报警状态 */
    OLED_ShowChinese(0, 48, HZ_HUO); OLED_ShowChinese(16, 48, HZ_YAN2);
    OLED_ShowString(32, 48, g_flame_alarm ? ":!!!" : ":OK ", 16);
    OLED_ShowChinese(72, 48, HZ_BAO); OLED_ShowChinese(88, 48, HZ_JING2);
    OLED_ShowString(104, 48, g_beep_state ? "!!!" : "OK", 16);
    OLED_Refresh();
}

主循环里每 3 秒切换一页:

static uint8_t page = 0;
static uint32_t page_tick = 0;
if(HAL_GetTick() - page_tick > 3000) { page_tick = HAL_GetTick(); page ^= 1; }
if(page) Display_Page2(); else Display_Page1();

(标准库没有 HAL_GetTick,可以用 SysTick 维护一个全局毫秒计数器。)

4.4.13 海凌科 V20 语音模块驱动

voice_v20.h

#ifndef __VOICE_V20_H
#define __VOICE_V20_H
#include "stm32f10x.h"

/* 语音命令编码(具体以模块手册为准,不同固件可能不同) */
#define VOICE_FAN_ON      0x01
#define VOICE_FAN_OFF     0x02
#define VOICE_AC_ON       0x03
#define VOICE_AC_OFF      0x04
#define VOICE_CURTAIN_ON  0x05
#define VOICE_CURTAIN_OFF 0x06
#define VOICE_LED1_ON     0x07
#define VOICE_LED1_OFF    0x08
#define VOICE_LED2_ON     0x09
#define VOICE_LED2_OFF    0x0A
#define VOICE_TV_ON       0x0B
#define VOICE_TV_OFF      0x0C

void Voice_Init(void);
uint8_t Voice_GetCommand(void);  // 从缓冲区取一条命令,0 表示无命令

#endif

voice_v20.c

#include "voice_v20.h"
#include "pin_config.h"
#include "relay.h"
#include "stepper.h"
#include "ir_remote.h"
#include "tv_sim.h"
#include "buzzer.h"

#define VOICE_BUF_SIZE 32
static volatile uint8_t s_rx_buf[VOICE_BUF_SIZE];
static volatile uint8_t s_rx_head = 0, s_rx_tail = 0;

/* USART2 初始化:PA2 TX,PA3 RX,波特率 9600 */
void Voice_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    USART_InitTypeDef USART_InitStructure;
    NVIC_InitTypeDef NVIC_InitStructure;

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    RCC_APB1PeriphClockCmd(VOICE_USART_RCC, ENABLE);

    /* PA2 复用推挽输出(TX) */
    GPIO_InitStructure.GPIO_Pin   = GPIO_Pin_2;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    /* PA3 浮空输入(RX) */
    GPIO_InitStructure.GPIO_Pin  = GPIO_Pin_3;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    USART_InitStructure.USART_BaudRate            = VOICE_BAUDRATE;
    USART_InitStructure.USART_WordLength          = USART_WordLength_8b;
    USART_InitStructure.USART_StopBits            = USART_StopBits_1;
    USART_InitStructure.USART_Parity              = USART_Parity_No;
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStructure.USART_Mode                = USART_Mode_Tx | USART_Mode_Rx;
    USART_Init(VOICE_USART, &USART_InitStructure);

    /* 使能接收中断 */
    USART_ITConfig(VOICE_USART, USART_IT_RXNE, ENABLE);
    NVIC_InitStructure.NVIC_IRQChannel                   = USART2_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority        = 1;
    NVIC_InitStructure.NVIC_IRQChannelCmd                = ENABLE;
    NVIC_Init(&NVIC_InitStructure);

    USART_Cmd(VOICE_USART, ENABLE);
}

/* USART2 中断服务函数:每收到一个字节就放进环形缓冲区 */
void USART2_IRQHandler(void)
{
    if(USART_GetITStatus(VOICE_USART, USART_IT_RXNE) != RESET)
    {
        uint8_t ch = USART_ReceiveData(VOICE_USART);
        uint8_t next = (s_rx_head + 1) % VOICE_BUF_SIZE;
        if(next != s_rx_tail) { s_rx_buf[s_rx_head] = ch; s_rx_head = next; }
        USART_ClearITPendingBit(VOICE_USART, USART_IT_RXNE);
    }
}

/* 从环形缓冲区读取一个字节,返回 -1 表示无数据 */
static int Voice_GetChar(void)
{
    if(s_rx_head == s_rx_tail) return -1;
    uint8_t ch = s_rx_buf[s_rx_tail];
    s_rx_tail = (s_rx_tail + 1) % VOICE_BUF_SIZE;
    return ch;
}

/* 获取并执行一条语音命令。返回命令编码,0 表示无命令。
   V20 模块一般会发一帧数据,这里简化为读取一个编码字节。
   如果模块返回的是多帧格式(如帧头+长度+命令+校验),在此处解析帧。 */
uint8_t Voice_GetCommand(void)
{
    int ch = Voice_GetChar();
    if(ch < 0) return 0;
    /* 简单过滤:只处理 0x01~0x0C 范围的命令编码 */
    if(ch >= VOICE_FAN_ON && ch <= VOICE_TV_OFF)
        return (uint8_t)ch;
    return 0;
}

/* 在 main 主循环里调用,执行语音命令 */
void Voice_Execute(uint8_t cmd)
{
    switch(cmd)
    {
        case VOICE_FAN_ON:      Relay_SetFan(1);    Buzzer_Beep(50); break;
        case VOICE_FAN_OFF:     Relay_SetFan(0);    Buzzer_Beep(50); break;
        case VOICE_AC_ON:       IR_SendCode(1);     Relay_SetAc(1);  Buzzer_Beep(50); break;
        case VOICE_AC_OFF:      IR_SendCode(2);     Relay_SetAc(0);  Buzzer_Beep(50); break;
        case VOICE_CURTAIN_ON:  g_dev.SW3 = 1;      Stepper_OpenCurtain();  Buzzer_Beep(50); break;
        case VOICE_CURTAIN_OFF: g_dev.SW3 = 0;      Stepper_CloseCurtain(); Buzzer_Beep(50); break;
        case VOICE_LED1_ON:     Relay_SetLed1(1);   Buzzer_Beep(50); break;
        case VOICE_LED1_OFF:    Relay_SetLed1(0);   Buzzer_Beep(50); break;
        case VOICE_LED2_ON:     Relay_SetLed2(1);   Buzzer_Beep(50); break;
        case VOICE_LED2_OFF:    Relay_SetLed2(0);   Buzzer_Beep(50); break;
        case VOICE_TV_ON:       TV_SetState(1);     Buzzer_Beep(50); break;
        case VOICE_TV_OFF:      TV_SetState(0);     Buzzer_Beep(50); break;
        default: break;
    }
}

4.4.14 红外学习模块驱动

ir_remote.h

#ifndef __IR_REMOTE_H
#define __IR_REMOTE_H
#include "stm32f10x.h"

void IR_Init(void);
void IR_SendCode(uint8_t key);  // 发送第 key 路已学习的红外码

#endif

ir_remote.c

#include "ir_remote.h"
#include "pin_config.h"

/* USART3:PB10 TX,PB11 RX。成品红外学习模块一般是 9600 波特率。
   不同模块的串口协议不同,这里以常见协议为例:
   发送一帧 0xA5 key 0x5A,模块收到后回放第 key 路红外码。
   实际协议以购买模块的手册为准,只需修改 IR_SendCode 的组帧逻辑。 */
void IR_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    USART_InitTypeDef USART_InitStructure;

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    RCC_APB1PeriphClockCmd(IR_USART_RCC, ENABLE);

    /* PB10 复用推挽 TX */
    GPIO_InitStructure.GPIO_Pin   = GPIO_Pin_10;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &GPIO_InitStructure);
    /* PB11 浮空输入 RX */
    GPIO_InitStructure.GPIO_Pin  = GPIO_Pin_11;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOB, &GPIO_InitStructure);

    USART_InitStructure.USART_BaudRate            = IR_BAUDRATE;
    USART_InitStructure.USART_WordLength          = USART_WordLength_8b;
    USART_InitStructure.USART_StopBits            = USART_StopBits_1;
    USART_InitStructure.USART_Parity              = USART_Parity_No;
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStructure.USART_Mode                = USART_Mode_Tx | USART_Mode_Rx;
    USART_Init(IR_USART, &USART_InitStructure);
    USART_Cmd(IR_USART, ENABLE);
}

/* 通过串口发送一个字节 */
static void IR_SendByte(uint8_t dat)
{
    USART_SendData(IR_USART, dat);
    while(USART_GetFlagStatus(IR_USART, USART_FLAG_TXE) == RESET);
}

/* 发送红外控制命令。key 为学习时保存的按键序号(1=开机,2=关机等) */
void IR_SendCode(uint8_t key)
{
    /* 帧头 + 按键号 + 帧尾,具体格式看模块手册 */
    IR_SendByte(0xA5);
    IR_SendByte(key);
    IR_SendByte(0x5A);
}

4.4.15 ESP8266 WiFi/MQTT 驱动

ESP8266 刷乐鑫官方 AT 固件(V1.7 及以上支持 MQTT 指令),STM32 通过 USART1 发 AT 指令控制。模块接线:ESP8266 TX→PA10,RX→PA9,EN 拉高,CH_PC 拉高,RST 可接 PB0 之外的任意 GPIO 做硬件复位(本项目用 PB1,代码里可选)。

esp8266.h

#ifndef __ESP8266_H
#define __ESP8266_H
#include "stm32f10x.h"

uint8_t ESP8266_Init(void);             // 初始化串口和复位
uint8_t ESP8266_JoinAP(const char *ssid, const char *pwd); // 连 WiFi
uint8_t ESP8266_MQTT_Config(void);      // 配置 MQTT 三元组
uint8_t ESP8266_MQTT_Connect(void);     // 连接华为云
uint8_t ESP8266_MQTT_Subscribe(void);   // 订阅下行主题
uint8_t ESP8266_MQTT_Publish(const char *json); // 发布 JSON
uint8_t ESP8266_SendCmd(const char *cmd, const char *ack, uint32_t timeout);
void ESP8266_ClearBuffer(void);
uint16_t ESP8266_GetResponse(char *out, uint16_t maxlen);

#endif

esp8266.c

#include "esp8266.h"
#include "pin_config.h"
#include "delay.h"
#include <stdio.h>
#include <string.h>

/* ===== 华为云 IoT 参数(本项目真实参数) ===== */
#define WIFI_SSID       "YourWiFiSSID"
#define WIFI_PASSWORD   "YourWiFiPassword"

#define MQTT_SERVER_IP  "117.78.5.125"
#define MQTT_SERVER_PORT 1883
#define MQTT_CLIENT_ID  "69194919bf22cc5a8c0707ce_dev1_0_0_2025111603"
#define MQTT_USER_NAME  "69194919bf22cc5a8c0707ce_dev1"
#define MQTT_PASS_WORD  "ab0de61dafe7741d44d0fef981a941bf8e2c297a2698fd438abcbaf107d75ce4"

#define SET_TOPIC  "$oc/devices/69194919bf22cc5a8c0707ce_dev1/sys/messages/down"
#define POST_TOPIC "$oc/devices/69194919bf22cc5a8c0707ce_dev1/sys/properties/report"

/* 串口接收环形缓冲区 */
#define ESP_BUF_SIZE 1024
static volatile char s_rx_buf[ESP_BUF_SIZE];
static volatile uint16_t s_rx_head = 0, s_rx_tail = 0;

void ESP8266_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    USART_InitTypeDef USART_InitStructure;
    NVIC_InitTypeDef NVIC_InitStructure;

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE);

    /* PA9 TX 复用推挽 */
    GPIO_InitStructure.GPIO_Pin   = GPIO_Pin_9;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    /* PA10 RX 浮空输入 */
    GPIO_InitStructure.GPIO_Pin  = GPIO_Pin_10;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    USART_InitStructure.USART_BaudRate            = ESP_BAUDRATE;
    USART_InitStructure.USART_WordLength          = USART_WordLength_8b;
    USART_InitStructure.USART_StopBits            = USART_StopBits_1;
    USART_InitStructure.USART_Parity              = USART_Parity_No;
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStructure.USART_Mode                = USART_Mode_Tx | USART_Mode_Rx;
    USART_Init(USART1, &USART_InitStructure);

    USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
    NVIC_InitStructure.NVIC_IRQChannel                   = USART1_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority        = 2;
    NVIC_InitStructure.NVIC_IRQChannelCmd                = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
    USART_Cmd(USART1, ENABLE);
}

/* USART1 接收中断:每个字节存入环形缓冲区 */
void USART1_IRQHandler(void)
{
    if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
    {
        char ch = USART_ReceiveData(USART1);
        uint16_t next = (s_rx_head + 1) % ESP_BUF_SIZE;
        if(next != s_rx_tail) { s_rx_buf[s_rx_head] = ch; s_rx_head = next; }
    }
}

/* 串口发送一个字符串 */
static void ESP_SendString(const char *str)
{
    while(*str)
    {
        while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
        USART_SendData(USART1, *str++);
    }
}

/* 清空接收缓冲区 */
void ESP8266_ClearBuffer(void)
{
    s_rx_head = s_rx_tail = 0;
}

/* 向 ESP8266 发送一条 AT 命令,等待 ack 字符串出现在响应中。
   返回 0 成功收到 ack,1 超时失败 */
uint8_t ESP8266_SendCmd(const char *cmd, const char *ack, uint32_t timeout)
{
    char buf[256];
    uint16_t len = 0;
    uint32_t time = 0;

    ESP8266_ClearBuffer();
    ESP_SendString(cmd);
    ESP_SendString("rn");

    while(time < timeout)
    {
        while(s_rx_head != s_rx_tail && len < sizeof(buf) - 1)
        {
            buf[len++] = s_rx_buf[s_rx_tail];
            s_rx_tail = (s_rx_tail + 1) % ESP_BUF_SIZE;
        }
        buf[len] = '';
        if(ack && strstr(buf, ack)) return 0;
        Delay_ms(10);
        time += 10;
    }
    return 1;
}

/* 把响应内容复制到 out,返回长度 */
uint16_t ESP8266_GetResponse(char *out, uint16_t maxlen)
{
    uint16_t len = 0;
    while(s_rx_head != s_rx_tail && len < maxlen - 1)
    {
        out[len++] = s_rx_buf[s_rx_tail];
        s_rx_tail = (s_rx_tail + 1) % ESP_BUF_SIZE;
    }
    out[len] = '';
    return len;
}

/* 完整初始化流程:复位、关闭回显、设 STA 模式、连 WiFi、MQTT 登录 */
uint8_t ESP8266_JoinAP(const char *ssid, const char *pwd)
{
    char cmd[128];
    /* 复位 */
    if(ESP8266_SendCmd("AT+RST", "ready", 5000)) return 1;
    Delay_ms(2000);
    /* 关闭回显,减少串口数据量 */
    ESP8266_SendCmd("ATE0", "OK", 1000);
    /* 设置 STA 模式 */
    if(ESP8266_SendCmd("AT+CWMODE=1", "OK", 1000)) return 2;
    /* 连接路由器 */
    sprintf(cmd, "AT+CWJAP="%s","%s"", ssid, pwd);
    if(ESP8266_SendCmd(cmd, "WIFI GOT IP", 15000)) return 3;
    /* 查 IP 确认 */
    if(ESP8266_SendCmd("AT+CIFSR", "IP", 2000)) return 4;
    return 0;
}

/* 配置 MQTT 客户端参数(link ID 固定 0) */
uint8_t ESP8266_MQTT_Config(void)
{
    char cmd[512];
    /* 清理可能存在的旧连接 */
    ESP8266_SendCmd("AT+MQTTCLEAN=0", "OK", 1000);
    /* AT+MQTTUSERCFG=<linkID>,<scheme>,<clientID>,<username>,<password>,
       <certKeyID>,<CAID>,<path>
       scheme=1 表示 TCP 直连(非 TLS) */
    sprintf(cmd,
      "AT+MQTTUSERCFG=0,1,"%s","%s","%s",0,0,""",
      MQTT_CLIENT_ID, MQTT_USER_NAME, MQTT_PASS_WORD);
    if(ESP8266_SendCmd(cmd, "OK", 3000)) return 1;

    /* 设置 keepalive 120 秒,清理会话 */
    if(ESP8266_SendCmd("AT+MQTTCLEAN=0", "OK", 1000)) return 2;
    return 0;
}

/* 连接华为云 MQTT 服务器 */
uint8_t ESP8266_MQTT_Connect(void)
{
    char cmd[128];
    sprintf(cmd, "AT+MQTTCONN=0,"%s",%d,1", MQTT_SERVER_IP, MQTT_SERVER_PORT);
    /* 返回 +MQTTCONNECTED:0 表示成功,超时 10 秒 */
    if(ESP8266_SendCmd(cmd, "MQTTCONNECTED", 10000)) return 1;
    return 0;
}

/* 订阅平台下行主题 */
uint8_t ESP8266_MQTT_Subscribe(void)
{
    char cmd[256];
    /* AT+MQTTSUB=<linkID>,<topic>,<qos> */
    sprintf(cmd, "AT+MQTTSUB=0,"%s",0", SET_TOPIC);
    if(ESP8266_SendCmd(cmd, "OK", 3000)) return 1;
    return 0;
}

/* 发布 JSON 到属性上报主题。
   AT+MQTTPUB=<linkID>,<topic>,<data>,<qos>,<retain>
   注意:JSON 里的双引号要转义成 " */
uint8_t ESP8266_MQTT_Publish(const char *json)
{
    char cmd[768];
    char escaped[512];
    int j = 0;
    /* 把 JSON 中的双引号转义,因为 AT 命令字符串本身用双引号包裹 */
    for(int i = 0; json[i] && j < (int)sizeof(escaped) - 2; i++)
    {
        if(json[i] == '"') escaped[j++] = '\';
        escaped[j++] = json[i];
    }
    escaped[j] = '';

    sprintf(cmd, "AT+MQTTPUB=0,"%s","%s",0,0", POST_TOPIC, escaped);
    if(ESP8266_SendCmd(cmd, "OK", 3000)) return 1;
    return 0;
}

/* 一键联网,main 里调用 */
uint8_t ESP8266_ConnectAll(void)
{
    uint8_t res;
    res = ESP8266_JoinAP(WIFI_SSID, WIFI_PASSWORD);
    if(res) return res;
    res = ESP8266_MQTT_Config();
    if(res) return 10 + res;
    res = ESP8266_MQTT_Connect();
    if(res) return 20 + res;
    res = ESP8266_MQTT_Subscribe();
    if(res) return 30 + res;
    return 0;
}

固件版本差异:乐鑫 AT 固件在 V2.x 后 MQTT 指令有少量调整(如 AT+MQTTUSERCFG 的 scheme 取值)。如果 AT 指令返回 ERROR,先用 AT+GMR 查看版本,对照乐鑫官方 AT 指令手册微调。建议刷 V1.7.x 或 V2.2.x 稳定版。

4.4.16 MQTT 数据构造与命令解析

下行命令解析需要从 ESP8266 的串口数据里找出 MQTT 消息。乐鑫 AT 固件收到订阅消息时会上报:

+MQTTSUBRECV:0,"$oc/devices/xxx/sys/messages/down",<len>,<payload>

华为云下行消息的 payload 是一段 JSON,例如:

{"content":"{"LED1":1}","id":"123456"}

代码里收到一帧后,解析 content 字段,取出属性名和值,调用对应控制函数。为了避免在单片机上引入庞大的 cJSON 库,这里用简单的字符串查找解析(属性固定、结构简单)。

mqtt_data.h

#ifndef __MQTT_DATA_H
#define __MQTT_DATA_H
#include "stm32f10x.h"

/* 把当前所有传感器和设备状态拼成华为云要求的 JSON,写入 out */
void MQTT_BuildReportJSON(char *out, uint16_t maxlen);

/* 解析下行命令 payload,执行对应控制。
   payload 为 +MQTTSUBRECV 中逗号后的原始内容(可能带引号) */
void MQTT_ParseCommand(const char *payload);

#endif

mqtt_data.c

#include "mqtt_data.h"
#include "relay.h"
#include "stepper.h"
#include "ir_remote.h"
#include "tv_sim.h"
#include "buzzer.h"
#include <stdio.h>
#include <string.h>

/* 全局传感器数据,由 main 采集任务更新 */
float g_temp = 0, g_humi = 0, g_light = 0, g_smoke = 0;
uint8_t g_flame_alarm = 0;
uint8_t g_beep_state = 0;

/* 构造上报 JSON,格式严格按华为云产品模型 */
void MQTT_BuildReportJSON(char *out, uint16_t maxlen)
{
    snprintf(out, maxlen,
      "{"services":[{"service_id":"stm32","properties":{"
      ""LED1":%d,"LED2":%d,"BH1750":%.1f,"
      ""SW1":%d,"SW2":%d,"SW3":%d,"SW4":%d,"
      ""SHT30_T":%.1f,"SHT30_H":%.1f,"MQ2":%.1f,"
      ""BEEP":%d,"FLAME":%d}}]}",
      g_dev.LED1, g_dev.LED2, g_light,
      g_dev.SW1, g_dev.SW2, g_dev.SW3, g_dev.SW4,
      g_temp, g_humi, g_smoke,
      g_beep_state, g_flame_alarm);
}

/* 在 payload 中查找 "key":value 形式的整数 value,返回 1 找到 */
static uint8_t FindIntValue(const char *json, const char *key, int *value)
{
    char pattern[32];
    char *p;
    sprintf(pattern, ""%s":", key);
    p = strstr((char*)json, pattern);
    if(!p) return 0;
    p += strlen(pattern);
    *value = atoi(p);
    return 1;
}

/* 处理一个属性的设置 */
static void ApplyProperty(const char *key, int value)
{
    if(strcmp(key, "LED1") == 0) Relay_SetLed1(value ? 1 : 0);
    else if(strcmp(key, "LED2") == 0) Relay_SetLed2(value ? 1 : 0);
    else if(strcmp(key, "SW1")  == 0) Relay_SetFan(value ? 1 : 0);
    else if(strcmp(key, "SW2")  == 0) {
        /* 空调:用红外学习模块发码 */
        if(value) { IR_SendCode(1); Relay_SetAc(1); }
        else      { IR_SendCode(2); Relay_SetAc(0); }
    }
    else if(strcmp(key, "SW3")  == 0) {
        g_dev.SW3 = value ? 1 : 0;
        if(value) Stepper_OpenCurtain();
        else      Stepper_CloseCurtain();
    }
    else if(strcmp(key, "SW4")  == 0) TV_SetState(value ? 1 : 0);
    else if(strcmp(key, "BEEP") == 0) {
        g_beep_state = value ? 1 : 0;
        if(g_beep_state) Buzzer_On(); else Buzzer_Off();
    }
}

/* 解析下行命令。华为云下发消息 content 里嵌套了一段 JSON 字符串,
   本函数依次尝试所有可控属性,找到哪个就设置哪个。 */
void MQTT_ParseCommand(const char *payload)
{
    const char *keys[] = {"LED1","LED2","SW1","SW2","SW3","SW4","BEEP"};
    int v;
    for(uint8_t i = 0; i < sizeof(keys)/sizeof(keys[0]); i++)
    {
        if(FindIntValue(payload, keys[i], &v))
        {
            ApplyProperty(keys[i], v);
        }
    }
}

主循环里从 ESP8266 缓冲区读取数据,检测 +MQTTSUBRECV 帧,取出 payload 交给解析函数:

static void MQTT_Process(void)
{
    static char mqtt_buf[512];
    static uint16_t mqtt_len = 0;
    char ch;

    /* 逐个字节读,拼成一行 */
    while(s_rx_head != s_rx_tail && mqtt_len < sizeof(mqtt_buf) - 1)
    {
        ch = s_rx_buf[s_rx_tail];
        s_rx_tail = (s_rx_tail + 1) % ESP_BUF_SIZE;
        if(ch == 'n')
        {
            mqtt_buf[mqtt_len] = '';
            if(strstr(mqtt_buf, "+MQTTSUBRECV"))
            {
                /* 找到 payload:第四个逗号之后的内容 */
                char *p = mqtt_buf;
                for(uint8_t i = 0; i < 3; i++) {
                    p = strchr(p, ',');
                    if(!p) break;
                    p++;
                }
                if(p) MQTT_ParseCommand(p);
            }
            mqtt_len = 0;
        }
        else if(ch != 'r')
        {
            mqtt_buf[mqtt_len++] = ch;
        }
    }
}

4.4.17 main.c 主程序

main.c 把所有模块串起来:

#include "stm32f10x.h"
#include "delay.h"
#include "i2c.h"
#include "sht30.h"
#include "bh1750.h"
#include "mq2.h"
#include "flame.h"
#include "sr602.h"
#include "relay.h"
#include "buzzer.h"
#include "stepper.h"
#include "tv_sim.h"
#include "oled.h"
#include "app_display.h"
#include "esp8266.h"
#include "voice_v20.h"
#include "ir_remote.h"
#include "mqtt_data.h"
#include <stdio.h>

/* 烟雾报警阈值(百分比),超过即报警;可改成云端下发 */
#define SMOKE_THRESHOLD  50.0f

/* SysTick 毫秒计数器,用中断累加 */
volatile uint32_t g_tick = 0;
void SysTick_Handler(void) { g_tick++; }

/* 所有硬件初始化 */
static void Hardware_Init(void)
{
    SystemInit();
    Delay_init();
    SysTick_Config(SystemCoreClock / 1000); // 1ms 中断

    I2C_Init();
    SHT30_Init();
    BH1750_Init();
    MQ2_Init();
    Flame_Init();
    SR602_Init();
    Relay_Init();
    Buzzer_Init();
    Stepper_Init();
    TV_SetState(0);
    OLED_Init();
    Voice_Init();
    IR_Init();
    ESP8266_Init();

    /* PIR 上电后有 60 秒稳定期,MQ2 预热 20 秒,OLED 提示 */
    OLED_ShowString(0, 0, "Initializing...", 16);
    OLED_Refresh();
    Delay_ms(2000);  // MQ2 预热
}

/* 采集所有传感器,更新全局变量 */
static void Sensor_Update(void)
{
    float t, h, lux;
    if(SHT30_ReadData(&t, &h) == 0) { g_temp = t; g_humi = h; }
    if(BH1750_ReadLight(&lux) == 0) { g_light = lux; }
    g_smoke = MQ2_GetPercent();
    g_flame_alarm = Flame_Detected();
    (void)SR602_HumanDetected();  // 人体状态可上报或联动
}

/* 报警检测:烟雾超阈值或有火焰都触发蜂鸣器 */
static void Alarm_Check(void)
{
    if(g_smoke >= SMOKE_THRESHOLD || g_flame_alarm)
    {
        if(!g_beep_state) { Buzzer_On(); g_beep_state = 1; }
    }
    else
    {
        if(g_beep_state) { Buzzer_Off(); g_beep_state = 0; }
    }
}

/* 处理语音命令 */
static void Voice_Process(void)
{
    uint8_t cmd = Voice_GetCommand();
    if(cmd) Voice_Execute(cmd);
}

int main(void)
{
    char json[512];
    uint8_t res;

    Hardware_Init();

    /* 连接 WiFi 和华为云,失败则蜂鸣器提示并在 OLED 显示 */
    res = ESP8266_ConnectAll();
    if(res == 0)
    {
        OLED_Clear();
        OLED_ShowString(0, 0, "Cloud Connected!", 16);
        OLED_Refresh();
        Buzzer_Beep(100);
    }
    else
    {
        char buf[24];
        OLED_Clear();
        sprintf(buf, "Connect Err:%d", res);
        OLED_ShowString(0, 0, buf, 16);
        OLED_Refresh();
        Buzzer_On(); Delay_ms(500); Buzzer_Off();
    }
    Delay_ms(1000);

    while(1)
    {
        Sensor_Update();
        Alarm_Check();
        Voice_Process();
        MQTT_Process();

        /* OLED 两页轮播 */
        if((g_tick / 3000) % 2 == 0) Display_Page1();
        else                          Display_Page2();

        /* 构造 JSON 上报 */
        MQTT_BuildReportJSON(json, sizeof(json));
        ESP8266_MQTT_Publish(json);

        Delay_ms(1000);
    }
}

/* printf 重定向到 USART1(调试打印) */
int fputc(int ch, FILE *f)
{
    while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET);
    USART_SendData(USART1, (uint8_t)ch);
    return ch;
}

4.5 语音命令编码映射表

语音命令编码在 4.4.13 节已列出,这里再汇总成一张完整对照表。海凌科 V20 不同固件版本、不同自定义词条返回的编码会有差异,下表为示例,实际编码以模块配套手册为准。开发者拿到模块后,可以先用串口调试助手打印模块返回的原始字节,再对照修改 voice_v20.h 中的宏定义。

语音命令 模块返回编码 执行函数 物理动作
打开风扇 0x01 Relay_SetFan(1) PA4 输出高电平,风扇继电器吸合
关闭风扇 0x02 Relay_SetFan(0) PA4 低电平,继电器断开
打开空调 0x03 IR_SendCode(1)+Relay_SetAc(1) 红外模块发送开机码,PB0 高电平
关闭空调 0x04 IR_SendCode(2)+Relay_SetAc(0) 红外模块发送关机码,PB0 低电平
打开窗帘 0x05 Stepper_OpenCurtain() PB12~15 输出 8 拍正转约 20480 步
关闭窗帘 0x06 Stepper_CloseCurtain() 反转同样步数
打开卧室灯 0x07 Relay_SetLed1(1) PA6 高电平,卧室灯亮
关闭卧室灯 0x08 Relay_SetLed1(0) PA6 低电平,灯灭
打开客厅灯 0x09 Relay_SetLed2(1) PA7 高电平
关闭客厅灯 0x0A Relay_SetLed2(0) PA7 低电平
打开电视 0x0B TV_SetState(1) OLED 右上角显示 TV ON
关闭电视 0x0C TV_SetState(0) OLED 右上角显示 TV OFF

每条命令执行成功后蜂鸣器短响 50ms 作为反馈,让用户知道命令被识别。如果识别到不支持的编码,直接丢弃。

4.6 烟雾和火焰报警逻辑

报警逻辑集中在 Alarm_Check() 函数中,规则如下:

1. 烟雾报警:MQ2 采集的浓度百分比 g_smoke 大于等于 SMOKE_THRESHOLD(默认 50%)时,判定烟雾异常。

2. 火焰报警:火焰传感器 DO 为低电平时 g_flame_alarm=1,判定有明火。

3. 任一条件满足:蜂鸣器持续响(Buzzer_On()),g_beep_state=1,上报 JSON 中 BEEP=1FLAME=1(或 MQ2 高值)。

4. 两个条件都解除:蜂鸣器停止,g_beep_state=0

5. APP 弹窗:Qt 上位机定时拉取设备影子,发现 BEEP==1 或 FLAME==1 时弹出 QMessageBox(Windows)或系统通知(Android),提醒用户。

阈值不要写死。进阶做法是在产品模型里给 MQ2 加一个可写的阈值属性 MQ2_THRESHOLD,用户在 APP 上设置,云端下发,设备保存到 Flash。本项目为简化用宏定义,读者可自行扩展。

误报处理:火焰传感器对强光敏感,MQ2 对酒精、香水也有反应。实际部署时建议:

    • 火焰传感器避开窗户和灯具,安装在高处朝下;• MQ2 阈值根据现场基线标定,开机自动测一段洁净空气基线;• 烟雾和火焰同时触发才推送紧急报警,单一触发只本地蜂鸣,减少打扰。

4.7 程序下载

编译代码、下载 STM32 程序有视频教程:https://www.bilibili.com/video/BV1Cw4m1e7Yc

打开 STM32 的 Keil 工程,编译代码。使用 USB 线将开发板的 USB 口(串口 1 或 ST-Link)与电脑连接,打开程序下载软件下载程序。

打开程序下载软件(软件在资料包的软件工具目录下):

如果用 ST-Link,在 Keil 里 Settings 选择 ST-Link Debugger,SWD 接口,点击 Load 按钮即可一键下载。用串口下载则需要把 BOOT0 接高电平、BOOT1 接低电平,用 FlyMcu 等软件下载 hex 文件,下载完把 BOOT0 接回低电平复位运行。

4.8 程序正常运行效果

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

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

正常启动流程:

1. OLED 显示"Initializing...",MQ2 预热 20 秒。

2. ESP8266 连接路由器,串口打印 WIFI GOT IP

3. MQTT 连接华为云,串口打印 +MQTTCONNECTED:0

4. OLED 显示"Cloud Connected!",蜂鸣器短响。

5. 进入主循环,OLED 轮播传感器页和设备状态页,每秒上报一次 JSON。

6. 说"打开风扇",V20 识别后风扇转动,蜂鸣器短响,OLED 状态页风扇变为 ON。

7. 用打火机靠近火焰传感器,蜂鸣器长响,APP 弹窗报警。

4.9 取模软件的使用

显示屏上显示中文、字母、数字,需要用取模软件把字符转换成点阵数组。本项目使用 PCtoLCD2002 完美版(软件在资料包的软件工具目录下)。

打开软件之后:

取模设置步骤:

    1. 1. 点击菜单"选项",按以下配置:

      • • 点阵格式:阴码(1 点亮)• 取模方式:逐行式• 取模走向:顺向(高位在前)• 输出数制:十六进制• 自定义格式:C51 格式

2. 在文字输入框输入需要的汉字,例如"智能家居"。3. 点击"生成字模",下方会输出 C 语言数组。4. 把数组复制到 oledfont.h 的 Hzk16 数组中,记住每个汉字对应的下标。5. 代码里调用 OLED_ShowChinese(x, y, 下标) 显示对应汉字。

英文和数字可以直接用现成的 ASCII 字库(asc2_1608 和 asc2_0806),不需要取模。


五、Qt 上位机开发

Qt 是一套跨平台 C++ 应用开发框架,一套代码可以编译出 Windows、Linux、macOS 桌面程序,也能交叉编译出 Android、iOS 手机 APP。本项目上位机用 Qt 开发,同一份工程在 Windows 上用 MinGW 编译出 exe,在 Android 上用 GCC 编译出 APK,界面和业务逻辑共用,只有少量平台相关代码用条件编译处理。

5.1 Qt 开发环境安装

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

Qt 5.12.6 下载地址:https://download.qt.io/archive/qt/5.12/5.12.6

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

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

软件安装时建议断网安装,否则可能提示登录账户。安装时第一个复选框里的编译器可以全选,直接点击下一步继续安装。

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

Android 开发还需要额外安装 Android NDK、JDK、Android SDK,并在 Qt Creator 的 Tools→Options→Devices→Android 里配置路径。建议先把 Windows 版本调通,再折腾 Android。

5.2 新建上位机工程

【1】新建工程

【2】设置项目名称

【3】选择编译系统(qmake)

【4】选择默认继承的类(QMainWindow)

【5】选择编译器

【6】点击完成

【7】工程创建完成

5.3 切换编译器

左下角可以切换编译器,选择用什么样的编译器编译程序。新建工程时选择了两种编译器:一种是 MinGW 32bit,编译 Windows 下运行的程序;一种是 Android 编译器,可以生成 Android 手机 APP。

Android 编译器需要配置环境才能正常使用,开发者可以根据网上教程配置 NDK 和 SDK。Windows 编译器不需要额外配置,安装好 Qt 就能编译使用。

下面选择 MinGW 32bit 编译器,编译 Windows 下运行的程序。

5.4 编译测试功能

创建完毕后编译测试功能是否正常。点击左下角的绿色三角形按钮。

正常运行可以看到弹出一个白色窗口,说明工程环境没有问题,接下来可以设计界面。

5.5 UI 界面设计

【1】打开 UI 文件

默认界面如下:

【2】界面布局设计

上位机界面分三个区域:

    1. 1.

传感器数据区(左上)

    1. :用 QLabel 显示温度、湿度、光强、烟雾、火焰状态、人体感应状态,数值用大字号、绿色或蓝色突出。2.

设备控制区(左下)

    1. :6 个 QPushButton(卧室灯、客厅灯、风扇、空调、窗帘、电视),用 checkable 属性表示开关状态,开启时按钮变绿色。3.

报警提示区(右侧)

    :一个 QTextEdit 显示报警日志,烟雾或火焰报警时追加红色文字并弹出提示框。

Android APP 界面用垂直布局适配小屏,传感器区在上、设备按钮网格在中、日志在下。用 QSS(Qt Style Sheets)美化按钮和背景,让 APP 看起来不像默认控件。

[图片:Windows 上位机 UI 设计,三区域布局]

[图片:Android APP 界面,手机端竖向布局]

UI 文件设计完成后,在 .pro 文件里加上网络模块:

QT       += core gui network
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
CONFIG += c++11
TARGET = SmartHome
TEMPLATE = app
SOURCES += main.cpp mainwindow.cpp httpmanager.cpp
HEADERS += mainwindow.h httpmanager.h

5.6 华为云 API 对接

上位机不直接连 MQTT,而是通过华为云的 HTTPS API 间接访问设备:

    1. 1.

获取 Token

    1. :用 IAM 账号密码调用华为云 IAM 接口,拿到有效期 24 小时的 X-Subject-Token。2.

查询设备影子

    1. :带 Token 调用 IoTDA 的 ShowDeviceShadow 接口,获取设备最新上报数据。3.

下发命令

    :调用 CreateCommand 接口,把 JSON 命令发给设备,华为云通过 MQTT 下行主题转发给 ESP8266。

5.6.1 HTTP 请求封装

httpmanager.h

#ifndef HTTPMANAGER_H
#define HTTPMANAGER_H

#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QTimer>

class HttpManager : public QObject
{
    Q_OBJECT
public:
    explicit HttpManager(QObject *parent = nullptr);

    void getToken();                       // 获取 IAM Token
    void queryShadow();                    // 查询设备影子
    void sendCommand(const QString &key, int value); // 下发命令

    QString token() const { return m_token; }
    bool    isTokenValid() const;

signals:
    void shadowReceived(const QJsonObject &shadow);  // 影子数据就绪
    void alarmOccurred(const QString &msg);          // 报警信号
    void tokenReady();

private slots:
    void onReplyFinished(QNetworkReply *reply);

private:
    QNetworkAccessManager *m_nam;
    QString m_token;
    qint64  m_tokenTime;   // Token 获取时间戳(秒)

    // 华为云 IoT 参数
    QString m_iamUrl;
    QString m_shadowUrl;
    QString m_cmdUrl;
    QString m_projectId;
    QString m_deviceId;
    QString m_iamUser;
    QString m_iamPwd;
    QString m_domain;
};

#endif

httpmanager.cpp

#include "httpmanager.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QDateTime>
#include <QDebug>

HttpManager::HttpManager(QObject *parent) : QObject(parent)
{
    m_nam = new QNetworkAccessManager(this);
    connect(m_nam, &QNetworkAccessManager::finished,
            this, &HttpManager::onReplyFinished);

    // 华为云参数(本项目真实参数)
    m_projectId = "27f7663400f145449d295ef6ee1eb5ab";
    m_deviceId  = "69194919bf22cc5a8c0707ce_dev1";
    m_iamUser   = "ds_abc";
    m_iamPwd    = "DS12345678";
    m_domain    = "hid_zay6txvrbcku-74";   // 主用户名

    // IAM Token 接口(华北-北京四)
    m_iamUrl = "https://iam.cn-north-4.myhuaweicloud.com/v3/auth/tokens";
    // 设备影子接口
    m_shadowUrl = QString("https://dab1a1f2c6.st1.iotda-app.cn-north-4.myhuaweicloud.com:443"
                          "/v5/iot/%1/devices/%2/shadow")
                  .arg(m_projectId).arg(m_deviceId);
    // 命令下发接口
    m_cmdUrl = QString("https://dab1a1f2c6.st1.iotda-app.cn-north-4.myhuaweicloud.com:443"
                       "/v5/iot/%1/devices/%2/commands")
               .arg(m_projectId).arg(m_deviceId);

    m_tokenTime = 0;
}

bool HttpManager::isTokenValid() const
{
    if(m_token.isEmpty()) return false;
    // Token 有效期 24 小时,提前 1 小时刷新
    return (QDateTime::currentSecsSinceEpoch() - m_tokenTime) < 23 * 3600;
}

/* 获取 IAM Token。
   请求体里填主账号名、IAM 用户名、密码、项目名(cn-north-4) */
void HttpManager::getToken()
{
    QUrl url(m_iamUrl);
    QNetworkRequest request(url);
    request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json;charset=utf-8");

    QJsonObject auth;
    QJsonObject identity;
    identity["methods"] = QJsonArray{"password"};
    QJsonObject password;
    QJsonObject user;
    QJsonObject domain; domain["name"] = m_domain;
    user["domain"] = domain;
    user["name"] = m_iamUser;
    user["password"] = m_iamPwd;
    password["user"] = user;
    identity["password"] = password;
    auth["identity"] = identity;

    QJsonObject scope;
    QJsonObject project; project["name"] = "cn-north-4";
    scope["project"] = project;
    auth["scope"] = scope;

    QJsonObject root; root["auth"] = auth;
    QByteArray data = QJsonDocument(root).toJson();

    QNetworkReply *reply = m_nam->post(request, data);
    reply->setProperty("reqType", "token");
}

/* 查询设备影子 */
void HttpManager::queryShadow()
{
    if(!isTokenValid()) { getToken(); return; }
    QUrl url(m_shadowUrl);
    QNetworkRequest request(url);
    request.setRawHeader("X-Auth-Token", m_token.toUtf8());
    QNetworkReply *reply = m_nam->get(request);
    reply->setProperty("reqType", "shadow");
}

/* 下发设备命令。华为云命令接口 body 格式:
   { "service_id": "stm32", "command_name": "property",
     "paras": { "LED1": 1 } }
   设备端通过 $oc/devices/.../sys/messages/down 收到消息 */
void HttpManager::sendCommand(const QString &key, int value)
{
    if(!isTokenValid()) { getToken(); return; }
    QUrl url(m_cmdUrl);
    QNetworkRequest request(url);
    request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json;charset=utf-8");
    request.setRawHeader("X-Auth-Token", m_token.toUtf8());

    QJsonObject paras; paras[key] = value;
    QJsonObject body;
    body["service_id"]   = "stm32";
    body["command_name"] = "property";
    body["paras"]        = paras;

    QByteArray data = QJsonDocument(body).toJson();
    QNetworkReply *reply = m_nam->post(request, data);
    reply->setProperty("reqType", "command");
}

void HttpManager::onReplyFinished(QNetworkReply *reply)
{
    QString type = reply->property("reqType").toString();
    QByteArray data = reply->readAll();
    int code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();

    if(type == "token")
    {
        // Token 在响应头 X-Subject-Token 里
        QByteArray tok = reply->rawHeader("X-Subject-Token");
        if(!tok.isEmpty())
        {
            m_token = QString::fromUtf8(tok);
            m_tokenTime = QDateTime::currentSecsSinceEpoch();
            qDebug() << "Token OK";
            emit tokenReady();
        }
        else
        {
            qDebug() << "Token failed:" << data;
        }
    }
    else if(type == "shadow")
    {
        if(code == 200)
        {
            QJsonParseError err;
            QJsonDocument doc = QJsonDocument::fromJson(data, &err);
            if(err.error == QJsonParseError::NoError)
            {
                QJsonObject root = doc.object();
                /* shadow 数组第一个元素的 reported.properties 就是设备上报数据 */
                QJsonArray shadowArr = root.value("shadow").toArray();
                if(!shadowArr.isEmpty())
                {
                    QJsonObject reported = shadowArr.at(0).toObject()
                                              .value("reported").toObject();
                    QJsonObject props = reported.value("properties").toObject();
                    emit shadowReceived(props);

                    /* 检测报警:BEEP=1 或 FLAME=1 时发信号 */
                    if(props.value("BEEP").toInt() == 1 ||
                       props.value("FLAME").toInt() == 1)
                    {
                        emit alarmOccurred(
                            QString("报警!烟雾:%1 火焰:%2")
                            .arg(props.value("MQ2").toDouble())
                            .arg(props.value("FLAME").toInt()));
                    }
                }
            }
        }
        else
        {
            qDebug() << "Shadow query failed:" << code << data;
            /* 401 一般是 Token 过期,重新获取 */
            if(code == 401) { m_token.clear(); getToken(); }
        }
    }
    else if(type == "command")
    {
        if(code != 200 && code != 201)
            qDebug() << "Command failed:" << code << data;
    }

    reply->deleteLater();
}

5.6.2 Token 获取说明

华为云 IAM Token 接口是一个 POST 请求,URL 为 https://iam.cn-north-4.myhuaweicloud.com/v3/auth/tokens。请求体里要填:

identity.methods

["password"]

identity.password.user.domain.name

    • :主账号名(本项目

hid_zay6txvrbcku-74

    • )•

identity.password.user.name

    • :IAM 用户名(

ds_abc

    • )•

identity.password.user.password

    • :密码(

DS12345678

    • )•

scope.project.name

    • :区域(

cn-north-4

    ,北京四)

成功后返回 201,响应头 X-Subject-Token 就是 Token。之后调用 IoT 接口时在请求头带上 X-Auth-Token

5.7 数据解析与显示

MainWindow 里用 QTimer 每 2 秒调用一次 queryShadow(),收到数据后更新界面:

mainwindow.h

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QTimer>
#include "httpmanager.h"

namespace Ui { class MainWindow; }

class MainWindow : public QMainWindow
{
    Q_OBJECT
public:
    explicit MainWindow(QWidget *parent = nullptr);
    ~MainWindow();

private slots:
    void onShadowReceived(const QJsonObject &props);
    void onAlarm(const QString &msg);
    void onBtnClicked();

private:
    Ui::MainWindow *ui;
    HttpManager *m_http;
    QTimer *m_timer;
    bool m_alarmPopupShown;
};

#endif

mainwindow.cpp

#include "mainwindow.h"
#include "ui_mainwindow.h"
#include <QMessageBox>
#include <QDateTime>
#ifdef Q_OS_ANDROID
#include <QtAndroid>
#endif

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent), ui(new Ui::MainWindow)
{
    ui->setupUi(this);
    setWindowTitle("智能家居控制系统");

    m_http = new HttpManager(this);
    m_timer = new QTimer(this);
    m_alarmPopupShown = false;

    connect(m_http, &HttpManager::shadowReceived,
            this, &MainWindow::onShadowReceived);
    connect(m_http, &HttpManager::alarmOccurred,
            this, &MainWindow::onAlarm);

    /* 6 个设备控制按钮统一连到 onBtnClicked */
    connect(ui->btnLed1, &QPushButton::clicked, this, &MainWindow::onBtnClicked);
    connect(ui->btnLed2, &QPushButton::clicked, this, &MainWindow::onBtnClicked);
    connect(ui->btnFan,  &QPushButton::clicked, this, &MainWindow::onBtnClicked);
    connect(ui->btnAc,   &QPushButton::clicked, this, &MainWindow::onBtnClicked);
    connect(ui->btnCurtain, &QPushButton::clicked, this, &MainWindow::onBtnClicked);
    connect(ui->btnTv,   &QPushButton::clicked, this, &MainWindow::onBtnClicked);

    /* 启动时先获取 Token,拿到后立刻查一次影子 */
    connect(m_http, &HttpManager::tokenReady, this, [this](){ m_http->queryShadow(); });
    m_http->getToken();

    /* 每 2 秒刷新一次设备影子 */
    connect(m_timer, &QTimer::timeout, this, [this](){ m_http->queryShadow(); });
    m_timer->start(2000);
}

MainWindow::~MainWindow() { delete ui; }

/* 收到设备影子数据,更新界面 */
void MainWindow::onShadowReceived(const QJsonObject &p)
{
    /* 传感器数据 */
    ui->lblTemp->setText(QString::number(p.value("SHT30_T").toDouble(), 'f', 1) + " ℃");
    ui->lblHumi->setText(QString::number(p.value("SHT30_H").toDouble(), 'f', 1) + " %RH");
    ui->lblLight->setText(QString::number(p.value("BH1750").toDouble(), 'f', 0) + " lx");
    ui->lblSmoke->setText(QString::number(p.value("MQ2").toDouble(), 'f', 1) + " %");
    ui->lblFlame->setText(p.value("FLAME").toInt() ? "报警" : "正常");
    ui->lblFlame->setStyleSheet(p.value("FLAME").toInt()
                                ? "color:red;font-weight:bold" : "color:green;");

    /* 设备开关状态。按钮用 checkable,按下状态与云端同步 */
    auto setBtn = [](QPushButton *btn, int on){
        btn->blockSignals(true);
        btn->setChecked(on);
        btn->setText(on ? "已开启" : "已关闭");
        btn->setStyleSheet(on ? "background-color:#4CAF50;color:white;"
                              : "background-color:#9E9E9E;color:white;");
        btn->blockSignals(false);
    };
    setBtn(ui->btnLed1, p.value("LED1").toInt());
    setBtn(ui->btnLed2, p.value("LED2").toInt());
    setBtn(ui->btnFan,  p.value("SW1").toInt());
    setBtn(ui->btnAc,   p.value("SW2").toInt());
    setBtn(ui->btnCurtain, p.value("SW3").toInt());
    setBtn(ui->btnTv,   p.value("SW4").toInt());

    /* 报警解除则重置弹窗标记 */
    if(p.value("BEEP").toInt() == 0) m_alarmPopupShown = false;
}

/* 按钮点击:根据按钮 objectName 映射到 JSON 属性名,下发命令 */
void MainWindow::onBtnClicked()
{
    QPushButton *btn = qobject_cast<QPushButton*>(sender());
    if(!btn) return;
    static const QMap<QString,QString> map = {
        {"btnLed1","LED1"}, {"btnLed2","LED2"},
        {"btnFan","SW1"},   {"btnAc","SW2"},
        {"btnCurtain","SW3"},{"btnTv","SW4"}
    };
    QString key = map.value(btn->objectName());
    int value = btn->isChecked() ? 1 : 0;
    m_http->sendCommand(key, value);

    /* 乐观更新:先改界面,失败了下次刷新会回滚 */
    btn->setText(value ? "已开启" : "已关闭");
    btn->setStyleSheet(value ? "background-color:#4CAF50;color:white;"
                             : "background-color:#9E9E9E;color:white;");
}

/* 报警处理:日志区追加红色文字,Windows 弹 QMessageBox,
   Android 弹系统通知 */
void MainWindow::onAlarm(const QString &msg)
{
    QString line = QString("[%1] %2")
        .arg(QDateTime::currentDateTime().toString("hh:mm:ss"))
        .arg(msg);
    ui->txtLog->append(QString("<font color=red>%1</font>").arg(line));

    if(m_alarmPopupShown) return;  // 同一次报警只弹一次
    m_alarmPopupShown = true;

#ifdef Q_OS_ANDROID
    /* Android 用系统通知,避免弹窗遮挡 */
    QtAndroid::AndroidQtActivity().callMethod<void>(
        "org/qtproject/example", "showNotification",
        "(Ljava/lang/String;Ljava/lang/String;)V",
        QAndroidJniObject::fromString("智能家居报警").object<jstring>(),
        QAndroidJniObject::fromString(msg).object<jstring>());
#else
    QMessageBox::warning(this, "报警", msg);
#endif
}

main.cpp

#include "mainwindow.h"
#include <QApplication>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    MainWindow w;
    w.show();
    return a.exec();
}

5.8 设备控制命令下发

按钮点击后调用 HttpManager::sendCommand(key, value),向华为云命令下发接口发 POST 请求。华为云收到命令后,通过设备订阅的 MQTT 下行主题 $oc/devices/.../sys/messages/down 把命令推送给 ESP8266,ESP8266 通过串口把 +MQTTSUBRECV 帧发给 STM32,STM32 解析 content 里的 JSON 字段,调用对应 Relay_SetXxx() 或 Stepper_OpenCurtain()

一次完整的远程控制链路:

用户点"卧室灯开" → Qt HTTP POST 命令 → 华为云
  → MQTT 下发 → ESP8266 收到 → 串口给 STM32
  → STM32 解析 → PA6 输出高电平 → 继电器吸合 → 灯亮
  → STM32 上报 LED1=1 → 云端影子更新
  → Qt 2 秒后查影子 → 界面按钮变绿

从点击到界面刷新约 2~4 秒,主要延迟在 Qt 轮询间隔。如果想实时,可以让 Qt 也用 MQTT 直连订阅设备消息,或使用华为云的规则引擎把数据转发到 WebSocket/AMQP 订阅。轮询方式最简单,适合教学演示。

5.9 报警弹窗实现

报警触发在 HttpManager::onReplyFinished 中判断:解析影子 JSON 时,如果 BEEP==1 或 FLAME==1,发射 alarmOccurred 信号。MainWindow 收到信号后:

    1. 1. 在右侧日志区追加红色文字(时间戳 + 报警内容)。2. 用

m_alarmPopupShown

    1.  标记保证同一次报警只弹一次窗,避免每 2 秒弹一次。3. Windows 平台用

QMessageBox::warning

     弹窗;Android 平台用 QtAndroid JNI 调用 Java 代码显示系统通知,避免弹窗直接置顶打扰。

报警解除后(下次影子里 BEEP=0),重置标记,下次报警可以再次弹窗。

5.10 跨平台编译

5.10.1 编译 Windows exe

左下角编译器选 MinGW 32bit,点绿色三角运行,或用 Release 模式编译,在 build-xxx-Release 目录下生成 exe。发布时把 exe 和 Qt 的 DLL(Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll、Qt5Network.dll、platforms/qwindows.dll 等)放在同一文件夹,可以用 windeployqt 工具自动拷贝依赖:

windeployqt SmartHome.exe

5.10.2 编译 Android APK

    1. 安装 JDK 1.8、Android SDK(API 21 以上)、Android NDK(r19~r21 与 Qt 5.12 兼容性好)。2. Qt Creator → Tools → Options → Devices → Android,配置 JDK、SDK、NDK 路径,让绿色对勾全部亮起。3. 左下角编译器选 Android for armeabi-v7a,点运行,Qt 会自动用 Gradle 打包生成 APK,安装到连接的手机或模拟器。4. 发布签名 APK:在 Projects → Build Steps → Build Android APK 里勾选 Sign package,填写 keystore。

代码里用 #ifdef Q_OS_ANDROID 处理平台差异,例如报警弹窗、文件路径、字体大小等。UI 用布局管理器(QVBoxLayout/QGridLayout)而非绝对坐标,让界面在手机和电脑上都能自适应。

[图片:Windows 上位机运行截图]

[图片:Android APP 运行截图]


六、系统测试与效果

硬件焊接接线、STM32 程序烧录、Qt 上位机编译完成后,进入联调测试阶段。建议按模块逐个测试,再做整体联调,避免出了问题无从下手。

6.1 传感器数据测试

先不接 WiFi 和云,只测传感器。给 STM32 上电,OLED 显示"Initializing...",等待 MQ2 预热 20 秒后进入轮播界面。

SHT30

    • :用手捏住传感器,温度读数应上升 1~3℃,对着传感器哈气湿度应明显上升。如果读数恒定为 -45℃ 或 0%,检查 I2C 接线和地址(0x44)。•

BH1750

    • :用手机手电筒照射,光强读数应从几十 lx 飙升到几千甚至几万 lx;遮住传感器降到个位数。如果读数始终为 0 或 65535,检查地址 0x23 和 SDA/SCL 是否接反。•

MQ2

    • :用打火机释放一点气体(不要点火)靠近传感器,烟雾百分比应上升。注意 MQ2 响应有 10 秒左右延迟,恢复也需要 30 秒以上。上电预热期间读数偏高是正常的。•

火焰

    • :用打火机点火(保持 30cm 以上距离),OLED 火焰状态应变为"报警",蜂鸣器响。注意不要让火焰长时间靠近传感器,也不要在有可燃物的环境下测试。•

SR602

    :人在模块前移动,人体感应状态变化;静止不动后 2~3 秒恢复无人。

6.2 语音控制测试

海凌科 V20 模块上电后通常会有提示音。按模块手册说"打开风扇""关闭风扇"等口令:

    1. 说"打开风扇",V20 识别后串口返回 0x01,STM32 解析后 PA4 输出高电平,风扇继电器吸合,风扇转动,蜂鸣器短响,OLED 状态页风扇显示 ON。2. 说"关闭风扇",风扇停转。3. 依次测试空调、窗帘、卧室灯、客厅灯、电视的开关命令。

如果识别率低:检查麦克风是否被遮挡、环境是否嘈杂、波特率是否匹配(多数 V20 默认 9600)。可以先用 USB 转 TTL 模块把 V20 接到电脑串口助手,直接看它返回的编码,确认编码表与代码一致。

[图片:语音测试,V20 模块和串口助手返回编码]

6.3 红外空调控制测试

首次使用需要让红外学习模块学习空调遥控器的按键:

1. 按模块上的学习键进入学习模式(按模块手册操作,通常是长按或短按)。

2. 把空调遥控器对准模块红外接收头,按"开机"键,模块闪灯或串口回复学习成功。

3. 重复操作学习"关机"键,分别保存到第 1 路、第 2 路。

4. 在 STM32 代码里,SW2=1 时调用 IR_SendCode(1),SW2=0 时调用 IR_SendCode(2)

测试时说"打开空调",红外发射头对准空调,空调应开机;说"关闭空调"应关机。红外发射距离一般 3~8 米,角度 60 度以内。如果没反应,检查模块串口波特率、接线(PB10→模块RX)、发射头是否对准空调。

[图片:红外学习模块和空调遥控器]

6.4 窗帘步进电机测试

给 ULN2003 和步进电机上电(5V,注意电流够不够,单独 5V 供电最稳)。测试:

    1. 说"打开窗帘",PB12~PB15 按 8 拍相序输出,电机正转,窗帘拉动。2. 说"关闭窗帘",电机反转。3. 观察电机是否有失步、抖动、发烫。28BYJ48 转速慢但扭矩大,约 40 秒走完全程是正常的。

如果电机只抖不转:检查 ULN2003 与电机的 5/6 线插座是否插紧、相序是否正确、电源电流是否足够(至少 500mA)。如果转向相反,把 step_seq 数组反过来或调换正反转函数即可。

[图片:步进电机+ULN2003+窗帘模型]

6.5 烟雾报警测试

烟雾报警逻辑:MQ2 浓度 ≥ 50% 时蜂鸣器长响,上报 BEEP=1,APP 弹窗。

测试方法:

    1. 先在洁净空气中观察 MQ2 读数稳定后的基线值。如果基线偏高(如 >40%),调低代码阈值或重新标定。2. 用打火机释放少量丁烷气体靠近 MQ2(不要点火),观察烟雾百分比上升。3. 超过阈值时,蜂鸣器持续响,OLED 报警状态显示"报警",APP 2 秒内弹出红色报警提示。4. 移走气体,等待 30~60 秒读数回落到阈值以下,蜂鸣器自动停止,APP 不再弹窗。

[图片:烟雾报警测试,蜂鸣器响、APP 弹窗]

6.6 火焰报警测试

火焰报警逻辑:火焰传感器 DO 输出低电平时判定有火,蜂鸣器长响,上报 FLAME=1。

测试时用打火机点火,保持 50cm 左右距离靠近传感器(注意安全),DO 指示灯亮起时 STM32 检测到低电平,蜂鸣器响、APP 弹窗。熄灭火焰后恢复。

[图片:火焰报警测试,火光靠近传感器]

6.7 OLED 显示效果

OLED 轮播两页:

    • 第 1 页:标题"智能家居"、温度、湿度、烟雾、光强,右上角 TV 模拟区。• 第 2 页:卧室灯、客厅灯、风扇、空调、窗帘、电视六个设备的 ON/OFF 状态,以及火焰、报警状态。

每 3 秒切换一页。控制设备时状态页同步更新。中文显示如果乱码或缺字,检查字模顺序与代码枚举下标是否对应。

[图片:OLED 第 1 页传感器数据显示]

[图片:OLED 第 2 页设备状态显示]

6.8 华为云后台数据查看

打开华为云 IoTDA 控制台,进入设备详情页:

    1. 设备状态显示"在线"。2. "设备影子"页签能看到最新上报的 12 个属性值,温度、湿度、光强、烟雾随传感器变化。3. "设备日志"或"消息跟踪"能看到每秒一次的属性上报记录。4. "在线调试"页可以直接在网页下发命令,例如把 LED1 设为 1,设备应立即响应开灯。

[图片:华为云设备影子数据截图]

[图片:华为云在线调试下发命令截图]

6.9 Windows 上位机测试

运行编译好的 SmartHome.exe:

    1. 启动后自动获取 Token,2 秒内界面显示设备最新数据。2. 点击"卧室灯"按钮,按钮变绿显示"已开启",2 秒内 STM32 继电器吸合灯亮。3. 依次测试六个设备按钮。4. 触发烟雾或火焰报警,日志区出现红色文字,弹出 QMessageBox 警告框。5. 关闭报警条件,弹窗不再出现,日志保留历史记录。

[图片:Windows 上位机运行界面]

6.10 Android APP 测试

把 APK 安装到安卓手机(允许"安装未知来源应用"):

    1. 手机和设备必须能访问互联网(不一定要同一局域网,因为走华为云)。2. 打开 APP,界面自动适配手机竖屏,传感器数据和按钮正常显示。3. 点击按钮远程控制设备,响应时间与 Windows 版一致。4. 触发报警时收到系统通知栏通知,下拉通知栏可看到报警内容。

[图片:Android APP 运行界面]

[图片:Android 报警通知截图]

6.11 人体感应测试

SR602 上电后有 1 分钟初始化期,期间会频繁触发。初始化完成后:

    • 人走过模块前方,输出高电平,STM32 可检测到并上报。• 可以扩展联动:检测到人且环境光强低(BH1750 < 50lx)时自动打开卧室灯;人离开 3 分钟后自动关灯。• 也可以作为安防传感器,布防模式下检测到人移动时推送报警。

相关推荐