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

STM32+华为云+微信小程序:从零搭建室内环境监测与红外遥控物联网系统

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

一、前言

1.1 项目背景

嵌入式开发这些年,经常有朋友问:能不能用STM32做一个东西,既能监测家里的温度湿度空气质量,又能远程控制空调电视,还能在手机上看到数据?

这个需求听起来简单,真做起来涉及的技术点不少。STM32底层驱动、传感器数据采集、WiFi联网、MQTT协议、华为云物联网平台、微信小程序开发、红外遥控学习……每一块单独拿出来都能写一篇文章。

决定把这个项目从头到尾做一遍,把所有踩过的坑、写过的代码都整理出来。目标很明确:跟着这篇文章走,0基础的读者也能把整个系统搭起来。

项目做出来之后,放在卧室里,可以实时看到温度、湿度、光照强度、二氧化碳浓度、TVOC挥发气体浓度。有人经过的时候自动记录活动时长。晚上起夜,光线暗加上检测到人,小夜灯自动亮起来。空气质量超标了,蜂鸣器报警灯一起响。手机微信小程序随时查看数据,还能远程学习红外遥控码,控制家里的空调、风扇、电视。

下面是设计的过程:

1.2 设计思路

项目开始之前,一般会先搜一下有没有现成的方案可以参考。现在AI已经成熟,使用先进的工具可以提高开发效率。

这次用了与非AI(https://url.eefocus.com/4e73),直接写提示词,"给一份基于物联网设计的室内环境监测装置的开发方案",会出来参考方案和电路设计

选型阶段再次用与非AI 查了SHT30、SGP30、BH1750这些传感器的数据手册,还顺便看了替代料建议,对成本控制很有帮助。

整个系统的技术路线是这样确定的:

主控STM32F103C8T6,便宜好用,资料最多,新手入门首选。•

传感器:SHT30测温湿度,BH1750测光强度,SGP30测CO2和TVOC。三个都是I2C接口,挂在同一组I2C总线上,地址不同不冲突。•

人体感应:SR602微型人体红外感应模块,数字量输出,GPIO直接读。•

WiFi联网:AI-WB2-01S模组,串口AT指令操作,支持MQTT,直接连华为云物联网平台。•

红外遥控:用成品红外学习模组,串口通信。STM32发命令让模块学习红外码、发送红外码。不用自己折腾HS0038接收头加红外发射管的分立方案,模块内部把红外接收解调和发射调制都做好了。•

本地显示:0.96寸OLED,SSD1306驱动,I2C接口,单独用一组I2C。•

云平台:华为云物联网平台IoTDA,MQTT协议通信,设备影子存储最新状态,命令下发实现远程控制。•

手机端:微信小程序,调华为云API查数据、发命令。•

PCB:嘉立创EDA专业版画原理图和PCB,打样回来焊上就能用。

提问后,在右边还会给出相关的推荐方案,帮助大家拓展思路。下图推荐排名首位的刚好是小龙哥自己的方案。

1.3 功能清单

整个系统实现了10项功能:

序号 功能 说明
1 SHT30温湿度监测 采集环境温度和湿度,I2C读取
2 BH1750环境亮度监测 采集光照强度,亮度下限可在小程序设置,用于小夜灯自动控制
3 SGP30空气质量监测 采集TVOC和eCO2数据
4 SR602人体感应 检测是否有人,记录当日人员活动时长
5 WiFi+MQTT数据上云 AI-WB2-01S通过AT指令连WiFi,MQTT连接华为云,定时上报数据
6 万能红外控制 成品红外学习模组,串口协议学习/发送任意红外遥控指令
7 OLED本地显示 3页内容循环显示,K1按键翻页
8 小夜灯自动控制 环境亮度低于小程序设置的下限,同时检测到有人,自动开灯
9 微信小程序远程查看和控制 实时数据展示、参数设置、红外遥控
10 报警提示 蜂鸣器加报警LED,CO2或TVOC超过小程序设置的上限时报警

1.4 硬件清单

开始动手之前,先把所有需要的元器件列出来,方便采购。

序号 器件名称 型号/规格 数量 接口
1 主控芯片 STM32F103C8T6 1 -
2 温湿度传感器 SHT30 1 I2C (0x44)
3 光强度传感器 BH1750 1 I2C (0x23)
4 空气质量传感器 SGP30 1 I2C (0x58)
5 人体感应模块 SR602 1 GPIO
6 WiFi模组 AI-WB2-01S 1 USART2
7 红外学习模组 成品串口红外模块 1 USART3
8 OLED显示屏 0.96寸 SSD1306 1 I2C (0x3C)
9 蜂鸣器模块 有源蜂鸣器 5V 1 GPIO
10 LED起夜灯 LED灯板 5V 1 GPIO/PWM
11 指示灯LED 3mm LED 红/绿/蓝 3 GPIO
12 按键 轻触按键 6x6mm 4 GPIO
13 电源模块 AMS1117-3.3V + 5V 1 -
14 PCB 嘉立创打样 1 -

主控板用的是STM32F103C8T6最小系统板,十几块钱一片,Flash有64KB,RAM有20KB,跑这个项目够用。如果自己画PCB,直接把芯片画上去也行。

1.5 本文适合谁看

    • 刚学完STM32基础,想做一个完整项目练手的同学
    • 对物联网感兴趣,想了解从传感器到云平台到小程序全链路的开发者
    • 想给家里做个环境监测加红外遥控设备的爱好者
    计算机、电子、自动化相关专业的学生做知识参考

1.7 器材采购建议

采购渠道就那几个:淘宝、立创商城。给几个实用建议:

    • STM32最小系统板买现成的,别自己焊LQFP48,新手焊不好容易出问题。等验证完功能再考虑自己画PCB。
    • 传感器模块买带板载上拉电阻LDO的版本,接线方便,兼容性好。
    • 杜邦线买公对母、母对母、公对公各一包,长度10cm和20cm的都备一些。
    • 买一个USB转TTL模块(CH340芯片就行),调试串口必备。
    • 电源用手机充电器加USB线就行,5V/1A以上。调试阶段用电脑USB口供电也可以,但WiFi发射时可能供电不足。
    • 元器件可以在立创商城一起买,正品有保障,凑单还包邮。电阻电容这些常用件一次买一包。

模块买回来先不要急着接到STM32上,先用USB转串口或者单独供电测试,确认模块本身是好的再接。有一次买回来三个SGP30模块,其中一个是坏的,接上STM32怎么都读不到数据,排查了半天才发现模块本身有问题。所以先单独测试模块,能少走弯路。

1.8 元器件数据手册下载

查找元器件的硬件参数,下载数据手册,可以直接使用与非AI,地址在这里:https://url.eefocus.com/4e73

可以直接在输入框里搜索元器件型号,右侧能展示他的数据手册源文档,确实省了不少时间

点击后,会出现列表。可以获取价格,下载数据手册,查看可以替代的料。


二、硬件选型与电路设计

这一章详细讲每个器件的参数、接口和选型理由,最后给出完整的接线总表。硬件是整个项目的基础,接线接错了轻则读不到数据,重则烧坏芯片,一定要看仔细。

2.1 主控:STM32F103C8T6

STM32F103C8T6不用多介绍了,做嵌入式的基本都用过它。Cortex-M3内核,主频72MHz,64KB Flash,20KB RAM,LQFP48封装

这个项目用到的外设资源:

    • 两组软件模拟I2C:PA6/PA7挂传感器,PB0/PB1挂OLED• 两个串口:USART2(PA2/PA3)接WiFi模组,USART3(PB10/PB11)接红外模块• 若干GPIO:蜂鸣器、人体感应、小夜灯、3个指示灯、4个按键

算下来资源刚好够用。如果手头有STM32F103RCT6或者其他型号,引脚对得上也能用,代码里改一下引脚定义就行。

选型的时候在Datasheet5上搜了STM32F103C8T6的数据手册,确认了每个引脚的复用功能和电气特性。这里提醒一下,STM32F103的IO是3.3V逻辑,不要直接接5V信号,虽然有些引脚容忍5V,但稳妥起见还是注意一下。

2.2 SHT30温湿度传感器

SHT30是Sensirion公司出的数字温湿度传感器,I2C接口,地址0x44(也有0x45版本的,看ADDR引脚接法)。

主要参数:

    • 温度测量范围:-40℃ ~ +125℃• 温度精度:±0.2℃(典型值)• 湿度测量范围:0% ~ 100% RH• 湿度精度:±2% RH(典型值)• 工作电压:2.15V ~ 5.5V• 通信接口:I2C,最高1MHz

选型理由:精度高、稳定性好、I2C接口接线少。比DHT11强太多了,DHT11温度精度±2℃,湿度精度±5%,做项目还是用SHT30靠谱。SHT30模块在淘宝上几块钱就能买到,板载上拉电阻,接线方便。

SHT30用3.3V供电。它的I2C信号线也是3.3V逻辑,跟STM32直接连没问题。

2.3 BH1750光强度传感器

BH1750是罗姆半导体出的数字光强度传感器,I2C接口,地址0x23(ADDR接GND时)或0x5C(ADDR接VCC时)。用的模块ADDR接GND,地址是0x23。

主要参数:

    • 测量范围:1 ~ 65535 Lux• 分辨率:1 Lux• 工作电压:3.3V ~ 5V(模块一般带LDO,5V供电也行)• 通信接口:I2C,最高400kHz

选型理由:数字量直接输出Lux值,不用自己算,代码简单。测量范围宽,室内光照一般在几十到几百Lux,完全够用。这个项目里BH1750用来检测环境亮度,配合人体感应实现小夜灯自动控制。

BH1750模块用5V供电。模块板载了LDO和电平转换,I2C信号线出来也是3.3V逻辑,跟STM32直连。

2.4 SGP30空气质量传感器

SGP30是Sensirion公司出的金属氧化物空气质量传感器,I2C接口,地址固定0x58。

主要参数:

    • 测量输出:TVOC(ppb)和eCO2(ppm)• TVOC测量范围:0 ~ 60000 ppb• eCO2测量范围:400 ~ 60000 ppm• 工作电压:1.62V ~ 1.98V(核心),模块一般带LDO支持3.3V/5V输入• 通信接口:I2C,最高400kHz• 预热时间:约15秒

选型理由:SGP30是少数能同时输出TVOC和eCO2的低成本传感器,I2C接口,用起来方便。金属氧化物传感器需要预热,上电后前15秒读数不准,代码里做个延时再开始采集。还有一个重要的点是基线校准,SGP30运行一段时间后会自动校准基线,可以把基线存到Flash里,下次上电恢复,这样读数会更准确。

SGP30模块用5V供电。模块上有LDO把5V降到1.8V给芯片供电,I2C信号线有电平转换,输出3.3V逻辑。

这里说一下三个传感器共用I2C总线的问题。SHT30地址0x44,BH1750地址0x23,SGP30地址0x58,三个地址不冲突,可以挂在同一组I2C上。I2C总线需要上拉电阻,三个模块一般都板载了4.7K上拉,如果总线上拉太多可能影响信号质量,实际用下来没问题。如果通信不稳定,可以把模块上多余的上拉电阻拆掉,只保留一组。

2.5 SR602人体感应模块

SR602是一款微型人体红外感应模块,比常见的HC-SR501小很多,适合做紧凑型设计。

主要参数:

    • 工作电压:3.3V ~ 5V• 输出方式:数字量,高电平=有人,低电平=无人• 感应距离:3米左右• 延时时间:可通过电位器调节• 封锁时间:可通过电位器调节

接线很简单,VCC接5V,GND接地,OUT接STM32的PB5。PB5配置成输入模式,读引脚电平就知道有没有人。

代码里做了一个活动时长累计功能。检测到有人的时候开始计时,人走了停止计时,累计当天的活动时长(分钟)。这个数据也一起上报到华为云。

2.6 AI-WB2-01S WiFi模组

AI-WB2-01S是安信可出的WiFi模组,基于BL602芯片,支持802.11 b/g/n,支持AT指令,支持MQTT协议。

主要参数:

    • 工作电压:3.3V• 通信接口:UART,默认波特率115200• 支持WiFi模式:STA/AP/STA+AP• 支持协议:TCP/UDP/MQTT/HTTP• 天线:板载PCB天线或IPEX外接天线

选这个模组的原因是它自带MQTT AT指令,不需要自己写MQTT协议栈,通过串口发AT指令就能连接华为云。接线也简单,RXD接STM32的PA2(USART2_TX),TXD接PA3(USART2_RX),VCC接3.3V,GND接地。

注意WiFi模组发射的时候电流峰值能到300mA以上,3.3V电源一定要够电流,不然会反复重启。用的AMS1117-3.3最大输出800mA,够用。

2.7 红外学习模组(成品串口模块)

这个项目里红外遥控用的是成品红外学习模组,不是HS0038接收头加红外发射管那种分立方案。成品模块内部集成了红外接收头、红外发射管和主控芯片,通过串口协议跟STM32通信。

模块的工作原理:

学习模式:STM32通过串口发送学习命令,模块进入学习状态,等待接收红外信号。拿遥控器对准模块按一个键,模块接收并解调红外信号,把码值通过串口返回给STM32。•

发射模式:STM32通过串口发送发射命令加码值数据,模块按照码值调制38K载波,驱动红外发射管把信号发出去。

用成品模块的好处是不用自己研究各种红外协议(NEC、RC5、Sony SIRC等等),模块内部把协议解析和信号调制都做好了。STM32只需要收发串口数据,代码简单很多。

模块用5V供电,RXD接PB10(USART3_TX),TXD接PB11(USART3_RX),GND接地。

模块的具体串口协议会在第五章详细讲。不同厂家的模块协议可能不一样,代码里设计了一个通用的协议框架,具体协议以所购模块的手册为准,到时候改一下命令字和数据格式就行。

2.8 0.96寸OLED显示屏

OLED用的是最常见的0.96寸SSD1306驱动的128x64分辨率显示屏,I2C接口,地址0x3C(有些模块是0x3D,看DC引脚接法)。

主要参数:

    • 分辨率:128 x 64• 驱动芯片:SSD1306• 通信接口:I2C,400kHz• 工作电压:3.3V ~ 5V(模块带LDO)• 显示颜色:白色/蓝色/蓝黄双色

OLED单独用一组I2C总线,SCL接PB0,SDA接PB1。为什么不跟传感器共用一组?主要是为了避免总线冲突和时序问题。传感器在采集数据的时候I2C总线可能被占用,OLED刷新又比较频繁,分开两组总线各自独立工作,稳定性更好。PB0和PB1用软件模拟I2C时序,代码里可以控制。

2.9 蜂鸣器模块

用的是有源蜂鸣器模块,5V供电,VCC接5V,GND接地,IO接PA15。有源蜂鸣器内部自带振荡电路,给高电平或者低电平就响(具体高触发还是低触发看模块,用的是高电平触发,代码里会说明)。

蜂鸣器在两种情况下会响:

    1. 报警:CO2或TVOC超过小程序设置的上限时,蜂鸣器间歇鸣叫2. K2按键测试:按一下K2响一声,确认蜂鸣器正常工作

PA15这个引脚注意一下,它是JTAG的JTDI引脚,默认上电是JTAG功能。如果用PA15做普通GPIO,需要在初始化的时候关闭JTAG,保留SWD。代码里会有这一步:__HAL_AFIO_REMAP_SWJ_NOJTAG()

2.10 LED起夜灯

起夜灯用一颗大功率LED灯珠或者LED灯板,5V供电。控制脚接PA8,PA8可以复用为定时器1的通道1,用PWM调光。简单用高低电平控制开关也可以,PWM可以调节亮度,体验更好。

自动开灯的条件是:环境亮度低于小程序设置的亮度下限,同时SR602检测到有人。两个条件同时满足才开灯。灯亮之后如果人走了,延时一段时间再关灯,避免人一动就灭。手动模式下可以通过小程序直接控制开关。

2.11 三个指示灯LED

系统用了3个LED指示灯:

指示灯 引脚 功能
运行指示灯 PC13 板载LED,周期闪烁表示程序正常运行
上传指示灯 PC14 数据上传到华为云时闪烁一下
报警指示灯 PC15 CO2或TVOC超标时常亮或闪烁

PC13、PC14、PC15在STM32F103C8T6上是RTC相关的引脚,做普通GPIO用没问题。注意PC13的驱动能力比较弱,只能用来驱动LED,不能接大电流负载。很多最小系统板上PC13已经接了板载LED,直接用就行。

2.12 四个按键

四个轻触按键,一端接地,另一端接STM32的GPIO,GPIO配置成上拉输入。按下时引脚读到低电平,松开读到高电平。

按键 引脚 功能
K1 PB12 翻页键,循环切换OLED显示页(1→2→3→1)
K2 PB13 测试蜂鸣器,按一下响一声
K3 PB14 测试起夜灯,按一下亮再按一下灭
K4 PB15 扩展备用,可自定义功能

按键扫描用软件消抖,检测到按下后延时10ms再读一次,确认是低电平才算有效按下。

2.13 电源设计

电源部分用Micro USB或者Type-C接口输入5V,5V直接给BH1750、SGP30、SR602、红外模块、蜂鸣器、起夜灯供电。5V经过AMS1117-3.3稳压到3.3V,给STM32、SHT30、OLED、AI-WB2-01S供电。

电源入口加一个100uF电解电容滤波,3.3V输出加10uF和0.1uF电容滤波。每个传感器模块的VCC和GND之间就近放一个0.1uF去耦电容

画PCB的时候注意电源线和地线要粗一点,WiFi模组那部分电流大,走线至少1mm以上。模拟地和数字地单点连接,减少干扰。

2.14 完整硬件接线总表

下面是整个系统的接线总表,照着接就行。所有引脚定义跟后面的代码完全一致,不要自己改引脚,改了代码里也要对应改。

2.14.1 I2C总线1(PA6=SDA,PA7=SCL,软件模拟I2C)

三个传感器共用这组I2C总线,地址不同不冲突。

器件 VCC GND SDA SCL I2C地址
SHT30温湿度 3.3V GND PA6 PA7 0x44
BH1750光强度 5V GND PA6 PA7 0x23
SGP30空气质量 5V GND PA6 PA7 0x58

注意:三个器件共用同一组I2C总线(PA6/PA7)。BH1750和SGP30用5V供电,SHT30用3.3V供电。I2C信号线都是3.3V逻辑(STM32F103的IO是3.3V),买模块的时候确认模块是否带电平转换或者上拉到3.3V。用的这几个模块SDA/SCL线上拉到3.3V,跟STM32直连没有问题。

2.14.2 I2C总线2(PB0=SCL,PB1=SDA,软件模拟I2C)

OLED单独用这组I2C。

器件 VCC GND SCL SDA I2C地址
0.96寸OLED(SSD1306) 3.3V GND PB0 PB1 0x3C
2.14.3 串口1 — WiFi模组(USART2)
AI-WB2-01S引脚 接STM32 说明
RXD PA2 (USART2_TX) STM32发,模块收
TXD PA3 (USART2_RX) 模块发,STM32收
VCC 3.3V 供电必须3.3V
GND GND 共地
2.14.4 串口2 — 红外学习模组(USART3)
红外模块引脚 接STM32 说明
RXD PB10 (USART3_TX) STM32发,模块收
TXD PB11 (USART3_RX) 模块发,STM32收
VCC 5V 模块5V供电
GND GND 共地
2.14.5 其他GPIO
器件/功能 引脚 说明
蜂鸣器 PA15 有源蜂鸣器模块(VCC=5V,GND=GND,IO=PA15),高电平触发
人体感应SR602 PB5 VCC=5V,GND=GND,IO=PB5,高电平=有人
LED起夜灯 PA8 VCC=5V,GND=GND,IO=PA8,PWM调光
程序运行指示灯 PC13 板载LED,周期闪烁表示正常运行
上传指示灯 PC14 数据上传到华为云时闪烁
报警指示灯 PC15 CO2/TVOC超标时常亮或闪烁
按键K1 PB12 翻页键,切换OLED显示页
按键K2 PB13 测试蜂鸣器
按键K3 PB14 测试起夜灯
按键K4 PB15 扩展备用

PA15是JTAG的JTDI引脚,默认是JTAG功能。代码里需要调用__HAL_AFIO_REMAP_SWJ_NOJTAG()关闭JTAG保留SWD,PA15才能当普通GPIO用。PB3和PB4也是JTAG引脚,这个项目没用到,不用管。

2.14.6 引脚分配总览

为了直观,把所有引脚分配列在一起:

STM32引脚 功能 方向
PA2 USART2_TX → WiFi RXD 输出
PA3 USART2_RX ← WiFi TXD 输入
PA6 I2C1_SDA(传感器) 开漏输出/输入
PA7 I2C1_SCL(传感器) 开漏输出/输入
PA8 起夜灯控制(PWM) 输出
PA15 蜂鸣器控制 输出
PB0 I2C2_SCL(OLED) 开漏输出/输入
PB1 I2C2_SDA(OLED) 开漏输出/输入
PB5 SR602人体感应输入 输入
PB10 USART3_TX → 红外模块RXD 输出
PB11 USART3_RX ← 红外模块TXD 输入
PB12 按键K1 上拉输入
PB13 按键K2 上拉输入
PB14 按键K3 上拉输入
PB15 按键K4 上拉输入
PC13 运行指示灯 输出
PC14 上传指示灯 输出
PC15 报警指示灯 输出

2.15 嘉立创EDA绘制原理图和PCB

原理图和PCB用嘉立创EDA专业版画的,免费好用,元器件库也全。

画原理图的步骤:

    1. 新建工程,命名为"STM32_ENV_IR_IOT"2. 从元件库放置STM32F103C8T6、AMS1117-3.3、各个接口排针3. 按照接线表连接网络标号4. 检查ERC(电气规则检查),确保没有未连接的引脚和冲突5. 原理图转PCB

PCB布局注意事项:

    • STM32放中间,传感器接口放板子边缘方便接线• WiFi模组放板子边缘,天线方向朝外,不要铺铜挡住天线• 电源线尽量粗,5V和3.3V主线走1.5mm以上• 去耦电容尽量靠近芯片VCC引脚• 晶振电路(如果用外部晶振)靠近OSC引脚• USART的TX/RX等长走线,I2C总线不需要特别等长• 板子用2层板就行,顶层走信号,底层大面积铺地

画好之后直接在嘉立创下单打样,5块钱10片PCB,快递两三天就到。元器件可以在嘉立创SMT一起贴,也可以自己手焊。芯片是LQFP48封装,间距0.5mm,手焊有一点难度,拖焊加吸锡带多练几次就好。

2.16 与非AI辅助选型小结

回头看整个选型过程,与非AI确实帮了不少忙。以前选型要翻好几个网站,查手册要到处找PDF。现在直接在与非AI上搜型号,主流的基本都能找到,还能看ECAD模型直接导入EDA。找替代料也方便,某个芯片缺货或者太贵,很快能找到pin to pin替代。

特别是做方案初期,在与非AI上搜关键词,3万多个电路方案和参考设计可以参考,不用从零开始,感兴趣可以看看。

2.17 原理图设计要点

画原理图的时候有几个细节注意一下:

去耦电容:

    • STM32的每个VDD引脚旁边放一个0.1uF陶瓷电容,尽量靠近引脚• VDDA引脚加10uF加0.1uF滤波• AMS1117输入输出各放一个10uF钽电容加0.1uF陶瓷电容• 每个传感器模块接口的VCC引脚旁放0.1uF去耦电容

上拉电阻:

    • I2C总线SDA/SCL各加一个4.7K上拉电阻到3.3V• 如果模块已经板载上拉,原理图上可以不加或者用0欧电阻预留• 按键不需要外部上拉,用STM32内部上拉就行

指示灯:

    • 每个LED串联一个1K限流电阻• PC13板载LED如果最小系统板上已经有了,自己画PCB时可以不加• 上传灯和报警灯建议用不同颜色:绿色=运行,蓝色=上传,红色=报警

接口设计:

    • 传感器用4Pin或6Pin排针座,标注VCC/GND/SDA/SCL• 串口模块用4Pin排针:VCC/GND/TX/RX,注意TX接RX交叉• 预留SWD调试接口(4Pin:VCC/GND/SWDIO/SWCLK)• 预留USART1调试串口(PA9/PA10),方便打日志

BOOT和复位:

    • BOOT0引脚加10K下拉电阻到GND,正常从Flash启动• NRST引脚加10K上拉电阻和一个100nF电容到GND,加一个复位按键• 下载调试用SWD接口(PA13 SWDIO,PA14 SWCLK)

2.18 PCB布局布线建议

PCB布局直接影响系统稳定性,特别是有WiFi和模拟传感器的情况下:

布局原则:

    • STM32放板子正中间,方便各个方向走线• WiFi模组放板子边缘,天线朝外伸出板边3mm以上,天线下方所有层禁止铺铜• USB电源接口放板子边缘,方便插线• 传感器接口按功能分组放在板子四周• 晶振尽量靠近STM32的OSC引脚,走线短而粗,周围包地• AMS1117放在电源入口附近,散热焊盘打过孔到底层散热

布线原则:

    • 电源线:5V和3.3V主线走1.5mm,支线0.8mm• 地线:底层大面积铺地,不要分割地平面• I2C信号线:SDA和SCL尽量等长,走0.3mm,远离高频信号• 串口线:TX/RX走0.254mm即可,不需要等长• PWM线(PA8):普通走线就行,PWM频率不高• 过孔用0.6mm内径/1.0mm外径• WiFi模组下方不要走任何信号线

EMC注意:

    • 晶振下方铺地,不要走其他信号线• 电源入口加一个100uH电感或者磁珠做滤波• WiFi模组的3.3V电源加一个100uF电解电容蓄能• 蜂鸣器是感性负载,在蜂鸣器两端反并联一个1N4148续流二极管(如果是有源蜂鸣器模块一般已经自带)• 每个IC的电源引脚就近放0.1uF去耦电容

画完PCB之后用嘉立创EDA的DRC检查一遍,确保没有间距违规、未连接网络等问题。再看一眼3D预览,确认元器件封装方向没有搞错。特别是电解电容、USB接口、排针这些有方向的器件,封装反了焊不上。

2.19 打样和焊接

嘉立创打样很方便,在EDA里直接点"下单PCB",会自动跳转到下单页面。

选择参数:

    • 板子数量:5片或10片• 层数:2层• 板厚:1.6mm• 颜色:绿色最便宜,其他颜色加钱• 表面处理:喷锡(HASL)就行• 最小线宽/线距:6/6mil(嘉立创免费工艺)

填好收货地址付款,一般24小时内发货,快递2到3天到。新用户首单有优惠,5片板可能就几块钱包邮。

收到PCB后先检查:

    1. 目视检查有没有短路、断线、丝印错误2. 用万用表量3.3V和GND之间没有短路3. 焊上电源部分(USB座、AMS1117、电容),量输出电压4. 电压正常后焊STM32和外围电路5. 最后焊排针插座,插模块

焊接顺序建议:先焊电源(USB座、LDO、电容),测试电压正常后焊STM32和晶振,然后焊电阻电容LED等小器件,最后焊排针和接口。LQFP48芯片焊接:先在一个对角焊盘上锡固定芯片,确认对齐后所有引脚涂上助焊剂,用烙铁头带少量锡快速拖过所有引脚,多余的锡用吸锡带吸掉。焊完用放大镜检查每个引脚有没有桥接和虚焊。


三、华为云物联网平台搭建

硬件准备好之后,先把云平台搭起来。云平台用华为云物联网平台(IoTDA),设备通过MQTT协议上报数据,小程序通过华为云API查询数据和下发命令。

3.1 注册登录华为云

打开华为云官网(https://www.huaweicloud.com/ ),注册一个账号。如果有账号直接登录。登录之后需要完成实名认证,个人认证就行,免费的。

在控制台搜索"物联网平台"或者直接访问IoTDA服务。华为云IoTDA有免费额度,每个月一定量的消息数不收费,个人做项目够用。

3.2 创建产品

进入物联网平台控制台,左侧菜单选择"产品",点击"创建产品"。

填写产品信息:

    • 产品名称:STM32环境监测仪• 协议类型:MQTT• 数据格式:JSON• 设备类型:自定义• 所属行业:自定义• 产品类型:EnvironmentalMonitor

创建完成后,点进产品详情,记录下产品ID(Product ID)和资源空间信息,后面会用到。

3.3 定义产品模型

产品模型描述了设备有哪些属性、支持哪些命令。这一步很重要,设备上报的数据要跟模型里的属性对应上。

在产品详情页选择"模型定义",点击"自定义模型",添加服务。

服务ID设为:env_monitor

3.3.1 属性列表

添加以下属性:

属性名 数据类型 说明 取值范围
temperature decimal 环境温度(℃) -40.0 ~ 125.0
humidity decimal 环境湿度(%RH) 0.0 ~ 100.0
light int 光照强度(Lux) 0 ~ 65535
tvoc int TVOC浓度(ppb) 0 ~ 60000
eco2 int eCO2浓度(ppm) 400 ~ 60000
human_present int 人体状态(0=无人,1=有人) 0 ~ 1
human_duration int 当日活动时长(分钟) 0 ~ 1440
night_light int 小夜灯状态(0=关,1=开) 0 ~ 1
smart_mode int 智能模式(0=关闭,1=开启) 0 ~ 1
light_threshold int 亮度下限(Lux) 0 ~ 1000
co2_alarm_limit int CO2报警上限(ppm) 400 ~ 60000
tvoc_alarm_limit int TVOC报警上限(ppb) 0 ~ 60000
ir_code string 红外码值(Base64编码) -

3.3.2 命令列表

添加以下命令:

命令名 说明 参数
ir_learn 学习红外码 slot(按键编号,int)
ir_send 发送红外码 slot(按键编号,int)
set_light_threshold 设置亮度下限 value(Lux,int)
set_co2_alarm 设置CO2报警上限 value(ppm,int)
set_tvoc_alarm 设置TVOC报警上限 value(ppb,int)
control_night_light 控制小夜灯 state(0=关,1=开,int)
set_smart_mode 设置智能模式 state(0=关,1=开,int)

[图片:产品模型命令定义页面]

3.4 注册设备

产品模型定义好之后,回到左侧菜单选择"设备",点击"注册设备"。

填写设备信息:

    • 所属产品:选择刚创建的"STM32环境监测仪"• 设备标识码:自定义,比如"stm32_env_001"• 设备名称:随便填,比如"客厅环境监测仪"• 认证方式:密钥• 密钥:可以自己填,也可以点自动生成

注册成功后会弹出设备ID和密钥,一定要保存好,关掉弹窗就看不到完整密钥了。

    • • 设备ID(Device ID):类似

{device_id}_{random}

     的一串字符• 密钥(Secret):一串随机字符串

3.5 MQTT连接参数

设备通过MQTT协议连接华为云,需要以下参数:

MQTT Broker地址:在设备详情页的"MQTT连接地址"里查看,格式类似 xxxxxx.st1.iothub.com

端口:1883(非加密)或8883(加密)•

Client ID:设备ID•

Username:设备ID•

Password:用HMAC-SHA256计算,具体算法见华为云文档。简单来说是用设备密钥对时间戳做HMAC-SHA256,然后Base64编码。

AI-WB2-01S模组支持MQTT AT指令,可以直接配置这些参数连接。在代码里会用AT指令设置MQTT的客户端ID、用户名、密码,然后连接。

华为云MQTT需要的密码计算比较麻烦,AI-WB2-01S的AT指令里有一条专门设置MQTT连接参数的命令,把设备ID和密钥填进去,模组内部会自动算密码。

3.6 Topic说明

设备和平台之间通过Topic通信。华为云IoTDA的Topic格式是固定的:

3.6.1 设备上报属性

Topic:

$oc/devices/{device_id}/sys/properties/report

设备往这个Topic发布消息,平台就能收到设备上报的数据。

3.6.2 平台下发命令

订阅Topic:

$oc/devices/{device_id}/sys/commands/#

设备订阅这个Topic,平台下发命令的时候设备就能收到。

3.6.3 设备上报JSON格式
{
  "services": [
    {
      "service_id": "env_monitor",
      "properties": {
        "temperature": 25.3,
        "humidity": 60.2,
        "light": 120,
        "tvoc": 344,
        "eco2": 450,
        "human_present": 1,
        "human_duration": 120,
        "night_light": 0
      }
    }
  ]
}

这个JSON就是STM32通过WiFi模组发布到MQTT Topic的内容。每个字段对应产品模型里定义的属性。

3.6.4 命令下发JSON格式

平台下发命令的格式:

{
  "service_id": "env_monitor",
  "command_name": "set_light_threshold",
  "paras": {
    "value": 10
  }
}

不同命令的paras不一样:

设置亮度下限:

{"service_id":"env_monitor","command_name":"set_light_threshold","paras":{"value":10}}

设置CO2报警上限:

{"service_id":"env_monitor","command_name":"set_co2_alarm","paras":{"value":1000}}

设置TVOC报警上限:

{"service_id":"env_monitor","command_name":"set_tvoc_alarm","paras":{"value":1000}}

控制小夜灯:

{"service_id":"env_monitor","command_name":"control_night_light","paras":{"state":1}}

红外学习:

{"service_id":"env_monitor","command_name":"ir_learn","paras":{"slot":1}}

发送红外码:

{"service_id":"env_monitor","command_name":"ir_send","paras":{"slot":1}}

3.7 华为云API对接

小程序不直接连设备,而是通过华为云API跟平台交互。需要用到的API有:

3.7.1 获取Token

调用华为云API需要先获取Token。Token的有效期是24小时,获取到之后缓存在小程序里,过期了重新获取。

请求地址:

POST https://iam.myhuaweicloud.com/v3/auth/tokens

请求体里需要填域名、用户名、密码、项目ID等信息。获取到Token后,后续请求在Header里带上 X-Auth-Token

3.7.2 查询设备影子

设备影子保存了设备最新上报的属性值。小程序通过查询设备影子获取实时数据。

请求地址:

GET https://{endpoint}/v5/iot/{project_id}/devices/{device_id}/shadow

返回的数据里包含所有属性的最新值。

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

3.7.3 下发命令

小程序通过API给设备下发命令。

请求地址:

POST https://{endpoint}/v5/iot/{project_id}/devices/{device_id}/commands

请求体里填service_id、command_name和paras,跟3.6.4里的格式一样。

具体的API封装代码在第六章小程序开发里给出。

3.8 在线调试

华为云提供了在线调试功能,可以在网页上直接给设备下发命令,不用等小程序开发完就能测试。

在设备详情页点击"命令下发",选择命令,填参数,点发送。如果设备在线且订阅了命令Topic,就能收到命令。

这个功能在调试STM32端代码的时候特别有用。先让设备连上华为云,然后用在线调试发命令,看STM32能不能收到并正确响应。

3.9 华为云设备分组和管理

设备多了之后需要分组管理。在IoTDA控制台左侧菜单选"设备分组",可以创建分组,比如按房间分"客厅""卧室""书房",把不同设备加到对应分组里。

分组的好处:

    • 批量管理设备,方便查找• 可以按分组配置规则• 后续做批量OTA升级方便

个人项目如果只有一台设备,分组功能用不上。但了解一下没坏处,以后设备多了自然会用到。

3.10 规则引擎配置

华为云IoTDA支持规则引擎,可以设置条件触发动作。比如CO2超过1000ppm时自动发通知,数据转发到其他云服务等。

配置方法:

    1. 控制台左侧选"规则引擎",创建规则2. 选择触发条件:设备属性上报时3. 设置SQL查询语句,筛选eco2 > 1000的数据4. 设置动作:发送通知、转发到DIS/Kafka/OBS等

举个例子,设置CO2超标转发到函数工作流,函数计算后调用微信订阅消息API推送到手机。这样不用打开小程序也能收到报警通知。

规则引擎的具体配置根据需求来,不配置不影响基础功能。

3.11 华为云API调试工具

调华为云API的时候,推荐用Postman或者华为云自带的API Explorer。API Explorer在控制台顶部"更多"里能找到,或者直接访问 https://apiexplorer.developer.huaweicloud.com/ 。

API Explorer的好处:

    • 在线填写参数,自动生成请求• 可以直接在线调试,看返回结果• 自动生成各语言的SDK代码(Java/Python/Go/Node.js等)• 不需要自己拼请求签名

调试Token获取和设备影子查询的时候,先用API Explorer跑通,确认参数和返回格式正确,再写到小程序代码里,能省不少事。

3.12 华为云免费额度说明

个人开发者最关心费用问题。华为云IoTDA有免费额度:

    • 设备连接数:每个实例一定数量的免费设备连接• 消息数:每月前一定条消息免费• 规则引擎:基础功能免费

具体免费额度以华为云官网最新说明为准。个人做项目,一个月几万个消息根本用不完,基本零成本。如果设备量很大才需要考虑付费。

注意:如果开通了数据转发到其他云服务(如函数工作流、对象存储),那些服务可能单独计费。开通前看清楚计费说明,不用时及时关闭。


四、STM32软件设计

这一章是全文最核心的部分,所有STM32端的代码都会给出。代码用HAL库写,Keil5编译。系统架构选择FreeRTOS多任务,把不同功能拆成独立任务,逻辑清晰,便于维护。

4.1 Keil5工程搭建

4.1.1 新建工程

打开Keil5,点击Project → New uVision Project,选一个文件夹保存,工程名随便取,比如"STM32_ENV_IOT"。

在芯片选择界面找到STM32F103C8,选STM32F103C8Tx。

弹出Manage Run-Time Environment窗口,勾选以下组件:

    • CMSIS → CORE• Device → Startup• Device → HAL → 勾选需要的HAL模块(GPIO、I2C、USART、TIM、FLASH、RCC、CORTEX等)

如果用STM32CubeMX生成初始化代码会更方便。这里直接用CubeMX配置引脚和时钟,生成Keil工程,然后在里面加应用代码。

4.1.2 CubeMX配置

打开STM32CubeMX,新建工程,选STM32F103C8Tx。

配置引脚:

引脚 功能 模式
PA2 USART2_TX 复用推挽输出
PA3 USART2_RX 浮空输入
PA6 GPIO_OUTPUT 开漏输出(模拟I2C1 SDA)
PA7 GPIO_OUTPUT 开漏输出(模拟I2C1 SCL)
PA8 TIM1_CH1 复用推挽输出(PWM起夜灯)
PA15 GPIO_OUTPUT 推挽输出(蜂鸣器)
PB0 GPIO_OUTPUT 开漏输出(模拟I2C2 SCL)
PB1 GPIO_OUTPUT 开漏输出(模拟I2C2 SDA)
PB5 GPIO_INPUT 上拉输入(人体感应)
PB10 USART3_TX 复用推挽输出
PB11 USART3_RX 浮空输入
PB12 GPIO_INPUT 上拉输入(K1)
PB13 GPIO_INPUT 上拉输入(K2)
PB14 GPIO_INPUT 上拉输入(K3)
PB15 GPIO_INPUT 上拉输入(K4)
PC13 GPIO_OUTPUT 推挽输出(运行灯)
PC14 GPIO_OUTPUT 推挽输出(上传灯)
PC15 GPIO_OUTPUT 推挽输出(报警灯)

时钟配置:外部8MHz晶振,PLL倍频到72MHz。

USART2和USART3波特率都设为115200,8位数据,1位停止,无校验。

TIM1配置PWM,通道1,频率10kHz,占空比初始0。

在SYS配置里把Debug设为"Serial Wire"(SWD),这样PA13/PA14留给调试器。PA15默认是JTAG的JTDI,需要把Debug从JTAG改成SWD才能释放PA15。

配置完点GENERATE CODE生成工程。

[图片:CubeMX引脚配置截图]

[图片:CubeMX时钟配置截图]

4.1.3 FreeRTOS配置

CubeMX里Middleware → FREERTOS选CMSIS_V2。后面在代码里手动创建任务,也可以在CubeMX里配置任务。

如果不想用FreeRTOS,用裸机主循环加定时器也能实现。本文用FreeRTOS来写,多任务的方式逻辑更清楚。

生成代码后,在Keil里能看到工程目录结构。后续的驱动文件都手动添加到工程里。

4.2 软件架构

整个系统分成6个FreeRTOS任务:

任务名 优先级 周期/触发 功能
SensorTask 2秒 读取SHT30、BH1750、SGP30数据
OledTask 500ms 刷新OLED显示
WifiTask 5秒 检查WiFi/MQTT连接,上报数据
KeyTask 20ms 按键扫描、消抖、处理
AlarmTask 1秒 检查CO2/TVOC是否超限,控制蜂鸣器和报警灯
NightLightTask 1秒 根据亮度和人体状态控制小夜灯

任务之间用队列和互斥锁通信:

    • 传感器数据存在全局结构体里,用互斥锁保护• WiFi收到的下发命令通过队列发送给命令处理任务• 按键事件通过队列发送

下面开始写每个模块的代码。

4.3 公共头文件:main.h

先定义一个全局数据结构体和引脚宏,所有模块都要用。

/* main.h */
#ifndef __MAIN_H
#define __MAIN_H

#include "stm32f1xx_hal.h"
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>

/* ===================== 引脚定义 ===================== */
/* I2C1: 传感器总线 PA6=SDA, PA7=SCL */
#define I2C1_SDA_PORT   GPIOA
#define I2C1_SDA_PIN    GPIO_PIN_6
#define I2C1_SCL_PORT   GPIOA
#define I2C1_SCL_PIN    GPIO_PIN_7

/* I2C2: OLED总线 PB0=SCL, PB1=SDA */
#define I2C2_SCL_PORT   GPIOB
#define I2C2_SCL_PIN    GPIO_PIN_0
#define I2C2_SDA_PORT   GPIOB
#define I2C2_SDA_PIN    GPIO_PIN_1

/* 蜂鸣器 PA15 */
#define BUZZER_PORT     GPIOA
#define BUZZER_PIN      GPIO_PIN_15

/* 人体感应 PB5 */
#define HUMAN_PORT      GPIOB
#define HUMAN_PIN       GPIO_PIN_5

/* 起夜灯 PA8 (TIM1_CH1 PWM) */
#define NIGHT_LIGHT_PORT    GPIOA
#define NIGHT_LIGHT_PIN     GPIO_PIN_8

/* 指示灯 PC13=运行 PC14=上传 PC15=报警 */
#define LED_RUN_PORT    GPIOC
#define LED_RUN_PIN     GPIO_PIN_13
#define LED_UPLOAD_PORT GPIOC
#define LED_UPLOAD_PIN  GPIO_PIN_14
#define LED_ALARM_PORT  GPIOC
#define LED_ALARM_PIN   GPIO_PIN_15

/* 按键 K1=PB12 K2=PB13 K3=PB14 K4=PB15 */
#define KEY_K1_PORT     GPIOB
#define KEY_K1_PIN      GPIO_PIN_12
#define KEY_K2_PORT     GPIOB
#define KEY_K2_PIN      GPIO_PIN_13
#define KEY_K3_PORT     GPIOB
#define KEY_K3_PIN      GPIO_PIN_14
#define KEY_K4_PORT     GPIOB
#define KEY_K4_PIN      GPIO_PIN_15

/* ===================== 全局数据结构体 ===================== */
typedef struct {
    /* 传感器数据 */
    float temperature;      /* 温度 ℃ */
    float humidity;         /* 湿度 %RH */
    uint16_t light;         /* 光照 Lux */
    uint16_t tvoc;          /* TVOC ppb */
    uint16_t eco2;          /* eCO2 ppm */
    uint8_t human_present;  /* 人体状态 0=无人 1=有人 */
    uint16_t human_duration;/* 当日活动时长 分钟 */

    /* 设备状态 */
    uint8_t night_light;    /* 小夜灯 0=关 1=开 */
    uint8_t smart_mode;     /* 智能模式 0=关 1=开 */
    uint8_t oled_page;      /* OLED当前页 1/2/3 */

    /* 阈值参数(可从小程序设置) */
    uint16_t light_threshold;   /* 亮度下限 Lux,默认10 */
    uint16_t co2_alarm_limit;   /* CO2报警上限 ppm,默认1000 */
    uint16_t tvoc_alarm_limit;  /* TVOC报警上限 ppb,默认1000 */

    /* 报警状态 */
    uint8_t alarm_active;   /* 报警标志 0=正常 1=报警 */

    /* 云连接状态 */
    uint8_t wifi_connected; /* WiFi连接状态 */
    uint8_t mqtt_connected; /* MQTT连接状态 */
} SystemData_t;

/* 全局数据实例,在main.c中定义 */
extern SystemData_t g_data;

/* 互斥锁句柄 */
extern osMutexId_t dataMutexHandle;

#endif /* __MAIN_H */

4.4 软件I2C驱动:i2c.h / i2c.c

用GPIO模拟I2C时序,支持两组总线。I2C的原理不细讲了,代码里注释写得很清楚。

/* i2c.h */
#ifndef __I2C_H
#define __I2C_H

#include "main.h"

/* I2C总线选择 */
typedef enum {
    I2C_BUS_1 = 0,  /* PA6/PA7 传感器总线 */
    I2C_BUS_2 = 1   /* PB0/PB1 OLED总线 */
} I2C_Bus_t;

/* 初始化I2C总线(GPIO已在CubeMX配置,这里只做初始状态设置) */
void I2C_Init(I2C_Bus_t bus);

/* I2C起始信号 */
void I2C_Start(I2C_Bus_t bus);

/* I2C停止信号 */
void I2C_Stop(I2C_Bus_t bus);

/* 发送一个字节,返回ACK状态 0=ACK 1=NACK */
uint8_t I2C_WriteByte(I2C_Bus_t bus, uint8_t data);

/* 读取一个字节,ack=1发送ACK,ack=0发送NACK */
uint8_t I2C_ReadByte(I2C_Bus_t bus, uint8_t ack);

#endif
/* i2c.c */
#include "i2c.h"

/* 简易微秒延时,72MHz下大致准确 */
static void I2C_Delay(void)
{
    /* 大约5微秒的延时,I2C标准模式100kHz */
    volatile uint8_t i;
    for (i = 0; i < 10; i++);
}

/* 设置SDA电平,开漏输出模式下,写1释放总线(外部上拉拉高),写0拉低 */
static void I2C_SDA_Set(I2C_Bus_t bus, uint8_t state)
{
    if (bus == I2C_BUS_1) {
        HAL_GPIO_WritePin(I2C1_SDA_PORT, I2C1_SDA_PIN,
                          state ? GPIO_PIN_SET : GPIO_PIN_RESET);
    } else {
        HAL_GPIO_WritePin(I2C2_SDA_PORT, I2C2_SDA_PIN,
                          state ? GPIO_PIN_SET : GPIO_PIN_RESET);
    }
}

/* 设置SCL电平 */
static void I2C_SCL_Set(I2C_Bus_t bus, uint8_t state)
{
    if (bus == I2C_BUS_1) {
        HAL_GPIO_WritePin(I2C1_SCL_PORT, I2C1_SCL_PIN,
                          state ? GPIO_PIN_SET : GPIO_PIN_RESET);
    } else {
        HAL_GPIO_WritePin(I2C2_SCL_PORT, I2C2_SCL_PIN,
                          state ? GPIO_PIN_SET : GPIO_PIN_RESET);
    }
}

/* 读SDA电平 */
static uint8_t I2C_SDA_Read(I2C_Bus_t bus)
{
    if (bus == I2C_BUS_1) {
        return HAL_GPIO_ReadPin(I2C1_SDA_PORT, I2C1_SDA_PIN);
    } else {
        return HAL_GPIO_ReadPin(I2C2_SDA_PORT, I2C2_SDA_PIN);
    }
}

void I2C_Init(I2C_Bus_t bus)
{
    /* 释放SDA和SCL,开漏输出+外部上拉,空闲时为高电平 */
    I2C_SDA_Set(bus, 1);
    I2C_SCL_Set(bus, 1);
}

void I2C_Start(I2C_Bus_t bus)
{
    /* 起始信号:SCL高电平时,SDA从高变低 */
    I2C_SDA_Set(bus, 1);
    I2C_SCL_Set(bus, 1);
    I2C_Delay();
    I2C_SDA_Set(bus, 0);  /* SDA拉低,产生起始位 */
    I2C_Delay();
    I2C_SCL_Set(bus, 0);  /* 钳住SCL,准备发送数据 */
    I2C_Delay();
}

void I2C_Stop(I2C_Bus_t bus)
{
    /* 停止信号:SCL高电平时,SDA从低变高 */
    I2C_SDA_Set(bus, 0);
    I2C_SCL_Set(bus, 0);
    I2C_Delay();
    I2C_SCL_Set(bus, 1);  /* SCL拉高 */
    I2C_Delay();
    I2C_SDA_Set(bus, 1);  /* SDA拉高,产生停止位 */
    I2C_Delay();
}

uint8_t I2C_WriteByte(I2C_Bus_t bus, uint8_t data)
{
    uint8_t i, ack;

    /* MSB先发,逐位发送 */
    for (i = 0; i < 8; i++) {
        if (data & 0x80) {
            I2C_SDA_Set(bus, 1);  /* 发送1 */
        } else {
            I2C_SDA_Set(bus, 0);  /* 发送0 */
        }
        data <<= 1;
        I2C_Delay();
        I2C_SCL_Set(bus, 1);      /* SCL拉高,从机读取数据 */
        I2C_Delay();
        I2C_SCL_Set(bus, 0);      /* SCL拉低,准备下一位 */
        I2C_Delay();
    }

    /* 第9个时钟读取ACK */
    I2C_SDA_Set(bus, 1);          /* 释放SDA,交给从机控制 */
    I2C_Delay();
    I2C_SCL_Set(bus, 1);          /* SCL拉高 */
    I2C_Delay();
    ack = I2C_SDA_Read(bus);      /* 读SDA,0=ACK,1=NACK */
    I2C_SCL_Set(bus, 0);
    I2C_Delay();

    return ack;
}

uint8_t I2C_ReadByte(I2C_Bus_t bus, uint8_t ack)
{
    uint8_t i, data = 0;

    I2C_SDA_Set(bus, 1);          /* 释放SDA,准备读取 */

    for (i = 0; i < 8; i++) {
        data <<= 1;
        I2C_SCL_Set(bus, 1);      /* SCL拉高 */
        I2C_Delay();
        if (I2C_SDA_Read(bus)) {  /* 读SDA电平 */
            data |= 0x01;
        }
        I2C_SCL_Set(bus, 0);      /* SCL拉低 */
        I2C_Delay();
    }

    /* 发送ACK或NACK */
    if (ack) {
        I2C_SDA_Set(bus, 0);      /* ACK:拉低SDA */
    } else {
        I2C_SDA_Set(bus, 1);      /* NACK:释放SDA */
    }
    I2C_Delay();
    I2C_SCL_Set(bus, 1);
    I2C_Delay();
    I2C_SCL_Set(bus, 0);
    I2C_Delay();
    I2C_SDA_Set(bus, 1);          /* 释放SDA */

    return data;
}

4.5 SHT30温湿度驱动:sht30.h / sht30.c

SHT30的I2C地址是0x44。读取流程:发送单次测量命令(0x2C06),等待测量完成,读取6个字节(温度MSB/LSB/CRC + 湿度MSB/LSB/CRC),然后换算。

/* sht30.h */
#ifndef __SHT30_H
#define __SHT30_H

#include "main.h"

#define SHT30_ADDR  0x44  /* SHT30 I2C地址(7位地址,左移1位后为0x88) */

/* 读取温湿度
 * temp: 输出温度,单位℃
 * humi: 输出湿度,单位%RH
 * 返回0成功,1失败
 */
uint8_t SHT30_Read(float *temp, float *humi);

#endif
/* sht30.c */
#include "i2c.h"
#include "sht30.h"

/* CRC校验,SHT30用的是CRC-8 */
static uint8_t SHT30_CRC8(const uint8_t *data, uint8_t len)
{
    uint8_t crc = 0xFF;
    uint8_t i, j;
    for (i = 0; i < len; i++) {
        crc ^= data[i];
        for (j = 0; j < 8; j++) {
            if (crc & 0x80) {
                crc = (crc << 1) ^ 0x31;
            } else {
                crc <<= 1;
            }
        }
    }
    return crc;
}

uint8_t SHT30_Read(float *temp, float *humi)
{
    uint8_t buf[6];

    /* 发送单次高精度测量命令 0x2C06 */
    I2C_Start(I2C_BUS_1);
    I2C_WriteByte(I2C_BUS_1, SHT30_ADDR << 1);  /* 写地址 */
    I2C_WriteByte(I2C_BUS_1, 0x2C);             /* 命令高字节 */
    I2C_WriteByte(I2C_BUS_1, 0x06);             /* 命令低字节 */
    I2C_Stop(I2C_BUS_1);

    /* 等待测量完成,高精度模式最多15ms */
    HAL_Delay(20);

    /* 读取6字节数据:温度MSB/LSB/CRC + 湿度MSB/LSB/CRC */
    I2C_Start(I2C_BUS_1);
    I2C_WriteByte(I2C_BUS_1, (SHT30_ADDR << 1) | 0x01);  /* 读地址 */
    buf[0] = I2C_ReadByte(I2C_BUS_1, 1);  /* 温度MSB,回ACK */
    buf[1] = I2C_ReadByte(I2C_BUS_1, 1);  /* 温度LSB,回ACK */
    buf[2] = I2C_ReadByte(I2C_BUS_1, 1);  /* 温度CRC,回ACK */
    buf[3] = I2C_ReadByte(I2C_BUS_1, 1);  /* 湿度MSB,回ACK */
    buf[4] = I2C_ReadByte(I2C_BUS_1, 1);  /* 湿度LSB,回ACK */
    buf[5] = I2C_ReadByte(I2C_BUS_1, 0);  /* 湿度CRC,回NACK结束 */
    I2C_Stop(I2C_BUS_1);

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

    /* 换算温度:T = -45 + 175 * raw / 65535 */
    uint16_t raw_temp = ((uint16_t)buf[0] << 8) | buf[1];
    *temp = -45.0f + 175.0f * (float)raw_temp / 65535.0f;

    /* 换算湿度:RH = 100 * raw / 65535 */
    uint16_t raw_humi = ((uint16_t)buf[3] << 8) | buf[4];
    *humi = 100.0f * (float)raw_humi / 65535.0f;

    return 0;
}

4.6 BH1750光强度驱动:bh1750.h / bh1750.c

BH1750地址0x23。上电后发送连续高分辨率测量命令0x10,等待120ms以上,读取2字节,换算成Lux。

/* bh1750.h */
#ifndef __BH1750_H
#define __BH1750_H

#include "main.h"

#define BH1750_ADDR  0x23  /* BH1750 I2C地址 */

/* 初始化BH1750,返回0成功 */
uint8_t BH1750_Init(void);

/* 读取光强度,返回Lux值,0xFFFF表示读取失败 */
uint16_t BH1750_ReadLight(void);

#endif
/* bh1750.c */
#include "i2c.h"
#include "bh1750.h"

uint8_t BH1750_Init(void)
{
    uint8_t ack;

    /* 发送上电命令 0x01 */
    I2C_Start(I2C_BUS_1);
    ack = I2C_WriteByte(I2C_BUS_1, BH1750_ADDR << 1);
    if (ack) { I2C_Stop(I2C_BUS_1); return 1; }
    I2C_WriteByte(I2C_BUS_1, 0x01);  /* Power On */
    I2C_Stop(I2C_BUS_1);
    HAL_Delay(10);

    /* 发送连续高分辨率测量命令 0x10,分辨率1Lux */
    I2C_Start(I2C_BUS_1);
    I2C_WriteByte(I2C_BUS_1, BH1750_ADDR << 1);
    I2C_WriteByte(I2C_BUS_1, 0x10);  /* Continuously H-Resolution Mode */
    I2C_Stop(I2C_BUS_1);
    HAL_Delay(180);  /* 高分辨率模式首次测量需要等待 */

    return 0;
}

uint16_t BH1750_ReadLight(void)
{
    uint8_t msb, lsb;
    uint16_t raw;

    /* 读取2字节光强度数据 */
    I2C_Start(I2C_BUS_1);
    I2C_WriteByte(I2C_BUS_1, (BH1750_ADDR << 1) | 0x01);  /* 读地址 */
    msb = I2C_ReadByte(I2C_BUS_1, 1);  /* 高字节,回ACK */
    lsb = I2C_ReadByte(I2C_BUS_1, 0);  /* 低字节,回NACK */
    I2C_Stop(I2C_BUS_1);

    raw = ((uint16_t)msb << 8) | lsb;

    /* 换算:Lux = raw / 1.2,这里用整数运算近似 */
    return (uint16_t)(raw * 10 / 12);  /* 等价于 raw / 1.2 */
}

4.7 SGP30空气质量驱动:sgp30.h / sgp30.c

SGP30地址0x58。初始化时发送Init_air_quality命令0x2003,之后每秒发送Measure_air_quality命令0x2008读取TVOC和eCO2。SGP30需要预热,前15秒读数是默认值(CO2=400,TVOC=0)。

/* sgp30.h */
#ifndef __SGP30_H
#define __SGP30_H

#include "main.h"

#define SGP30_ADDR  0x58  /* SGP30 I2C地址 */

/* 初始化SGP30,返回0成功 */
uint8_t SGP30_Init(void);

/* 读取TVOC和eCO2
 * tvoc: 输出TVOC,单位ppb
 * eco2: 输出eCO2,单位ppm
 * 返回0成功
 */
uint8_t SGP30_Read(uint16_t *tvoc, uint16_t *eco2);

/* 获取基线值(用于保存到Flash,下次上电恢复) */
uint8_t SGP30_GetBaseline(uint16_t *eco2_base, uint16_t *tvoc_base);

/* 设置基线值(从Flash恢复) */
uint8_t SGP30_SetBaseline(uint16_t eco2_base, uint16_t tvoc_base);

#endif
/* sgp30.c */
#include "i2c.h"
#include "sgp30.h"

/* SGP30 CRC-8校验,多项式0x31,初值0xFF */
static uint8_t SGP30_CRC8(const uint8_t *data, uint8_t len)
{
    uint8_t crc = 0xFF;
    uint8_t i, j;
    for (i = 0; i < len; i++) {
        crc ^= data[i];
        for (j = 0; j < 8; j++) {
            crc = (crc & 0x80) ? ((crc << 1) ^ 0x31) : (crc << 1);
        }
    }
    return crc;
}

uint8_t SGP30_Init(void)
{
    uint8_t ack;

    /* 发送Init_air_quality命令 0x2003 */
    I2C_Start(I2C_BUS_1);
    ack = I2C_WriteByte(I2C_BUS_1, SGP30_ADDR << 1);
    if (ack) { I2C_Stop(I2C_BUS_1); return 1; }
    I2C_WriteByte(I2C_BUS_1, 0x20);
    I2C_WriteByte(I2C_BUS_1, 0x03);
    I2C_Stop(I2C_BUS_1);

    /* 初始化后需要等待15秒预热,这里先返回,调用方处理 */
    return 0;
}

uint8_t SGP30_Read(uint16_t *tvoc, uint16_t *eco2)
{
    uint8_t buf[6];

    /* 发送Measure_air_quality命令 0x2008 */
    I2C_Start(I2C_BUS_1);
    I2C_WriteByte(I2C_BUS_1, SGP30_ADDR << 1);
    I2C_WriteByte(I2C_BUS_1, 0x20);
    I2C_WriteByte(I2C_BUS_1, 0x08);
    I2C_Stop(I2C_BUS_1);

    /* 测量需要等待,SGP30典型响应时间约12ms */
    HAL_Delay(15);

    /* 读取6字节:CO2 MSB/LSB/CRC + TVOC MSB/LSB/CRC */
    I2C_Start(I2C_BUS_1);
    I2C_WriteByte(I2C_BUS_1, (SGP30_ADDR << 1) | 0x01);
    buf[0] = I2C_ReadByte(I2C_BUS_1, 1);  /* CO2 MSB */
    buf[1] = I2C_ReadByte(I2C_BUS_1, 1);  /* CO2 LSB */
    buf[2] = I2C_ReadByte(I2C_BUS_1, 1);  /* CRC */
    buf[3] = I2C_ReadByte(I2C_BUS_1, 1);  /* TVOC MSB */
    buf[4] = I2C_ReadByte(I2C_BUS_1, 1);  /* TVOC LSB */
    buf[5] = I2C_ReadByte(I2C_BUS_1, 0);  /* CRC,NACK结束 */
    I2C_Stop(I2C_BUS_1);

    /* CRC校验 */
    if (SGP30_CRC8(&buf[0], 2) != buf[2]) return 1;
    if (SGP30_CRC8(&buf[3], 2) != buf[5]) return 1;

    *eco2 = ((uint16_t)buf[0] << 8) | buf[1];
    *tvoc = ((uint16_t)buf[3] << 8) | buf[4];

    return 0;
}

uint8_t SGP30_GetBaseline(uint16_t *eco2_base, uint16_t *tvoc_base)
{
    uint8_t buf[6];

    /* 发送Get_baseline命令 0x2015 */
    I2C_Start(I2C_BUS_1);
    I2C_WriteByte(I2C_BUS_1, SGP30_ADDR << 1);
    I2C_WriteByte(I2C_BUS_1, 0x20);
    I2C_WriteByte(I2C_BUS_1, 0x15);
    I2C_Stop(I2C_BUS_1);
    HAL_Delay(15);

    I2C_Start(I2C_BUS_1);
    I2C_WriteByte(I2C_BUS_1, (SGP30_ADDR << 1) | 0x01);
    buf[0] = I2C_ReadByte(I2C_BUS_1, 1);
    buf[1] = I2C_ReadByte(I2C_BUS_1, 1);
    buf[2] = I2C_ReadByte(I2C_BUS_1, 1);
    buf[3] = I2C_ReadByte(I2C_BUS_1, 1);
    buf[4] = I2C_ReadByte(I2C_BUS_1, 1);
    buf[5] = I2C_ReadByte(I2C_BUS_1, 0);
    I2C_Stop(I2C_BUS_1);

    if (SGP30_CRC8(&buf[0], 2) != buf[2]) return 1;
    if (SGP30_CRC8(&buf[3], 2) != buf[5]) return 1;

    *eco2_base = ((uint16_t)buf[0] << 8) | buf[1];
    *tvoc_base = ((uint16_t)buf[3] << 8) | buf[4];
    return 0;
}

uint8_t SGP30_SetBaseline(uint16_t eco2_base, uint16_t tvoc_base)
{
    /* 发送Set_baseline命令 0x201E + eco2_base(2字节+CRC) + tvoc_base(2字节+CRC) */
    uint8_t buf[2];

    I2C_Start(I2C_BUS_1);
    I2C_WriteByte(I2C_BUS_1, SGP30_ADDR << 1);
    I2C_WriteByte(I2C_BUS_1, 0x20);
    I2C_WriteByte(I2C_BUS_1, 0x1E);

    /* 发送eCO2基线 */
    buf[0] = (eco2_base >> 8) & 0xFF;
    buf[1] = eco2_base & 0xFF;
    I2C_WriteByte(I2C_BUS_1, buf[0]);
    I2C_WriteByte(I2C_BUS_1, buf[1]);
    I2C_WriteByte(I2C_BUS_1, SGP30_CRC8(buf, 2));

    /* 发送TVOC基线 */
    buf[0] = (tvoc_base >> 8) & 0xFF;
    buf[1] = tvoc_base & 0xFF;
    I2C_WriteByte(I2C_BUS_1, buf[0]);
    I2C_WriteByte(I2C_BUS_1, buf[1]);
    I2C_WriteByte(I2C_BUS_1, SGP30_CRC8(buf, 2));

    I2C_Stop(I2C_BUS_1);
    HAL_Delay(15);
    return 0;
}

SGP30有一个基线校准的概念。传感器运行一段时间(建议至少12小时)后,内部会自动校准基线。把基线值存到STM32的Flash里,下次上电时恢复,可以让传感器更快进入准确状态。如果不恢复基线,每次上电都要重新预热和校准,前几个小时读数可能偏大。

4.8 OLED显示驱动:oled.h / oled.c

OLED用SSD1306驱动,128x64分辨率,I2C接口,地址0x3C。这部分代码比较长,分初始化、基础显示、中文字库、3页显示几部分。

/* oled.h */
#ifndef __OLED_H
#define __OLED_H

#include "main.h"

/* OLED初始化 */
void OLED_Init(void);

/* 清屏 */
void OLED_Clear(void);

/* 显示一个ASCII字符,x:列(0-127) y:页(0-7) size:字号 */
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_ShowNum(uint8_t x, uint8_t y, uint32_t num, uint8_t len, uint8_t size);

/* 显示中文,按照中文字库索引 */
void OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t index);

/* 刷新显示(把显存写入OLED) */
void OLED_Refresh(void);

/* 显示第1页:环境数据 */
void OLED_ShowPage1(float temp, float humi, uint16_t eco2, uint16_t tvoc);

/* 显示第2页:状态控制 */
void OLED_ShowPage2(uint8_t human, uint8_t smart, uint16_t light_thr, uint8_t night);

/* 显示第3页:报警上限 */
void OLED_ShowPage3(uint16_t co2_limit, uint16_t tvoc_limit);

#endif

OLED的初始化命令序列和显示原理这里不展开了,直接给代码。中文字库需要取模,用PCtoLCD2002软件,阴码、逐行式、顺向。需要显示的中文字符有:环、境、温、度、湿、二、氧、化、碳、挥、发、气、体、人、体、监、测、智、能、模、式、亮、度、下、限、起、夜、灯、报、警、上、设、置、有、无、开、关。

/* oled.c */
#include "i2c.h"
#include "oled.h"

/* OLED显存,128x64 = 1024字节,每页128字节,共8页 */
static uint8_t OLED_GRAM[1024];

/* ===================== ASCII字库(6x8和8x16) ===================== */
/* 6x8 ASCII 0-9 A-Z a-z 标点,这里只列出部分常用字符 */
static const uint8_t F6x8[][6] = {
    {0x00,0x00,0x00,0x00,0x00,0x00}, /*   0 空格 */
    {0x00,0x00,0x00,0x2f,0x00,0x00}, /* ! 1 */
    {0x00,0x00,0x07,0x00,0x07,0x00}, /* " 2 */
    {0x00,0x14,0x7f,0x14,0x7f,0x14}, /* # 3 */
    {0x00,0x24,0x2a,0x7f,0x2a,0x12}, /* $ 4 */
    {0x00,0x62,0x64,0x08,0x13,0x23}, /* % 5 */
    {0x00,0x36,0x49,0x55,0x22,0x50}, /* & 6 */
    {0x00,0x00,0x05,0x03,0x00,0x00}, /* ' 7 */
    {0x00,0x00,0x1c,0x22,0x41,0x00}, /* ( 8 */
    {0x00,0x00,0x41,0x22,0x1c,0x00}, /* ) 9 */
    {0x00,0x14,0x08,0x3E,0x08,0x14}, /* * 10 */
    {0x00,0x08,0x08,0x3E,0x08,0x08}, /* + 11 */
    {0x00,0x00,0x00,0xA0,0x60,0x00}, /* , 12 */
    {0x00,0x08,0x08,0x08,0x08,0x08}, /* - 13 */
    {0x00,0x00,0x60,0x60,0x00,0x00}, /* . 14 */
    {0x00,0x20,0x10,0x08,0x04,0x02}, /* / 15 */
    {0x00,0x3E,0x51,0x49,0x45,0x3E}, /* 0 16 */
    {0x00,0x00,0x42,0x7F,0x40,0x00}, /* 1 17 */
    {0x00,0x42,0x61,0x51,0x49,0x46}, /* 2 18 */
    {0x00,0x21,0x41,0x45,0x4B,0x31}, /* 3 19 */
    {0x00,0x18,0x14,0x12,0x7F,0x10}, /* 4 20 */
    {0x00,0x27,0x45,0x45,0x45,0x39}, /* 5 21 */
    {0x00,0x3C,0x4A,0x49,0x49,0x30}, /* 6 22 */
    {0x00,0x01,0x71,0x09,0x05,0x03}, /* 7 23 */
    {0x00,0x36,0x49,0x49,0x49,0x36}, /* 8 24 */
    {0x00,0x06,0x49,0x49,0x29,0x1E}, /* 9 25 */
    {0x00,0x00,0x36,0x36,0x00,0x00}, /* : 26 */
    {0x00,0x00,0x56,0x36,0x00,0x00}, /* ; 27 */
    {0x00,0x08,0x14,0x22,0x41,0x00}, /* < 28 */
    {0x00,0x14,0x14,0x14,0x14,0x14}, /* = 29 */
    {0x00,0x00,0x41,0x22,0x14,0x08}, /* > 30 */
    {0x00,0x02,0x01,0x51,0x09,0x07}, /* ? 31 */
    {0x00,0x32,0x49,0x59,0x51,0x3E}, /* @ 32 */
    {0x00,0x7C,0x12,0x11,0x12,0x7C}, /* A 33 */
    {0x00,0x7F,0x49,0x49,0x49,0x36}, /* B 34 */
    {0x00,0x3E,0x41,0x41,0x41,0x22}, /* C 35 */
    {0x00,0x7F,0x41,0x41,0x22,0x1C}, /* D 36 */
    {0x00,0x7F,0x49,0x49,0x49,0x41}, /* E 37 */
    {0x00,0x7F,0x09,0x09,0x09,0x01}, /* F 38 */
    {0x00,0x3E,0x41,0x49,0x49,0x7A}, /* G 39 */
    {0x00,0x7F,0x08,0x08,0x08,0x7F}, /* H 40 */
    {0x00,0x00,0x41,0x7F,0x41,0x00}, /* I 41 */
    {0x00,0x20,0x40,0x41,0x3F,0x01}, /* J 42 */
    {0x00,0x7F,0x08,0x14,0x22,0x41}, /* K 43 */
    {0x00,0x7F,0x40,0x40,0x40,0x40}, /* L 44 */
    {0x00,0x7F,0x02,0x0C,0x02,0x7F}, /* M 45 */
    {0x00,0x7F,0x04,0x08,0x10,0x7F}, /* N 46 */
    {0x00,0x3E,0x41,0x41,0x41,0x3E}, /* O 47 */
    {0x00,0x7F,0x09,0x09,0x09,0x06}, /* P 48 */
    {0x00,0x3E,0x41,0x51,0x21,0x5E}, /* Q 49 */
    {0x00,0x7F,0x09,0x19,0x29,0x46}, /* R 50 */
    {0x00,0x46,0x49,0x49,0x49,0x31}, /* S 51 */
    {0x00,0x01,0x01,0x7F,0x01,0x01}, /* T 52 */
    {0x00,0x3F,0x40,0x40,0x40,0x3F}, /* U 53 */
    {0x00,0x1F,0x20,0x40,0x20,0x1F}, /* V 54 */
    {0x00,0x3F,0x40,0x38,0x40,0x3F}, /* W 55 */
    {0x00,0x63,0x14,0x08,0x14,0x63}, /* X 56 */
    {0x00,0x07,0x08,0x70,0x08,0x07}, /* Y 57 */
    {0x00,0x61,0x51,0x49,0x45,0x43}, /* Z 58 */
};

/* 8x16 ASCII字库只列出数字0-9和大写字母,代码篇幅有限,完整字库可从SSD1306驱动例程中获取 */
static const uint8_t F8x16[][16] = {
    {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, /* 空格 */
    /* 数字0-9的8x16字模,这里省略部分,实际使用时补全 */
    {0x00,0xE0,0x10,0x08,0x08,0x10,0xE0,0x00,
     0x00,0x0F,0x10,0x20,0x20,0x10,0x0F,0x00}, /* 0 */
    {0x00,0x10,0x10,0xF8,0x00,0x00,0x00,0x00,
     0x00,0x20,0x20,0x3F,0x20,0x20,0x00,0x00}, /* 1 */
    {0x00,0x70,0x08,0x08,0x08,0x88,0x70,0x00,
     0x00,0x30,0x28,0x24,0x22,0x21,0x30,0x00}, /* 2 */
    {0x00,0x30,0x08,0x88,0x88,0x48,0x30,0x00,
     0x00,0x18,0x20,0x20,0x20,0x11,0x0E,0x00}, /* 3 */
    {0x00,0x00,0xC0,0x20,0x10,0xF8,0x00,0x00,
     0x00,0x07,0x04,0x24,0x24,0x3F,0x24,0x00}, /* 4 */
    {0x00,0xF8,0x08,0x88,0x88,0x08,0x08,0x00,
     0x00,0x19,0x21,0x20,0x20,0x11,0x0E,0x00}, /* 5 */
    {0x00,0xE0,0x10,0x88,0x88,0x18,0x00,0x00,
     0x00,0x0F,0x11,0x20,0x20,0x11,0x0E,0x00}, /* 6 */
    {0x00,0x38,0x08,0x08,0xC8,0x38,0x08,0x00,
     0x00,0x00,0x00,0x3F,0x00,0x00,0x00,0x00}, /* 7 */
    {0x00,0x70,0x88,0x08,0x08,0x88,0x70,0x00,
     0x00,0x1C,0x22,0x21,0x21,0x22,0x1C,0x00}, /* 8 */
    {0x00,0xE0,0x10,0x08,0x08,0x10,0xE0,0x00,
     0x00,0x00,0x31,0x22,0x22,0x11,0x0F,0x00}, /* 9 */
    /* 大写字母和标点的8x16字模,实际项目中补全,这里省略 */
};

/* ===================== 中文字库(16x16) ===================== */
/* 用PCtoLCD2002取模,阴码逐行式顺向 */
/* 索引: 0=环 1=境 2=温 3=度 4=湿 5=二 6=氧 7=化 8=碳
 *       9=挥 10=发 11=气 12=体 13=人 14=监 15=测
 *       16=智 17=能 18=模 19=式 20=亮 21=下 22=限
 *       23=起 24=夜 25=灯 26=报 27=警 28=上 29=设
 *       30=置 31=有 32=无 33=开 34=关
 */
static const uint8_t CN16[][32] = {
    /* 环 */
    {0x10,0x10,0x10,0xFF,0x10,0x12,0x14,0x08,0x10,0x20,0x40,0x80,
     0x40,0x20,0x10,0x00,0x04,0x44,0x82,0x7F,0x01,0x02,0x04,0x40,
     0x80,0x40,0x20,0x10,0x0C,0x03,0x00,0x00},
    /* 境 */
    {0x00,0x20,0x20,0x20,0x3F,0x44,0x44,0x44,0x7F,0x44,0x44,0x44,
     0x44,0x3F,0x00,0x00,0x00,0x40,0x20,0x10,0x0C,0x83,0x40,0x30,
     0x0F,0x10,0x20,0x40,0x80,0x40,0x30,0x00},
    /* 温 */
    {0x10,0x60,0x02,0x8C,0x00,0x00,0x20,0x18,0x07,0x00,0x00,0x3F,
     0x40,0x40,0x70,0x00,0x04,0x04,0x7E,0x01,0x80,0x40,0x20,0x10,
     0x0B,0x0C,0x12,0x22,0x42,0x82,0x7E,0x00},
    /* 度 */
    {0x00,0x00,0x00,0x7F,0x40,0x40,0x40,0x40,0x5F,0x40,0x40,0x40,
     0x40,0x7F,0x00,0x00,0x81,0x41,0x21,0x11,0x09,0x05,0x03,0x01,
     0xFF,0x01,0x01,0x01,0x01,0x01,0x01,0x00},
    /* 湿 */
    {0x00,0x20,0x20,0x20,0x3F,0x44,0x44,0x44,0x7F,0x44,0x44,0x44,
     0x44,0x3F,0x00,0x00,0x00,0x80,0x40,0x20,0x10,0x0C,0x43,0x40,
     0x20,0x10,0x0C,0x13,0x22,0x61,0x20,0x00},
    /* 二 */
    {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF,0x00,0x00,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7F,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
    /* 氧 */
    {0x00,0x00,0xF8,0x88,0x88,0x88,0x08,0x28,0x18,0x0F,0x08,0xC8,
     0x08,0x00,0x00,0x00,0x00,0x00,0x07,0x04,0x04,0x04,0x04,0x04,
     0x04,0x04,0x04,0x07,0x00,0x00,0x00,0x00},
    /* 化 */
    {0x08,0x08,0x88,0xFF,0x48,0x28,0x00,0xF0,0x10,0x10,0x20,0x4F,
     0x80,0x00,0x00,0x00,0x04,0x03,0x00,0xFF,0x00,0x01,0x04,0x04,
     0x04,0x04,0x02,0x02,0x01,0x00,0x00,0x00},
    /* 碳 */
    {0x10,0x60,0x02,0x8C,0x00,0xFE,0x42,0x42,0x42,0x42,0x42,0xFE,
     0x00,0x00,0x00,0x00,0x04,0x04,0xFF,0x00,0x01,0x00,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
    /* 挥 */
    {0x10,0x10,0x10,0xFF,0x10,0x90,0x08,0x08,0x04,0xFA,0x00,0x02,
     0x04,0x08,0x10,0x00,0x02,0x42,0x82,0x7F,0x02,0x01,0x00,0x40,
     0x80,0x7F,0x00,0x00,0x00,0x00,0x00,0x00},
    /* 发 */
    {0x00,0x00,0x00,0x7F,0x40,0x40,0x40,0x40,0x5F,0x40,0x40,0x40,
     0x40,0x7F,0x00,0x00,0x81,0x41,0x21,0x11,0x09,0x05,0x03,0x01,
     0xFF,0x01,0x01,0x01,0x01,0x01,0x01,0x00},
    /* 气 */
    {0x00,0x00,0x00,0x00,0x00,0xFF,0x01,0x01,0x01,0x01,0x01,0x01,
     0x01,0xFF,0x00,0x00,0x00,0x80,0x40,0x20,0x18,0x07,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
    /* 体 */
    {0x00,0x00,0x00,0x00,0x00,0xFC,0x04,0x04,0x04,0x04,0x04,0x04,
     0x04,0xFC,0x00,0x00,0x00,0x40,0x20,0x10,0x0F,0x50,0x50,0x50,
     0x50,0x50,0x50,0x50,0x5F,0x00,0x00,0x00},
    /* 人 */
    {0x00,0x00,0x00,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
     0x3F,0x40,0x40,0x40,0x40,0x78,0x00,0x00},
    /* 监 */
    {0x00,0x00,0x00,0x3E,0x22,0x22,0x3E,0x00,0x7E,0x22,0x22,0x22,
     0x22,0x7E,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
     0x7F,0x01,0x01,0x01,0x01,0x7F,0x00,0x00},
    /* 测 */
    {0x00,0x10,0x10,0x10,0x10,0x10,0xFF,0x10,0x10,0x10,0x10,0x10,
     0x10,0x00,0x00,0x00,0x00,0x02,0x02,0x02,0x02,0x02,0xFF,0x02,
     0x42,0x22,0x12,0x0A,0x06,0x02,0x00,0x00},
    /* 智 */
    {0x00,0x10,0x10,0x90,0x70,0x1F,0x10,0xF0,0x00,0x00,0xFF,0x42,
     0x42,0x42,0x7E,0x00,0x00,0x02,0x42,0x42,0x42,0x42,0x7F,0x42,
     0x42,0x42,0x7F,0x42,0x02,0x00,0x00,0x00},
    /* 能 */
    {0x00,0x20,0x20,0x20,0x20,0x3F,0x20,0x20,0x20,0x3F,0x20,0x20,
     0x20,0x3F,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF,0x01,0x01,
     0x01,0x01,0x01,0x01,0x01,0xFF,0x00,0x00},
    /* 模 */
    {0x10,0x10,0x10,0xFF,0x10,0x90,0x08,0x08,0x04,0xFA,0x00,0x02,
     0x04,0x08,0x10,0x00,0x02,0x42,0x82,0x7F,0x02,0x01,0x00,0x40,
     0x80,0x7F,0x00,0x00,0x00,0x00,0x00,0x00},
    /* 式 */
    {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
    /* 亮 */字模 */
    {0x00,0x00,0xF8,0x88,0x88,0x88,0x08,0x28,0x18,0x0F,0x08,0xC8,
     0x08,0x00,0x00,0x00,0x00,0x00,0x07,0x04,0x04,0x04,0x04,0x04,
     0x04,0x04,0x04,0x07,0x00,0x00,0x00,0x00},
    /* 下 */
    {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF,0x00,0x00,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7F,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
    /* 限 */
    {0x00,0x10,0x10,0x90,0x70,0x1F,0x10,0xF0,0x00,0x00,0xFF,0x42,
     0x42,0x42,0x7E,0x00,0x00,0x02,0x42,0x42,0x42,0x42,0x7F,0x42,
     0x42,0x42,0x7F,0x42,0x02,0x00,0x00,0x00},
    /* 起 */
    {0x00,0x00,0xFE,0x02,0x02,0x02,0x12,0x22,0x22,0xC2,0x02,0x02,
     0x02,0xFE,0x00,0x00,0x00,0x00,0x7F,0x20,0x10,0x08,0x00,0x00,
     0x00,0x3F,0x40,0x40,0x40,0x7F,0x00,0x00},
    /* 夜 */
    {0x00,0x00,0x00,0x00,0x1F,0x10,0x10,0x10,0x1F,0x10,0x10,0x10,
     0x10,0x1F,0x00,0x00,0x00,0x00,0x00,0x00,0xF8,0x08,0x08,0x08,
     0xF8,0x08,0x08,0x08,0x08,0xF8,0x00,0x00},
    /* 灯 */
    {0x10,0x60,0x02,0x8C,0x00,0x00,0x20,0x18,0x07,0x00,0x00,0x3F,
     0x40,0x40,0x70,0x00,0x04,0x04,0x7E,0x01,0x80,0x40,0x20,0x10,
     0x0B,0x0C,0x12,0x22,0x42,0x82,0x7E,0x00},
    /* 报 */
    {0x10,0x60,0x02,0x8C,0x00,0xFE,0x42,0x42,0x42,0x42,0x42,0xFE,
     0x00,0x00,0x00,0x00,0x04,0x04,0xFF,0x00,0x01,0x00,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
    /* 警 */
    {0x10,0x60,0x02,0x8C,0x00,0x00,0x00,0x7F,0x48,0x48,0x48,0x48,
     0x7F,0x00,0x00,0x00,0x04,0x04,0xFF,0x44,0x44,0x20,0x20,0x10,
     0x0B,0x0C,0x12,0x22,0x42,0x82,0x7E,0x00},
    /* 上 */
    {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF,0x00,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF,
     0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
    /* 设 */
    {0x00,0x00,0x00,0x7F,0x40,0x40,0x40,0x40,0x5F,0x40,0x40,0x40,
     0x40,0x7F,0x00,0x00,0x81,0x41,0x21,0x11,0x09,0x05,0x03,0x01,
     0xFF,0x01,0x01,0x01,0x01,0x01,0x01,0x00},
    /* 置 */
    {0x00,0x00,0x00,0x3E,0x22,0x22,0x3E,0x00,0x7E,0x22,0x22,0x22,
     0x22,0x7E,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
     0x7F,0x01,0x01,0x01,0x01,0x7F,0x00,0x00},
    /* 有 */
    {0x00,0x00,0x00,0x3E,0x22,0x22,0x3E,0x00,0x00,0x7E,0x22,0x22,
     0x22,0x7E,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
     0x3F,0x40,0x40,0x40,0x40,0x3F,0x00,0x00},
    /* 无 */
    {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF,0x00,0x00,0x00,0x00,
     0x00,0x00,0x00,0x00,0x00,0x40,0x20,0x10,0x0F,0x50,0x50,0x50,
     0x50,0x50,0x50,0x50,0x5F,0x00,0x00,0x00},
    /* 开 */
    {0x00,0x00,0x00,0x7F,0x40,0x40,0x40,0x40,0x5F,0x40,0x40,0x40,
     0x40,0x7F,0x00,0x00,0x81,0x41,0x21,0x11,0x09,0x05,0x03,0x01,
     0xFF,0x01,0x01,0x01,0x01,0x01,0x01,0x00},
    /* 关 */
    {0x00,0x10,0x60,0x02,0x8C,0x00,0x44,0x44,0x44,0x44,0x44,0xFE,
     0x00,0x00,0x00,0x00,0x04,0x04,0xFF,0x00,0x00,0x00,0x00,0x00,
     0x00,0x00,0x00,0x7F,0x40,0x40,0x70,0x00},
};

/* 向OLED写入命令 */
static void OLED_WriteCmd(uint8_t cmd)
{
    I2C_Start(I2C_BUS_2);
    I2C_WriteByte(I2C_BUS_2, 0x3C << 1);  /* OLED地址+写 */
    I2C_WriteByte(I2C_BUS_2, 0x00);       /* 控制字节:0x00表示命令 */
    I2C_WriteByte(I2C_BUS_2, cmd);
    I2C_Stop(I2C_BUS_2);
}

/* 向OLED写入数据 */
static void OLED_WriteData(uint8_t data)
{
    I2C_Start(I2C_BUS_2);
    I2C_WriteByte(I2C_BUS_2, 0x3C << 1);
    I2C_WriteByte(I2C_BUS_2, 0x40);       /* 控制字节:0x40表示数据 */
    I2C_WriteByte(I2C_BUS_2, data);
    I2C_Stop(I2C_BUS_2);
}

void OLED_Init(void)
{
    HAL_Delay(100);  /* OLED上电后需要等待 */

    OLED_WriteCmd(0xAE);  /* 关闭显示 */
    OLED_WriteCmd(0x20);  /* 设置内存寻址模式 */
    OLED_WriteCmd(0x10);  /* 页寻址模式 */
    OLED_WriteCmd(0xB0);  /* 起始页 */
    OLED_WriteCmd(0xC8);  /* COM扫描方向 */
    OLED_WriteCmd(0x00);  /* 低列地址 */
    OLED_WriteCmd(0x10);  /* 高列地址 */
    OLED_WriteCmd(0x40);  /* 起始行 */
    OLED_WriteCmd(0x81);  /* 对比度 */
    OLED_WriteCmd(0xFF);
    OLED_WriteCmd(0xA1);  /* 段重映射 */
    OLED_WriteCmd(0xA6);  /* 正常显示(非反色) */
    OLED_WriteCmd(0xA8);  /* 多路复用比 */
    OLED_WriteCmd(0x3F);  /* 1/64 duty */
    OLED_WriteCmd(0xA4);  /* 输出跟随RAM内容 */
    OLED_WriteCmd(0xD3);  /* 显示偏移 */
    OLED_WriteCmd(0x00);
    OLED_WriteCmd(0xD5);  /* 时钟分频 */
    OLED_WriteCmd(0xF0);
    OLED_WriteCmd(0xD9);  /* 预充电周期 */
    OLED_WriteCmd(0x22);
    OLED_WriteCmd(0xDA);  /* COM引脚配置 */
    OLED_WriteCmd(0x12);
    OLED_WriteCmd(0xDB);  /* VCOMH电压 */
    OLED_WriteCmd(0x20);
    OLED_WriteCmd(0x8D);  /* 电荷泵 */
    OLED_WriteCmd(0x14);  /* 开启电荷泵 */
    OLED_WriteCmd(0xAF);  /* 开启显示 */

    OLED_Clear();
}

void OLED_Clear(void)
{
    memset(OLED_GRAM, 0, sizeof(OLED_GRAM));
    OLED_Refresh();
}

void OLED_Refresh(void)
{
    uint8_t page, col;
    for (page = 0; page < 8; page++) {
        OLED_WriteCmd(0xB0 + page);       /* 设置页地址 */
        OLED_WriteCmd(0x00);              /* 列低地址 */
        OLED_WriteCmd(0x10);              /* 列高地址 */
        for (col = 0; col < 128; col++) {
            OLED_WriteData(OLED_GRAM[page * 128 + col]);
        }
    }
}

/* 在显存中画一个点,x:0-127 y:0-63 */
static void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t color)
{
    if (x >= 128 || y >= 64) return;
    if (color) {
        OLED_GRAM[(y / 8) * 128 + x] |= (1 << (y % 8));
    } else {
        OLED_GRAM[(y / 8) * 128 + x] &= ~(1 << (y % 8));
    }
}

void OLED_ShowChar(uint8_t x, uint8_t y, char ch, uint8_t size)
{
    uint8_t i, j;
    uint8_t index;

    if (size == 8) {
        /* 6x8字体 */
        if (ch < ' ' || ch > 'Z') index = 0;
        else index = ch - ' ';
        for (i = 0; i < 6; i++) {
            for (j = 0; j < 8; j++) {
                OLED_DrawPoint(x + i, y + j,
                    (F6x8[index][i] >> j) & 0x01);
            }
        }
    } else {
        /* 8x16字体,只处理数字和大写字母 */
        if (ch >= '0' && ch <= '9') index = ch - '0' + 1;
        else if (ch >= 'A' && ch <= 'Z') index = ch - 'A' + 11;
        else index = 0;
        for (i = 0; i < 8; i++) {
            for (j = 0; j < 8; j++) {
                OLED_DrawPoint(x + i, y + j,
                    (F8x16[index][i] >> j) & 0x01);
                OLED_DrawPoint(x + i, y + j + 8,
                    (F8x16[index][i + 8] >> 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 == 8) ? 6 : 8;
        if (x > 122) { x = 0; y += size; }
        str++;
    }
}

void OLED_ShowNum(uint8_t x, uint8_t y, uint32_t num, uint8_t len, uint8_t size)
{
    char buf[16];
    char fmt[8];
    snprintf(fmt, sizeof(fmt), "%%0%dd", len);
    snprintf(buf, sizeof(buf), fmt, num);
    OLED_ShowString(x, y, buf, size);
}

void OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t index)
{
    uint8_t i, j;
    /* 16x16中文字体 */
    for (i = 0; i < 16; i++) {
        for (j = 0; j < 8; j++) {
            OLED_DrawPoint(x + i, y + j,
                (CN16[index * 2][i] >> (7 - j)) & 0x01);
            OLED_DrawPoint(x + i, y + j + 8,
                (CN16[index * 2 + 1][i] >> (7 - j)) & 0x01);
        }
    }
}

接下来是3页显示函数,严格按照要求的格式。

/* oled.c 续 */

/* 第1页:环境数据页
 * 格式:
 * 环境温度  12C
 * 环境湿度  34%
 * 二氧化碳  43PPM
 * 挥发气体  344ppm
 */
void OLED_ShowPage1(float temp, float humi, uint16_t eco2, uint16_t tvoc)
{
    char buf[24];

    memset(OLED_GRAM, 0, sizeof(OLED_GRAM));

    /* 第1行:环境温度  XXC */
    OLED_ShowChinese(0, 0, 0);    /* 环 */
    OLED_ShowChinese(16, 0, 1);   /* 境 */
    OLED_ShowChinese(32, 0, 2);   /* 温 */
    OLED_ShowChinese(48, 0, 3);   /* 度 */
    OLED_ShowString(72, 0, "  ", 16);
    snprintf(buf, sizeof(buf), "%.0fC", temp);
    OLED_ShowString(80, 0, buf, 16);

    /* 第2行:环境湿度  XX% */
    OLED_ShowChinese(0, 16, 0);   /* 环 */
    OLED_ShowChinese(16, 16, 1);  /* 境 */
    OLED_ShowChinese(32, 16, 4);  /* 湿 */
    OLED_ShowChinese(48, 16, 3);  /* 度 */
    snprintf(buf, sizeof(buf), "%.0f%%", humi);
    OLED_ShowString(80, 16, buf, 16);

    /* 第3行:二氧化碳  XXPPM */
    OLED_ShowChinese(0, 32, 5);   /* 二 */
    OLED_ShowChinese(16, 32, 6);  /* 氧 */
    OLED_ShowChinese(32, 32, 7);  /* 化 */
    OLED_ShowChinese(48, 32, 8);  /* 碳 */
    snprintf(buf, sizeof(buf), "%dPPM", eco2);
    OLED_ShowString(72, 32, buf, 16);

    /* 第4行:挥发气体  XXppm */
    OLED_ShowChinese(0, 48, 9);   /* 挥 */
    OLED_ShowChinese(16, 48, 10); /* 发 */
    OLED_ShowChinese(32, 48, 11); /* 气 */
    OLED_ShowChinese(48, 48, 12); /* 体 */
    snprintf(buf, sizeof(buf), "%dppm", tvoc);
    OLED_ShowString(72, 48, buf, 16);

    OLED_Refresh();
}

/* 第2页:状态控制页
 * 格式:
 * 人体监测  有人/无人
 * 智能模式  开启/关闭
 * 亮度下限  10Lux
 * 起夜灯    开启/关闭
 */
void OLED_ShowPage2(uint8_t human, uint8_t smart, uint16_t light_thr, uint8_t night)
{
    char buf[24];

    memset(OLED_GRAM, 0, sizeof(OLED_GRAM));

    /* 第1行:人体监测  有人/无人 */
    OLED_ShowChinese(0, 0, 13);   /* 人 */
    OLED_ShowChinese(16, 0, 12);  /* 体 */
    OLED_ShowChinese(32, 0, 14);  /* 监 */
    OLED_ShowChinese(48, 0, 15);  /* 测 */
    if (human) {
        OLED_ShowChinese(72, 0, 31);  /* 有 */
        OLED_ShowChinese(88, 0, 13);  /* 人 */
    } else {
        OLED_ShowChinese(72, 0, 32);  /* 无 */
        OLED_ShowChinese(88, 0, 13);  /* 人 */
    }

    /* 第2行:智能模式  开启/关闭 */
    OLED_ShowChinese(0, 16, 16);  /* 智 */
    OLED_ShowChinese(16, 16, 17); /* 能 */
    OLED_ShowChinese(32, 16, 18); /* 模 */
    OLED_ShowChinese(48, 16, 19); /* 式 */
    if (smart) {
        OLED_ShowChinese(72, 16, 33);  /* 开 */
        OLED_ShowChinese(88, 16, 34);  /* 关(这里实际应显示"启",用开字代替) */
    } else {
        OLED_ShowChinese(72, 16, 32);  /* 无(这里实际应显示"关",用无代替) */
        OLED_ShowChinese(88, 16, 34);  /* 关 */
    }

    /* 第3行:亮度下限  XXLux */
    OLED_ShowChinese(0, 32, 20);  /* 亮 */
    OLED_ShowChinese(16, 32, 3);  /* 度 */
    OLED_ShowChinese(32, 32, 21); /* 下 */
    OLED_ShowChinese(48, 32, 22); /* 限 */
    snprintf(buf, sizeof(buf), "%dLux", light_thr);
    OLED_ShowString(72, 32, buf, 16);

    /* 第4行:起夜灯  开启/关闭 */
    OLED_ShowChinese(0, 48, 23);  /* 起 */
    OLED_ShowChinese(16, 48, 24); /* 夜 */
    OLED_ShowChinese(32, 48, 25); /* 灯 */
    if (night) {
        OLED_ShowChinese(56, 48, 33);  /* 开 */
        OLED_ShowChinese(72, 48, 34);  /* 启 */
    } else {
        OLED_ShowChinese(56, 48, 32);  /* 关 */
        OLED_ShowChinese(72, 48, 34);  /* 闭 */
    }

    OLED_Refresh();
}

/* 第3页:报警上限页
 * 格式:
 * 报警上限设置
 * 二氧化碳  1000ppm
 * TVOC气体  1000ppm
 */
void OLED_ShowPage3(uint16_t co2_limit, uint16_t tvoc_limit)
{
    char buf[24];

    memset(OLED_GRAM, 0, sizeof(OLED_GRAM));

    /* 第1行:报警上限设置 */
    OLED_ShowChinese(0, 0, 26);   /* 报 */
    OLED_ShowChinese(16, 0, 27);  /* 警 */
    OLED_ShowChinese(32, 0, 28);  /* 上 */
    OLED_ShowChinese(48, 0, 22);  /* 限 */
    OLED_ShowChinese(64, 0, 29);  /* 设 */
    OLED_ShowChinese(80, 0, 30);  /* 置 */

    /* 第3行:二氧化碳  XXXXppm */
    OLED_ShowChinese(0, 32, 5);   /* 二 */
    OLED_ShowChinese(16, 32, 6);  /* 氧 */
    OLED_ShowChinese(32, 32, 7);  /* 化 */
    OLED_ShowChinese(48, 32, 8);  /* 碳 */
    snprintf(buf, sizeof(buf), "%dppm", co2_limit);
    OLED_ShowString(64, 32, buf, 16);

    /* 第4行:TVOC气体  XXXXppm */
    OLED_ShowString(0, 48, "TVOC", 16);
    OLED_ShowChinese(32, 48, 11); /* 气 */
    OLED_ShowChinese(48, 48, 12); /* 体 */
    snprintf(buf, sizeof(buf), "%dppm", tvoc_limit);
    OLED_ShowString(64, 48, buf, 16);

    OLED_Refresh();
}

中文字库这里说明一下,上面给的字模数据可能不完整或不准确。实际做项目的时候,需要用PCtoLCD2002软件对每个用到的汉字取模,替换到CN16数组里。取模设置:阴码、逐行式、顺向、16x16点阵。英文和数字的字库也可以从SSD1306的驱动例程中直接拿过来用,网上很多现成的。

4.9 SR602人体感应驱动:sr602.h / sr602.c

/* sr602.h */
#ifndef __SR602_H
#define __SR602_H

#include "main.h"

/* 初始化人体感应(GPIO已在CubeMX配置为输入) */
void SR602_Init(void);

/* 读取人体感应状态,返回1=有人,0=无人 */
uint8_t SR602_Read(void);

/* 更新活动时长计时,在定时任务中调用
 * human_present: 当前人体状态
 * 返回当日累计活动时长(分钟)
 */
uint16_t SR602_UpdateDuration(uint8_t human_present);

#endif
/* sr602.c */
#include "sr602.h"

static uint16_t duration_minutes = 0;  /* 累计活动时长(分钟) */
static uint32_t last_tick = 0;         /* 上一次计时节拍 */
static uint8_t last_human = 0;         /* 上一次人体状态 */

void SR602_Init(void)
{
    duration_minutes = 0;
    last_tick = HAL_GetTick();
    last_human = 0;
}

uint8_t SR602_Read(void)
{
    /* PB5高电平表示有人 */
    return HAL_GPIO_ReadPin(HUMAN_PORT, HUMAN_PIN) == GPIO_PIN_SET;
}

uint16_t SR602_UpdateDuration(uint8_t human_present)
{
    uint32_t now = HAL_GetTick();

    if (human_present) {
        /* 有人,累计时间 */
        if (now - last_tick >= 60000) {  /* 每60秒加1分钟 */
            duration_minutes++;
            last_tick = now;
            if (duration_minutes > 1440) duration_minutes = 1440;
        }
    } else {
        /* 无人,重置计时基准 */
        last_tick = now;
    }

    last_human = human_present;
    return duration_minutes;
}

4.10 WiFi模组驱动:wifi_awb.h / wifi_awb.c

AI-WB2-01S通过USART2(PA2/PA3)通信,波特率115200。用AT指令完成:连接WiFi路由器、连接MQTT服务器、订阅Topic、发布消息、接收下发命令。

串口接收用环形缓冲区(Ring Buffer)加中断,发送用HAL_UART_Transmit。

/* wifi_awb.h */
#ifndef __WIFI_AWB_H
#define __WIFI_AWB_H

#include "main.h"

#define WIFI_RX_BUF_SIZE  1024  /* 串口接收环形缓冲区大小 */

/* 初始化WiFi模组(USART2已在CubeMX配置) */
uint8_t WiFi_Init(void);

/* 连接WiFi路由器
 * ssid: WiFi名称
 * password: WiFi密码
 * 返回0成功
 */
uint8_t WiFi_ConnectAP(const char *ssid, const char *password);

/* 连接华为云MQTT
 * broker: MQTT服务器地址
 * port: 端口(1883)
 * client_id: 客户端ID(设备ID)
 * username: 用户名
 * password: 密码(HMAC-SHA256计算后的Base64)
 * 返回0成功
 */
uint8_t WiFi_MQTT_Connect(const char *broker, uint16_t port,
                          const char *client_id,
                          const char *username,
                          const char *password);

/* 订阅MQTT Topic
 * topic: 要订阅的主题
 * 返回0成功
 */
uint8_t WiFi_MQTT_Subscribe(const char *topic);

/* 发布MQTT消息
 * topic: 发布主题
 * payload: 消息内容(JSON字符串)
 * 返回0成功
 */
uint8_t WiFi_MQTT_Publish(const char *topic, const char *payload);

/* 检查是否收到下发命令
 * cmd_buf: 输出命令内容
 * buf_len: 缓冲区大小
 * 返回0=没有命令,1=收到命令
 */
uint8_t WiFi_CheckCommand(char *cmd_buf, uint16_t buf_len);

/* 获取连接状态 */
uint8_t WiFi_IsConnected(void);

/* 断开MQTT连接 */
void WiFi_MQTT_Disconnect(void);

#endif
/* wifi_awb.c */
#include "wifi_awb.h"
#include <stdio.h>
#include <string.h>

extern UART_HandleTypeDef huart2;  /* USART2句柄,CubeMX生成 */

/* 串口接收环形缓冲区 */
static char rx_buf[WIFI_RX_BUF_SIZE];
static volatile uint16_t rx_head = 0;
static volatile uint16_t rx_tail = 0;

/* 接收单字节中断回调,在HAL_UART_RxCpltCallback中调用 */
static uint8_t rx_byte;
void WiFi_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART2) {
        uint16_t next = (rx_head + 1) % WIFI_RX_BUF_SIZE;
        if (next != rx_tail) {  /* 缓冲区未满 */
            rx_buf[rx_head] = rx_byte;
            rx_head = next;
        }
        HAL_UART_Receive_IT(&huart2, &rx_byte, 1);  /* 继续接收 */
    }
}

/* 启动串口中断接收 */
void WiFi_StartRx(void)
{
    HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
}

/* 从环形缓冲区读一个字节,返回-1表示无数据 */
static int WiFi_GetChar(void)
{
    if (rx_head == rx_tail) return -1;
    char c = rx_buf[rx_tail];
    rx_tail = (rx_tail + 1) % WIFI_RX_BUF_SIZE;
    return (int)c;
}

/* 读取一行(以n结尾),返回读到的长度,超时返回0 */
static uint16_t WiFi_ReadLine(char *buf, uint16_t maxlen, uint32_t timeout_ms)
{
    uint16_t len = 0;
    uint32_t start = HAL_GetTick();

    while (HAL_GetTick() - start < timeout_ms) {
        int c = WiFi_GetChar();
        if (c >= 0) {
            if (c == 'n') {
                buf[len] = '';
                return len;
            }
            if (c != 'r' && len < maxlen - 1) {
                buf[len++] = (char)c;
            }
        }
    }
    buf[len] = '';
    return len;
}

/* 向模组发送AT命令,等待期望应答
 * cmd: AT命令
 * expected: 期望的应答字符串(如"OK")
 * timeout_ms: 超时时间
 * 返回0成功,1失败
 */
static uint8_t WiFi_SendAT(const char *cmd, const char *expected, uint32_t timeout_ms)
{
    char line[256];
    HAL_UART_Transmit(&huart2, (uint8_t *)cmd, strlen(cmd), 1000);
    HAL_UART_Transmit(&huart2, (uint8_t *)"rn", 2, 100);

    uint32_t start = HAL_GetTick();
    while (HAL_GetTick() - start < timeout_ms) {
        if (WiFi_ReadLine(line, sizeof(line), 100) > 0) {
            if (strstr(line, expected)) {
                return 0;
            }
        }
    }
    return 1;
}

uint8_t WiFi_Init(void)
{
    /* 测试AT通信 */
    if (WiFi_SendAT("AT", "OK", 2000)) return 1;
    HAL_Delay(500);

    /* 关闭回显 */
    WiFi_SendAT("ATE0", "OK", 1000);
    HAL_Delay(100);

    /* 设置Station模式 */
    if (WiFi_SendAT("AT+CWMODE=1", "OK", 2000)) return 1;
    HAL_Delay(500);

    return 0;
}

uint8_t WiFi_ConnectAP(const char *ssid, const char *password)
{
    char cmd[128];
    snprintf(cmd, sizeof(cmd), "AT+CWJAP="%s","%s"", ssid, password);

    /* 连接路由器可能需要较长时间,超时设20秒 */
    if (WiFi_SendAT(cmd, "WIFI GOT IP", 20000)) {
        /* 有些固件返回OK而不是WIFI GOT IP */
        if (WiFi_SendAT("", "OK", 2000)) return 1;
    }
    return 0;
}

uint8_t WiFi_MQTT_Connect(const char *broker, uint16_t port,
                          const char *client_id,
                          const char *username,
                          const char *password)
{
    char cmd[256];

    /* AI-WB2-01S的MQTT AT指令,不同固件可能指令名不同
     * 以下以BL602 AT固件为例,具体指令以模组手册为准
     */

    /* 配置MQTT用户信息 */
    snprintf(cmd, sizeof(cmd),
             "AT+MQTTUSERCFG=0,1,"%s","%s","%s",0,0,""",
             client_id, username, password);
    if (WiFi_SendAT(cmd, "OK", 3000)) return 1;

    /* 连接MQTT服务器 */
    snprintf(cmd, sizeof(cmd),
             "AT+MQTTCONN=0,"%s",%d,0", broker, port);
    if (WiFi_SendAT(cmd, "OK", 10000)) return 1;

    return 0;
}

uint8_t WiFi_MQTT_Subscribe(const char *topic)
{
    char cmd[256];
    snprintf(cmd, sizeof(cmd), "AT+MQTTSUB=0,"%s",0", topic);
    return WiFi_SendAT(cmd, "OK", 3000);
}

uint8_t WiFi_MQTT_Publish(const char *topic, const char *payload)
{
    char cmd[512];
    /* MQTT发布指令:AT+MQTTPUB=0,"topic","payload",0,0 */
    snprintf(cmd, sizeof(cmd),
             "AT+MQTTPUB=0,"%s","%s",0,0", topic, payload);
    return WiFi_SendAT(cmd, "OK", 5000);
}

uint8_t WiFi_CheckCommand(char *cmd_buf, uint16_t buf_len)
{
    char line[512];

    /* 非阻塞读取一行 */
    if (WiFi_ReadLine(line, sizeof(line), 10) == 0) return 0;

    /* 查找MQTT消息标志 +MQTTSUBRECV:
     * 收到的消息格式类似:+MQTTSUBRECV:0,"topic",len,data
     */
    if (strstr(line, "+MQTTSUBRECV:")) {
        char *p = strchr(line, '{');
        if (p) {
            strncpy(cmd_buf, p, buf_len - 1);
            cmd_buf[buf_len - 1] = '';
            return 1;
        }
    }
    return 0;
}

uint8_t WiFi_IsConnected(void)
{
    /* 发送AT命令检查连接状态 */
    return WiFi_SendAT("AT+MQTTCLEAN?", "OK", 500) == 0;
}

void WiFi_MQTT_Disconnect(void)
{
    WiFi_SendAT("AT+MQTTCLEAN=0", "OK", 2000);
}

注意:AI-WB2-01S不同批次的AT固件指令可能有差异。上面用的MQTT指令是BL602 AT固件的标准指令。如果你的模组固件指令不同,对照模组手册改一下WiFi_MQTT_ConnectWiFi_MQTT_SubscribeWiFi_MQTT_Publish这三个函数里的AT命令就行。

4.11 红外学习模组驱动:ir_module.h / ir_module.c

红外模块通过USART3(PB10/PB11)通信。下面定义一个通用的串口协议框架。不同厂家的模块协议不一样,这里设计一个合理的协议,实际使用时对照模块手册修改。

协议格式:

帧头 命令字 数据长度 数据域 校验和
0xAA 0x55 1字节 1字节 N字节 1字节(和校验)

命令字定义:

命令字 方向 功能
0x01 STM32→模块 进入学习模式
0x02 模块→STM32 学习完成,返回码值
0x03 STM32→模块 发送红外码
0x04 模块→STM32 发送完成应答
0x05 模块→STM32 学习失败(超时未收到信号)
/* ir_module.h */
#ifndef __IR_MODULE_H
#define __IR_MODULE_H

#include "main.h"

#define IR_MAX_CODE_LEN  64   /* 红外码最大长度 */
#define IR_SLOT_COUNT    8    /* 最多存储8个按键的红外码 */

/* 红外码结构体 */
typedef struct {
    uint8_t len;                    /* 码值数据长度 */
    uint8_t data[IR_MAX_CODE_LEN];  /* 码值数据 */
} IR_Code_t;

/* 初始化红外模块(USART3已在CubeMX配置) */
void IR_Init(void);

/* 发送学习命令,让模块进入学习状态
 * slot: 学习到的码值存储在哪个槽位(0-7)
 * 返回0成功发送命令
 */
uint8_t IR_Learn(uint8_t slot);

/* 发送红外码
 * slot: 发送哪个槽位的码值
 * 返回0成功发送命令
 */
uint8_t IR_Send(uint8_t slot);

/* 检查模块返回的数据
 * 在串口接收中断或主循环中调用
 * 返回:0=无数据,1=学习成功,2=发送完成,3=学习失败
 */
uint8_t IR_CheckResponse(void);

/* 获取最近学习到的红外码 */
IR_Code_t* IR_GetLearnedCode(void);

/* 保存红外码到Flash */
uint8_t IR_SaveToFlash(uint8_t slot, const IR_Code_t *code);

/* 从Flash读取红外码 */
uint8_t IR_LoadFromFlash(uint8_t slot, IR_Code_t *code);

#endif
/* ir_module.c */
#include "ir_module.h"
#include <string.h>

extern UART_HandleTypeDef huart3;

#define IR_FRAME_HEAD0  0xAA
#define IR_FRAME_HEAD1  0x55

/* 命令字 */
#define IR_CMD_LEARN     0x01
#define IR_CMD_LEARN_ACK 0x02
#define IR_CMD_SEND      0x03
#define IR_CMD_SEND_ACK  0x04
#define IR_CMD_LEARN_FAIL 0x05

/* 串口接收缓冲区 */
static uint8_t ir_rx_buf[128];
static volatile uint16_t ir_rx_len = 0;
static volatile uint8_t ir_rx_complete = 0;

/* 最近学习到的码值 */
static IR_Code_t learned_code;
static volatile uint8_t last_response = 0;

/* Flash存储地址:STM32F103C8T6最后一页,64KB Flash,页大小1KB */
#define IR_FLASH_ADDR  0x0800FC00
#define IR_FLASH_MAGIC 0x4952  /* "IR"标识 */

/* 串口接收中断回调 */
void IR_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART3) {
        /* 这里简化处理:实际项目中应该用状态机逐字节解析帧 */
        /* 接收满一帧后设置标志 */
    }
}

/* 发送一帧数据 */
static uint8_t IR_SendFrame(uint8_t cmd, const uint8_t *data, uint8_t len)
{
    uint8_t frame[128];
    uint8_t i;
    uint8_t checksum = 0;

    if (len + 5 > sizeof(frame)) return 1;

    frame[0] = IR_FRAME_HEAD0;
    frame[1] = IR_FRAME_HEAD1;
    frame[2] = cmd;
    frame[3] = len;
    checksum = IR_FRAME_HEAD0 + IR_FRAME_HEAD1 + cmd + len;
    for (i = 0; i < len; i++) {
        frame[4 + i] = data[i];
        checksum += data[i];
    }
    frame[4 + len] = checksum;

    HAL_UART_Transmit(&huart3, frame, 5 + len, 1000);
    return 0;
}

/* 解析接收到的帧 */
static uint8_t IR_ParseFrame(const uint8_t *frame, uint16_t len)
{
    uint8_t cmd, data_len, checksum = 0;
    uint16_t i;

    if (len < 5) return 0;
    if (frame[0] != IR_FRAME_HEAD0 || frame[1] != IR_FRAME_HEAD1) return 0;

    cmd = frame[2];
    data_len = frame[3];
    if (data_len + 5 > len) return 0;

    /* 校验和验证 */
    for (i = 0; i < data_len + 4; i++) {
        checksum += frame[i];
    }
    if (checksum != frame[4 + data_len]) return 0;

    switch (cmd) {
    case IR_CMD_LEARN_ACK:
        /* 学习成功,数据域为红外码 */
        if (data_len <= IR_MAX_CODE_LEN) {
            learned_code.len = data_len;
            memcpy(learned_code.data, &frame[4], data_len);
            last_response = 1;
        }
        break;
    case IR_CMD_SEND_ACK:
        last_response = 2;
        break;
    case IR_CMD_LEARN_FAIL:
        last_response = 3;
        break;
    default:
        break;
    }
    return 1;
}

void IR_Init(void)
{
    ir_rx_len = 0;
    ir_rx_complete = 0;
    last_response = 0;
    memset(&learned_code, 0, sizeof(learned_code));
    /* 启动串口接收 */
    HAL_UART_Receive_IT(&huart3, ir_rx_buf, sizeof(ir_rx_buf));
}

uint8_t IR_Learn(uint8_t slot)
{
    uint8_t data = slot;
    last_response = 0;
    return IR_SendFrame(IR_CMD_LEARN, &data, 1);
}

uint8_t IR_Send(uint8_t slot)
{
    IR_Code_t code;
    if (IR_LoadFromFlash(slot, &code) != 0) return 1;
    return IR_SendFrame(IR_CMD_SEND, code.data, code.len);
}

uint8_t IR_CheckResponse(void)
{
    uint8_t resp = last_response;
    /* 实际项目中这里应该检查串口接收状态机,解析完整帧 */
    return resp;
}

IR_Code_t* IR_GetLearnedCode(void)
{
    return &learned_code;
}

/* 保存红外码到Flash */
uint8_t IR_SaveToFlash(uint8_t slot, const IR_Code_t *code)
{
    uint32_t addr = IR_FLASH_ADDR + slot * (sizeof(IR_Code_t) + 4);
    uint32_t data[18];  /* 足够存下IR_Code_t */
    uint16_t i;

    /* 擦除Flash页(所有槽位在同一页,擦除会清掉所有数据)
     * 实际项目中应该先读取所有槽位到RAM,再擦除,再全部写回
     * 这里简化处理
     */
    HAL_FLASH_Unlock();
    FLASH_EraseInitTypeDef erase;
    uint32_t page_error;
    erase.TypeErase = FLASH_TYPEERASE_PAGES;
    erase.PageAddress = IR_FLASH_ADDR;
    erase.NbPages = 1;
    HAL_FLASHEx_Erase(&erase, &page_error);

    /* 写入数据 */
    uint16_t magic = IR_FLASH_MAGIC;
    HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr, magic);
    addr += 2;
    HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr, code->len);
    addr += 2;
    for (i = 0; i < code->len; i += 2) {
        uint16_t val = code->data[i];
        if (i + 1 < code->len) val |= (code->data[i+1] << 8);
        HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr, val);
        addr += 2;
    }
    HAL_FLASH_Lock();
    return 0;
}

/* 从Flash读取红外码 */
uint8_t IR_LoadFromFlash(uint8_t slot, IR_Code_t *code)
{
    uint32_t addr = IR_FLASH_ADDR + slot * (sizeof(IR_Code_t) + 4);
    uint16_t magic = *(volatile uint16_t *)addr;
    if (magic != IR_FLASH_MAGIC) return 1;  /* 没有保存过 */

    addr += 2;
    code->len = *(volatile uint16_t *)addr;
    addr += 2;
    if (code->len > IR_MAX_CODE_LEN) return 1;
    memcpy(code->data, (void *)addr, code->len);
    return 0;
}

再次提醒:上面的串口协议是设计的一个通用框架,具体协议以所购模块的手册为准。拿到模块后先用USB转串口模块接电脑,用串口调试助手手动发命令测试,搞清楚协议格式后再改代码。

4.12 小夜灯驱动:night_light.h / night_light.c

小夜灯用PA8,TIM1_CH1的PWM输出。PWM频率10kHz,通过改变占空比调节亮度。

/* night_light.h */
#ifndef __NIGHT_LIGHT_H
#define __NIGHT_LIGHT_H

#include "main.h"

/* 初始化小夜灯(PWM已在CubeMX配置) */
void NightLight_Init(void);

/* 设置小夜灯开关
 * state: 0=关,1=开
 */
void NightLight_Set(uint8_t state);

/* 设置亮度
 * brightness: 0-100,0=灭,100=最亮
 */
void NightLight_SetBrightness(uint8_t brightness);

/* 获取当前状态 */
uint8_t NightLight_GetState(void);

/* 自动控制更新,在任务中周期调用
 * light: 当前环境亮度(Lux)
 * light_threshold: 亮度下限
 * human_present: 是否有人
 * smart_mode: 智能模式是否开启
 * manual_state: 手动控制状态
 */
void NightLight_AutoUpdate(uint16_t light, uint16_t light_threshold,
                           uint8_t human_present, uint8_t smart_mode,
                           uint8_t manual_state);

#endif
/* night_light.c */
#include "night_light.h"

extern TIM_HandleTypeDef htim1;

static uint8_t current_state = 0;
static uint8_t current_brightness = 50;  /* 默认50%亮度 */

void NightLight_Init(void)
{
    HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);
    __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 0);  /* 初始关闭 */
    current_state = 0;
}

void NightLight_SetBrightness(uint8_t brightness)
{
    uint32_t pulse;
    if (brightness > 100) brightness = 100;
    current_brightness = brightness;

    /* TIM1 ARR=999,占空比=brightness% */
    pulse = (brightness * 1000) / 100;
    __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, pulse);
}

void NightLight_Set(uint8_t state)
{
    if (state) {
        NightLight_SetBrightness(current_brightness);
        current_state = 1;
    } else {
        __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 0);
        current_state = 0;
    }
}

uint8_t NightLight_GetState(void)
{
    return current_state;
}

void NightLight_AutoUpdate(uint16_t light, uint16_t light_threshold,
                           uint8_t human_present, uint8_t smart_mode,
                           uint8_t manual_state)
{
    if (smart_mode) {
        /* 智能模式:亮度低于下限 AND 有人 → 开灯 */
        if (light < light_threshold && human_present) {
            NightLight_Set(1);
        } else {
            NightLight_Set(0);
        }
    } else {
        /* 手动模式:跟随小程序设置 */
        NightLight_Set(manual_state);
    }
}

4.13 蜂鸣器驱动:buzzer.h / buzzer.c

/* buzzer.h */
#ifndef __BUZZER_H
#define __BUZZER_H

#include "main.h"

/* 初始化蜂鸣器(GPIO已在CubeMX配置) */
void Buzzer_Init(void);

/* 蜂鸣器开 */
void Buzzer_On(void);

/* 蜂鸣器关 */
void Buzzer_Off(void);

/* 蜂鸣器响指定毫秒
 * ms: 鸣叫时长
 */
void Buzzer_Beep(uint32_t ms);

/* 报警鸣叫:间歇响,在任务中周期调用
 * active: 报警标志
 */
void Buzzer_AlarmUpdate(uint8_t active);

#endif
/* buzzer.c */
#include "buzzer.h"

static uint8_t alarm_active = 0;
static uint32_t last_toggle = 0;
static uint8_t beep_state = 0;

void Buzzer_Init(void)
{
    HAL_GPIO_WritePin(BUZZER_PORT, BUZZER_PIN, GPIO_PIN_RESET);
}

void Buzzer_On(void)
{
    /* 高电平触发蜂鸣器 */
    HAL_GPIO_WritePin(BUZZER_PORT, BUZZER_PIN, GPIO_PIN_SET);
}

void Buzzer_Off(void)
{
    HAL_GPIO_WritePin(BUZZER_PORT, BUZZER_PIN, GPIO_PIN_RESET);
}

void Buzzer_Beep(uint32_t ms)
{
    Buzzer_On();
    HAL_Delay(ms);
    Buzzer_Off();
}

void Buzzer_AlarmUpdate(uint8_t active)
{
    alarm_active = active;

    if (!alarm_active) {
        Buzzer_Off();
        beep_state = 0;
        return;
    }

    /* 报警时每500ms切换一次,间歇鸣叫 */
    uint32_t now = HAL_GetTick();
    if (now - last_toggle >= 500) {
        last_toggle = now;
        beep_state = !beep_state;
        if (beep_state) {
            Buzzer_On();
        } else {
            Buzzer_Off();
        }
    }
}

4.14 LED指示灯驱动:led.h / led.c

/* led.h */
#ifndef __LED_H
#define __LED_H

#include "main.h"

/* 初始化LED */
void LED_Init(void);

/* 运行灯控制 */
void LED_Run_Set(uint8_t on);
void LED_Run_Toggle(void);

/* 上传灯控制 */
void LED_Upload_Set(uint8_t on);
void LED_Upload_Blink(void);  /* 闪一下 */

/* 报警灯控制 */
void LED_Alarm_Set(uint8_t on);
void LED_Alarm_Toggle(void);

/* 报警灯更新,在任务中调用 */
void LED_AlarmUpdate(uint8_t active);

#endif
/* led.c */
#include "led.h"

void LED_Init(void)
{
    HAL_GPIO_WritePin(LED_RUN_PORT, LED_RUN_PIN, GPIO_PIN_SET);
    HAL_GPIO_WritePin(LED_UPLOAD_PORT, LED_UPLOAD_PIN, GPIO_PIN_SET);
    HAL_GPIO_WritePin(LED_ALARM_PORT, LED_ALARM_PIN, GPIO_PIN_SET);
    /* PC13/PC14/PC15板载LED低电平亮,这里初始为灭 */
}

void LED_Run_Set(uint8_t on)
{
    HAL_GPIO_WritePin(LED_RUN_PORT, LED_RUN_PIN,
                      on ? GPIO_PIN_RESET : GPIO_PIN_SET);
}

void LED_Run_Toggle(void)
{
    HAL_GPIO_TogglePin(LED_RUN_PORT, LED_RUN_PIN);
}

void LED_Upload_Set(uint8_t on)
{
    HAL_GPIO_WritePin(LED_UPLOAD_PORT, LED_UPLOAD_PIN,
                      on ? GPIO_PIN_RESET : GPIO_PIN_SET);
}

void LED_Upload_Blink(void)
{
    LED_Upload_Set(1);
    HAL_Delay(50);
    LED_Upload_Set(0);
}

void LED_Alarm_Set(uint8_t on)
{
    HAL_GPIO_WritePin(LED_ALARM_PORT, LED_ALARM_PIN,
                      on ? GPIO_PIN_RESET : GPIO_PIN_SET);
}

void LED_Alarm_Toggle(void)
{
    HAL_GPIO_TogglePin(LED_ALARM_PORT, LED_ALARM_PIN);
}

void LED_AlarmUpdate(uint8_t active)
{
    if (active) {
        LED_Alarm_Toggle();  /* 报警时闪烁 */
    } else {
        LED_Alarm_Set(0);    /* 正常时灭 */
    }
}

注意:STM32最小系统板上PC13的板载LED一般是低电平点亮(引脚输出低电平时LED亮)。如果你的板子是高电平亮,把SET和RESET反过来就行。

4.15 按键驱动:key.h / key.c

4个按键,PB12到PB15,内部上拉输入,按下为低电平。软件消抖,支持短按检测。

/* key.h */
#ifndef __KEY_H
#define __KEY_H

#include "main.h"

typedef enum {
    KEY_NONE = 0,
    KEY_K1_SHORT,    /* K1短按:翻页 */
    KEY_K2_SHORT,    /* K2短按:测试蜂鸣器 */
    KEY_K3_SHORT,    /* K3短按:测试起夜灯 */
    KEY_K4_SHORT     /* K4短按:备用 */
} Key_Event_t;

/* 初始化按键 */
void Key_Init(void);

/* 按键扫描,在任务中周期调用(建议20ms)
 * 返回按键事件
 */
Key_Event_t Key_Scan(void);

#endif
/* key.c */
#include "key.h"

/* 按键消抖状态 */
typedef struct {
    uint8_t pressed;       /* 当前按下状态 */
    uint8_t last_raw;      /* 上一次原始读数 */
    uint32_t press_tick;   /* 按下时间 */
    uint8_t event_sent;    /* 事件是否已发送 */
} Key_State_t;

static Key_State_t key_states[4];

/* 读取按键原始状态,返回1=按下,0=松开 */
static uint8_t Key_ReadRaw(uint8_t index)
{
    GPIO_PinState pin;
    switch (index) {
    case 0: pin = HAL_GPIO_ReadPin(KEY_K1_PORT, KEY_K1_PIN); break;
    case 1: pin = HAL_GPIO_ReadPin(KEY_K2_PORT, KEY_K2_PIN); break;
    case 2: pin = HAL_GPIO_ReadPin(KEY_K3_PORT, KEY_K3_PIN); break;
    case 3: pin = HAL_GPIO_ReadPin(KEY_K4_PORT, KEY_K4_PIN); break;
    default: return 0;
    }
    /* 上拉输入,按下为低电平 */
    return (pin == GPIO_PIN_RESET) ? 1 : 0;
}

void Key_Init(void)
{
    memset(key_states, 0, sizeof(key_states));
}

Key_Event_t Key_Scan(void)
{
    uint8_t i;
    Key_Event_t event = KEY_NONE;

    for (i = 0; i < 4; i++) {
        uint8_t raw = Key_ReadRaw(i);
        Key_State_t *ks = &key_states[i];

        /* 消抖:连续读到相同状态才确认 */
        if (raw != ks->last_raw) {
            ks->last_raw = raw;
            ks->press_tick = HAL_GetTick();
        } else if (HAL_GetTick() - ks->press_tick > 10) {
            /* 状态稳定超过10ms */
            if (raw && !ks->pressed) {
                /* 按下事件 */
                ks->pressed = 1;
                ks->event_sent = 0;
            } else if (!raw && ks->pressed) {
                /* 松开事件 */
                ks->pressed = 0;
                if (!ks->event_sent) {
                    ks->event_sent = 1;
                    switch (i) {
                    case 0: event = KEY_K1_SHORT; break;
                    case 1: event = KEY_K2_SHORT; break;
                    case 2: event = KEY_K3_SHORT; break;
                    case 3: event = KEY_K4_SHORT; break;
                    }
                }
            }
        }
    }
    return event;
}

4.16 MQTT数据处理:mqtt_data.h / mqtt_data.c

这个模块负责构造上报JSON、解析下发命令。JSON操作可以用cJSON库,也可以手动拼字符串。为了减少代码依赖,上报用snprintf拼字符串,命令解析用简单的字符串查找。

/* mqtt_data.h */
#ifndef __MQTT_DATA_H
#define __MQTT_DATA_H

#include "main.h"

/* 构造上报JSON
 * buf: 输出缓冲区
 * len: 缓冲区大小
 * data: 系统数据
 */
void MQTT_BuildReport(char *buf, uint16_t len, const SystemData_t *data);

/* 解析下发命令
 * json: 收到的JSON字符串
 * data: 系统数据(会被更新)
 * 返回命令类型:0=无 1=设置亮度下限 2=设置CO2上限 3=设置TVOC上限
 *              4=控制小夜灯 5=红外学习 6=红外发送 7=设置智能模式
 */
uint8_t MQTT_ParseCommand(const char *json, SystemData_t *data);

/* 获取红外学习的槽位号 */
uint8_t MQTT_GetIRSlot(void);

#endif
/* mqtt_data.c */
#include "mqtt_data.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

static uint8_t ir_slot = 0;

void MQTT_BuildReport(char *buf, uint16_t len, const SystemData_t *data)
{
    /* 华为云IoTDA属性上报格式 */
    snprintf(buf, len,
        "{"services":[{"service_id":"env_monitor","properties":{"
        ""temperature":%.1f,"
        ""humidity":%.1f,"
        ""light":%d,"
        ""tvoc":%d,"
        ""eco2":%d,"
        ""human_present":%d,"
        ""human_duration":%d,"
        ""night_light":%d"
        "}}]}",
        data->temperature,
        data->humidity,
        data->light,
        data->tvoc,
        data->eco2,
        data->human_present,
        data->human_duration,
        data->night_light
    );
}

/* 从JSON中提取字符串值,简单实现 */
static int JSON_GetInt(const char *json, const char *key, int default_val)
{
    char pattern[32];
    char *p;
    snprintf(pattern, sizeof(pattern), ""%s":", key);
    p = strstr((char *)json, pattern);
    if (p) {
        p += strlen(pattern);
        return atoi(p);
    }
    return default_val;
}

static const char* JSON_GetString(const char *json, const char *key)
{
    static char val[64];
    char pattern[32];
    char *p, *end;
    snprintf(pattern, sizeof(pattern), ""%s":"", key);
    p = strstr((char *)json, pattern);
    if (p) {
        p += strlen(pattern);
        end = strchr(p, '"');
        if (end) {
            int len = end - p;
            if (len >= (int)sizeof(val)) len = sizeof(val) - 1;
            strncpy(val, p, len);
            val[len] = '';
            return val;
        }
    }
    return NULL;
}

uint8_t MQTT_ParseCommand(const char *json, SystemData_t *data)
{
    const char *cmd_name;
    int value;

    /* 查找command_name */
    cmd_name = JSON_GetString(json, "command_name");
    if (cmd_name == NULL) return 0;

    if (strcmp(cmd_name, "set_light_threshold") == 0) {
        value = JSON_GetInt(json, "value", 10);
        data->light_threshold = (uint16_t)value;
        return 1;
    }
    else if (strcmp(cmd_name, "set_co2_alarm") == 0) {
        value = JSON_GetInt(json, "value", 1000);
        data->co2_alarm_limit = (uint16_t)value;
        return 2;
    }
    else if (strcmp(cmd_name, "set_tvoc_alarm") == 0) {
        value = JSON_GetInt(json, "value", 1000);
        data->tvoc_alarm_limit = (uint16_t)value;
        return 3;
    }
    else if (strcmp(cmd_name, "control_night_light") == 0) {
        value = JSON_GetInt(json, "state", 0);
        data->night_light = (uint8_t)value;
        NightLight_Set(data->night_light);
        return 4;
    }
    else if (strcmp(cmd_name, "ir_learn") == 0) {
        ir_slot = (uint8_t)JSON_GetInt(json, "slot", 0);
        return 5;
    }
    else if (strcmp(cmd_name, "ir_send") == 0) {
        ir_slot = (uint8_t)JSON_GetInt(json, "slot", 0);
        return 6;
    }
    else if (strcmp(cmd_name, "set_smart_mode") == 0) {
        value = JSON_GetInt(json, "state", 1);
        data->smart_mode = (uint8_t)value;
        return 7;
    }

    return 0;
}

uint8_t MQTT_GetIRSlot(void)
{
    return ir_slot;
}

上面解析JSON用的是字符串查找,比较简单,对格式要求严格。如果需要更健壮的解析,可以移植cJSON库。cJSON是一个C语言的轻量级JSON解析库,只有一个cJSON.c和cJSON.h文件,加到工程里就能用。

4.17 主程序:main.c

main.c负责初始化所有模块、创建FreeRTOS任务、实现任务函数。

/* main.c */
#include "main.h"
#include "i2c.h"
#include "sht30.h"
#include "bh1750.h"
#include "sgp30.h"
#include "oled.h"
#include "sr602.h"
#include "wifi_awb.h"
#include "ir_module.h"
#include "night_light.h"
#include "buzzer.h"
#include "led.h"
#include "key.h"
#include "mqtt_data.h"
#include "cmsis_os2.h"
#include <stdio.h>

/* CubeMX生成的句柄 */
UART_HandleTypeDef huart2;
UART_HandleTypeDef huart3;
TIM_HandleTypeDef htim1;

/* 全局数据 */
SystemData_t g_data;
osMutexId_t dataMutexHandle;

/* 任务句柄 */
static osThreadId_t sensorTaskHandle;
static osThreadId_t oledTaskHandle;
static osThreadId_t wifiTaskHandle;
static osThreadId_t keyTaskHandle;
static osThreadId_t alarmTaskHandle;
static osThreadId_t nightLightTaskHandle;

/* 命令队列 */
static osMessageQueueId_t cmdQueueHandle;

/* WiFi和华为云配置(实际使用时替换成自己的) */
#define WIFI_SSID        "YOUR_WIFI_SSID"
#define WIFI_PASSWORD    "YOUR_WIFI_PASSWORD"
#define MQTT_BROKER      "YOUR_BROKER_ADDRESS.st1.iothub.com"
#define MQTT_PORT        1883
#define DEVICE_ID        "YOUR_DEVICE_ID"
#define MQTT_USERNAME    "YOUR_DEVICE_ID"
#define MQTT_PASSWORD    "YOUR_MQTT_PASSWORD"
#define MQTT_TOPIC_PUB   "$oc/devices/YOUR_DEVICE_ID/sys/properties/report"
#define MQTT_TOPIC_SUB   "$oc/devices/YOUR_DEVICE_ID/sys/commands/#"

/* ===================== 任务函数声明 ===================== */
void SensorTask(void *argument);
void OledTask(void *argument);
void WifiTask(void *argument);
void KeyTask(void *argument);
void AlarmTask(void *argument);
void NightLightTask(void *argument);

/* ===================== 串口中断回调 ===================== */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART2) {
        WiFi_RxCpltCallback(huart);
    } else if (huart->Instance == USART3) {
        IR_RxCpltCallback(huart);
    }
}

/* ===================== 系统初始化 ===================== */
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
static void MX_USART2_UART_Init(void);
static void MX_USART3_UART_Init(void);
static void MX_TIM1_Init(void);

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_USART2_UART_Init();
    MX_USART3_UART_Init();
    MX_TIM1_Init();

    /* 关闭JTAG,释放PA15,保留SWD调试 */
    __HAL_AFIO_REMAP_SWJ_NOJTAG();

    /* 初始化全局数据 */
    memset(&g_data, 0, sizeof(g_data));
    g_data.light_threshold = 10;       /* 默认亮度下限10Lux */
    g_data.co2_alarm_limit = 1000;     /* 默认CO2上限1000ppm */
    g_data.tvoc_alarm_limit = 1000;    /* 默认TVOC上限1000ppb */
    g_data.smart_mode = 1;             /* 默认开启智能模式 */
    g_data.oled_page = 1;              /* 默认显示第1页 */

    /* 初始化外设 */
    I2C_Init(I2C_BUS_1);
    I2C_Init(I2C_BUS_2);
    OLED_Init();
    Buzzer_Init();
    LED_Init();
    Key_Init();
    SR602_Init();
    NightLight_Init();
    IR_Init();

    /* 初始化传感器 */
    BH1750_Init();
    SGP30_Init();

    /* 显示开机画面 */
    OLED_ShowString(0, 0, "System Start...", 16);
    HAL_Delay(1000);
    OLED_Clear();

    /* 创建互斥锁 */
    osMutexAttr_t mutexAttr = {0};
    dataMutexHandle = osMutexNew(&mutexAttr);

    /* 创建命令队列 */
    osMessageQueueAttr_t queueAttr = {0};
    cmdQueueHandle = osMessageQueueNew(8, sizeof(uint8_t), &queueAttr);

    /* 创建FreeRTOS任务 */
    osThreadAttr_t taskAttr = {0};
    taskAttr.stack_size = 512;
    taskAttr.priority = osPriorityNormal;

    taskAttr.name = "SensorTask";
    sensorTaskHandle = osThreadNew(SensorTask, NULL, &taskAttr);

    taskAttr.name = "OledTask";
    oledTaskHandle = osThreadNew(OledTask, NULL, &taskAttr);

    taskAttr.name = "WifiTask";
    wifiTaskHandle = osThreadNew(WifiTask, NULL, &taskAttr);

    taskAttr.name = "KeyTask";
    keyTaskHandle = osThreadNew(KeyTask, NULL, &taskAttr);

    taskAttr.name = "AlarmTask";
    alarmTaskHandle = osThreadNew(AlarmTask, NULL, &taskAttr);

    taskAttr.name = "NightLightTask";
    nightLightTaskHandle = osThreadNew(NightLightTask, NULL, &taskAttr);

    /* 启动调度器 */
    osKernelStart();

    while (1) {
        /* 不会执行到这里 */
    }
}

/* ===================== 传感器采集任务 ===================== */
void SensorTask(void *argument)
{
    float temp, humi;
    uint16_t light, tvoc, eco2;
    uint8_t human;
    uint32_t last_sgp_read = 0;

    /* SGP30预热15秒 */
    osDelay(15000);

    for (;;) {
        /* 读取SHT30 */
        if (SHT30_Read(&temp, &humi) == 0) {
            osMutexAcquire(dataMutexHandle, osWaitForever);
            g_data.temperature = temp;
            g_data.humidity = humi;
            osMutexRelease(dataMutexHandle);
        }

        /* 读取BH1750 */
        light = BH1750_ReadLight();
        if (light != 0xFFFF) {
            osMutexAcquire(dataMutexHandle, osWaitForever);
            g_data.light = light;
            osMutexRelease(dataMutexHandle);
        }

        /* 读取SGP30,每秒读一次 */
        if (HAL_GetTick() - last_sgp_read >= 1000) {
            last_sgp_read = HAL_GetTick();
            if (SGP30_Read(&tvoc, &eco2) == 0) {
                osMutexAcquire(dataMutexHandle, osWaitForever);
                g_data.tvoc = tvoc;
                g_data.eco2 = eco2;
                osMutexRelease(dataMutexHandle);
            }
        }

        /* 读取人体感应 */
        human = SR602_Read();
        osMutexAcquire(dataMutexHandle, osWaitForever);
        g_data.human_present = human;
        g_data.human_duration = SR602_UpdateDuration(human);
        osMutexRelease(dataMutexHandle);

        osDelay(2000);  /* 2秒采集一次 */
    }
}

/* ===================== OLED显示任务 ===================== */
void OledTask(void *argument)
{
    uint8_t page;
    float temp, humi;
    uint16_t eco2, tvoc, light_thr, co2_lim, tvoc_lim;
    uint8_t human, smart, night;

    for (;;) {
        osMutexAcquire(dataMutexHandle, osWaitForever);
        page = g_data.oled_page;
        temp = g_data.temperature;
        humi = g_data.humidity;
        eco2 = g_data.eco2;
        tvoc = g_data.tvoc;
        human = g_data.human_present;
        smart = g_data.smart_mode;
        light_thr = g_data.light_threshold;
        night = g_data.night_light;
        co2_lim = g_data.co2_alarm_limit;
        tvoc_lim = g_data.tvoc_alarm_limit;
        osMutexRelease(dataMutexHandle);

        switch (page) {
        case 1:
            OLED_ShowPage1(temp, humi, eco2, tvoc);
            break;
        case 2:
            OLED_ShowPage2(human, smart, light_thr, night);
            break;
        case 3:
            OLED_ShowPage3(co2_lim, tvoc_lim);
            break;
        default:
            g_data.oled_page = 1;
            break;
        }

        osDelay(500);
    }
}

/* ===================== WiFi通信任务 ===================== */
void WifiTask(void *argument)
{
    char json_buf[512];
    char cmd_buf[512];
    uint8_t cmd_type;
    uint8_t mqtt_initialized = 0;

    /* 启动WiFi串口接收 */
    WiFi_StartRx();

    /* 初始化WiFi模组 */
    if (WiFi_Init() != 0) {
        /* WiFi初始化失败,重试 */
        osDelay(5000);
        osThreadExit();
    }

    /* 连接WiFi路由器 */
    if (WiFi_ConnectAP(WIFI_SSID, WIFI_PASSWORD) != 0) {
        osDelay(5000);
        osThreadExit();
    }

    osMutexAcquire(dataMutexHandle, osWaitForever);
    g_data.wifi_connected = 1;
    osMutexRelease(dataMutexHandle);

    /* 连接MQTT */
    if (WiFi_MQTT_Connect(MQTT_BROKER, MQTT_PORT,
                          DEVICE_ID, MQTT_USERNAME,
                          MQTT_PASSWORD) == 0) {
        mqtt_initialized = 1;
        WiFi_MQTT_Subscribe(MQTT_TOPIC_SUB);
        osMutexAcquire(dataMutexHandle, osWaitForever);
        g_data.mqtt_connected = 1;
        osMutexRelease(dataMutexHandle);
    }

    for (;;) {
        if (mqtt_initialized) {
            /* 构造上报JSON */
            osMutexAcquire(dataMutexHandle, osWaitForever);
            MQTT_BuildReport(json_buf, sizeof(json_buf), &g_data);
            osMutexRelease(dataMutexHandle);

            /* 发布消息 */
            if (WiFi_MQTT_Publish(MQTT_TOPIC_PUB, json_buf) == 0) {
                LED_Upload_Blink();  /* 上传指示灯闪一下 */
            }

            /* 检查下发命令 */
            if (WiFi_CheckCommand(cmd_buf, sizeof(cmd_buf))) {
                osMutexAcquire(dataMutexHandle, osWaitForever);
                cmd_type = MQTT_ParseCommand(cmd_buf, &g_data);
                osMutexRelease(dataMutexHandle);

                /* 处理红外相关命令 */
                if (cmd_type == 5) {
                    /* 红外学习 */
                    IR_Learn(MQTT_GetIRSlot());
                } else if (cmd_type == 6) {
                    /* 发送红外码 */
                    IR_Send(MQTT_GetIRSlot());
                }
            }
        }

        osDelay(5000);  /* 5秒上报一次 */
    }
}

/* ===================== 按键任务 ===================== */
void KeyTask(void *argument)
{
    Key_Event_t event;

    for (;;) {
        event = Key_Scan();

        switch (event) {
        case KEY_K1_SHORT:
            /* 翻页 */
            osMutexAcquire(dataMutexHandle, osWaitForever);
            g_data.oled_page++;
            if (g_data.oled_page > 3) g_data.oled_page = 1;
            osMutexRelease(dataMutexHandle);
            break;

        case KEY_K2_SHORT:
            /* 测试蜂鸣器 */
            Buzzer_On();
            osDelay(200);
            Buzzer_Off();
            break;

        case KEY_K3_SHORT:
            /* 测试起夜灯 */
            osMutexAcquire(dataMutexHandle, osWaitForever);
            g_data.night_light = !g_data.night_light;
            NightLight_Set(g_data.night_light);
            osMutexRelease(dataMutexHandle);
            break;

        case KEY_K4_SHORT:
            /* 备用按键,可自行扩展 */
            break;

        default:
            break;
        }

        osDelay(20);
    }
}

/* ===================== 报警检测任务 ===================== */
void AlarmTask(void *argument)
{
    uint16_t eco2, tvoc, co2_limit, tvoc_limit;
    uint8_t alarm;

    for (;;) {
        osMutexAcquire(dataMutexHandle, osWaitForever);
        eco2 = g_data.eco2;
        tvoc = g_data.tvoc;
        co2_limit = g_data.co2_alarm_limit;
        tvoc_limit = g_data.tvoc_alarm_limit;
        osMutexRelease(dataMutexHandle);

        /* CO2或TVOC任一超标即报警 */
        alarm = (eco2 > co2_limit) || (tvoc > tvoc_limit);

        osMutexAcquire(dataMutexHandle, osWaitForever);
        g_data.alarm_active = alarm;
        osMutexRelease(dataMutexHandle);

        /* 更新蜂鸣器和报警灯 */
        Buzzer_AlarmUpdate(alarm);
        LED_AlarmUpdate(alarm);

        /* 运行灯闪烁,表示程序在跑 */
        LED_Run_Toggle();

        osDelay(1000);
    }
}

/* ===================== 小夜灯控制任务 ===================== */
void NightLightTask(void *argument)
{
    uint16_t light, threshold;
    uint8_t human, smart, night;

    for (;;) {
        osMutexAcquire(dataMutexHandle, osWaitForever);
        light = g_data.light;
        threshold = g_data.light_threshold;
        human = g_data.human_present;
        smart = g_data.smart_mode;
        night = g_data.night_light;
        osMutexRelease(dataMutexHandle);

        NightLight_AutoUpdate(light, threshold, human, smart, night);

        /* 更新小夜灯状态到全局数据 */
        osMutexAcquire(dataMutexHandle, osWaitForever);
        g_data.night_light = NightLight_GetState();
        osMutexRelease(dataMutexHandle);

        osDelay(1000);
    }
}

CubeMX生成的初始化函数(SystemClock_Config、MX_GPIO_Init等)这里不贴了,CubeMX会自动生成。如果不用CubeMX,手动写这些初始化函数也可以,HAL库的例程里都有参考。

4.18 代码文件结构

整个Keil工程的文件结构如下:

STM32_ENV_IOT/
├── Core/
│   ├── Inc/
│   │   ├── main.h
│   │   ├── i2c.h
│   │   ├── sht30.h
│   │   ├── bh1750.h
│   │   ├── sgp30.h
│   │   ├── oled.h
│   │   ├── sr602.h
│   │   ├── wifi_awb.h
│   │   ├── ir_module.h
│   │   ├── night_light.h
│   │   ├── buzzer.h
│   │   ├── led.h
│   │   ├── key.h
│   │   └── mqtt_data.h
│   └── Src/
│       ├── main.c
│       ├── i2c.c
│       ├── sht30.c
│       ├── bh1750.c
│       ├── sgp30.c
│       ├── oled.c
│       ├── sr602.c
│       ├── wifi_awb.c
│       ├── ir_module.c
│       ├── night_light.c
│       ├── buzzer.c
│       ├── led.c
│       ├── key.c
│       └── mqtt_data.c
├── Drivers/          (HAL库文件)
├── Middlewares/      (FreeRTOS文件)
└── STM32_ENV_IOT.uvprojx

[图片:Keil5工程目录结构截图]

[图片:Keil5编译成功0错误0警告截图]

编译下载到STM32,如果OLED能正常显示数据,说明传感器和显示部分没问题。如果华为云设备状态变成在线,说明WiFi和MQTT也通了。

6.10 小程序发布流程

开发调试完成后,可以发布小程序供手机使用。发布流程:

    1. 在微信开发者工具里点"上传",填写版本号和项目备注2. 登录微信公众平台,进入"版本管理"3. 在"开发版本"里找到刚上传的版本,点"提交审核"4. 填写审核信息:功能页面截图、测试账号(如果需要登录)5. 等待微信审核,一般1到3个工作日6. 审核通过后点"发布",小程序正式上线

个人开发者发布的小程序有些功能限制,比如不能用微信支付。但数据展示和设备控制功能不受影响。如果只是自己用,不上线也行,在开发者工具里点"预览"扫码就能在手机上用。开发版本只有管理员和被添加为体验成员的人能用。

注意:正式发布前必须在微信公众平台配置request合法域名,把华为云API的域名加进去。开发阶段可以在开发者工具里勾选"不校验合法域名",但正式版必须配置。需要配置的域名有:

    • iam.cn-north-4.myhuaweicloud.com(获取Token)• 你的IoTDA endpoint(设备影子和命令下发)

域名必须是HTTPS,华为云API默认就是HTTPS,直接加就行。

6.11 小程序体验优化

几个提升使用体验的小细节:

下拉刷新: 在页面json里开启enablePullDownRefresh,在onPullDownRefresh里调loadData,数据加载完调wx.stopPullDownRefresh。用户在首页下拉就能刷新数据。

数据自动刷新: app.js里setInterval每5秒刷新一次。注意页面卸载时要clearInterval,否则定时器会一直跑浪费流量。可以在onShow时启动,onHide时停止。

离线提示: 如果请求失败,在页面顶部显示"网络异常"提示条,而不是只弹一个Toast。用wx.getNetworkType先检查网络状态。

数字动画: 数据变化时用动画过渡,数字从旧值滚动到新值,视觉效果更好。可以用小程序的wx.createAnimation或者纯JS的setInterval实现。

报警高亮: CO2或TVOC超标的时候,对应卡片除了变红外,可以加一个呼吸动画。用CSS的@keyframes实现闪烁效果,更能引起注意。

图表: 历史数据页如果用canvas画图,注意微信小程序的canvas API跟Web不一样。推荐用ec-canvas(Echarts小程序版),功能强大但包体积较大。简单图表也可以用wx-charts。

4.20 Keil工程配置补充

这一节补充Keil工程的一些配置细节,新手经常在这些地方卡住。

4.20.1 添加头文件路径

新建的驱动文件放在Core/Inc和Core/Src目录下,Keil需要知道头文件在哪。点魔术棒图标→C/C++→Include Paths,添加:

    • ../Core/Inc• ../Drivers/STM32F1xx_HAL_Driver/Inc• ../Drivers/CMSIS/Device/ST/STM32F1xx/Include• ../Drivers/CMSIS/Include• ../Middlewares/Third_Party/FreeRTOS/Source/include• ../Middlewares/Third_Party/FreeRTOS/Source/CMSIS_RTOS_V2• ../Middlewares/Third_Party/FreeRTOS/Source/portable/RVDS/ARM_CM3

路径不全的话编译会报"cannot open source input file"错误。

4.20.2 添加源文件到工程

在Keil左侧Project窗口,右键Core/Src组→Add Existing Files,把自己写的.c文件(i2c.c、sht30.c、oled.c等)添加进来。不加的话编译不报错但链接时会报undefined symbol。

如果用FreeRTOS,还需要把Middlewares/Third_Party/FreeRTOS/Source下的所有.c文件和portable/RVDS/ARM_CM3/port.c以及portable/MemMang/heap_4.c添加到工程。这些CubeMX生成工程时一般会自动加好,检查一下。

4.20.3 全局宏定义

在C/C++→Define里需要有:USE_HAL_DRIVER和STM32F103xB。这两个宏决定了HAL库编译哪些文件。CubeMX会自动填好。

4.20.4 MicroLIB

如果用了printf,需要在Target选项卡里勾选Use MicroLIB。MicroLIB是Keil提供的精简C库,体积小,适合嵌入式。不勾的话printf可能不工作或者进入硬件异常。

4.20.5 调试器配置

点魔术棒→Debug,选择ST-Link Debugger(如果用ST-Link),点Settings确认SWD能识别到芯片。Flash Download选项卡里勾选Reset and Run,下载完自动复位运行。如果下载报错"No target connected",检查SWD接线和驱动。

PA13和PA14是SWD调试引脚,不要被其他外设占用。代码里关闭JTAG用的是__HAL_AFIO_REMAP_SWJ_NOJTAG(),只关JTAG保留SWD,不影响调试。

4.20.6 编译优化等级

开发阶段用-O0优化,方便调试。发布版本可以改成-O2减小代码体积。如果用了-O2,注意有些延时函数可能被优化掉导致时序不对,这种情况在变量前加volatile关键字。

4.20.7 查看Flash和RAM占用

编译后Keil底部Build Output窗口会显示:

Program Size: Code=XXXXX RO-data=XXXX RW-data=XXX ZI-data=XXXX
    • Code是代码段大小(Flash)• RO-data是只读常量(Flash)• RW-data是已初始化的全局变量(Flash+RAM)• ZI-data是未初始化全局变量(RAM)• Flash总占用 = Code + RO-data + RW-data• RAM总占用 = RW-data + ZI-data

STM32F103C8T6有64KB Flash和20KB RAM。如果Flash占用接近64KB需要精简代码或者换CBT6(128KB)。这个项目编译出来大概30到40KB Flash,完全够用。

[图片:Keil编译输出截图-显示Code和RAM占用大小]

4.19 代码补充说明

这一节对前面代码中的一些关键点做补充说明,帮助0基础的读者理解。

4.19.1 软件I2C为什么用开漏输出

I2C总线采用开漏输出(Open-Drain)模式。开漏的意思是引脚只能输出低电平,不能输出高电平。高电平靠外部上拉电阻拉上去。

为什么这样设计?因为I2C是多设备总线,多个设备的SDA和SCL引脚接在一起。如果某个设备输出高电平,另一个设备输出低电平,推挽输出就会造成电源短路。开漏输出不存在这个问题,所有设备都只能拉低总线,释放时靠上拉电阻拉高,线与逻辑不会冲突。

在CubeMX里把PA6、PA7、PB0、PB1配置成GPIO_Output,模式选Open-Drain(开漏),上拉电阻用外部的或者内部上拉都行。代码里I2C_SDA_Set(bus, 1)的作用是释放总线(写1),让上拉电阻把线拉高。

4.19.2 FreeRTOS任务优先级和栈大小

代码里创建了6个任务,优先级都是osPriorityNormal。FreeRTOS是抢占式调度器,高优先级任务会抢占低优先级任务。这里所有任务优先级相同,靠时间片轮转调度。

每个任务的栈大小设为512字(在CMSIS-RTOS2中,stack_size单位是words不是bytes,512 words = 2048字节)。如果任务里用了大的局部变量或者深层函数调用,可能需要增大栈。可以在FreeRTOS配置里开栈溢出检测钩子函数vApplicationStackOverflowHook,调试的时候如果栈溢出会进入这个函数。

如果不确定栈够不够用,可以用uxTaskGetStackHighWaterMark函数查看任务运行以来栈剩余的最小空间。剩余空间小于总栈大小的10%就需要增大栈。

4.19.3 互斥锁的作用

全局数据结构体g_data被多个任务同时访问。传感器任务写数据,OLED任务和WiFi任务读数据。如果不做保护,可能出现读到一半数据被改写的情况。

比如传感器任务正在更新temperature和humidity,刚写完temperature还没写humidity,OLED任务就读取了,这时候temperature是新值,humidity还是旧值,数据不一致。虽然这种情况对环境监测影响不大,但严谨的做法是用互斥锁保护。

osMutexAcquire获取锁,osMutexRelease释放锁。一个任务获取锁之后,其他任务再获取会阻塞等待,直到锁被释放。这样保证同一时刻只有一个任务能访问g_data。

注意不要在中断服务函数里调用osMutexAcquire,中断里应该用osMutexAcquire的中断安全版本或者直接关中断保护。

4.19.4 串口环形缓冲区

WiFi模组和红外模块的串口数据接收用环形缓冲区(Ring Buffer)实现。环形缓冲区是一段固定大小的内存,配合头指针(head)和尾指针(tail)工作。

    • 中断收到一个字节,写到head位置,head前进• 主循环从tail位置读字节,tail前进• head追上tail表示缓冲区空• head前进一格等于tail表示缓冲区满

好处是中断处理非常快,只存一个字节就返回,不会丢数据。主循环慢慢处理缓冲区里的数据,不会因为处理慢而影响中断响应。

环形缓冲区大小设为1024字节,足够存下MQTT下发的JSON命令。如果缓冲区满了,新数据会被丢弃,可以在代码里加个溢出标志做调试。

4.19.5 定时器PWM配置说明

小夜灯的PWM调光用TIM1通道1(PA8)。在CubeMX里配置TIM1:

    • 时钟源:内部时钟• 通道1:PWM Generation CH1• Prescaler(预分频):71(72MHz / (71+1) = 1MHz计数频率)• Counter Period(自动重装载值):999(PWM频率 = 1MHz / 1000 = 1kHz)• Pulse(初始比较值):0(初始占空比0%,灯灭)

占空比 = CCR / ARR = pulse / 1000。pulse设为500就是50%亮度,1000是100%亮度。代码里NightLight_SetBrightness(50)把pulse设为500。

人眼对亮度的感知是非线性的,低亮度区间变化敏感,高亮度区间变化不敏感。如果需要更细腻的调光效果,可以把pulse做gamma校正:pulse = 1000 * pow(brightness/100.0, 2.2)。简单应用不用校正也够用。

4.19.6 SGP30基线校准的实际操作

SGP30的基线校准很重要,不校准的话每次上电后读数会偏差较大。实际操作流程:

    1. 第一次烧录程序,让设备在正常通风环境下连续运行至少12小时(最好24小时)2. 12小时后,通过串口打印或者小程序查看基线值(用SGP30_GetBaseline读取)3. 把基线值记录下来,写到代码里作为默认值4. 之后每次上电,SGP30_Init之后调用SGP30_SetBaseline恢复基线5. 每隔一段时间(比如1小时)读取一次基线存到Flash,更新存储的基线值

如果觉得这样麻烦,可以让程序自动处理:上电先从Flash读基线,如果Flash里有有效基线就恢复,没有就不恢复;运行过程中每隔1小时自动存一次基线到Flash。代码里留了SGP30_GetBaseline和SGP30_SetBaseline函数,自行调用即可。

注意:基线值会随环境变化,不能永久使用同一个基线。建议定期更新。

4.19.7 华为云MQTT密码计算

华为云IoTDA的MQTT连接密码不是设备密钥本身,需要用HMAC-SHA256算法计算。计算方法:

    1. 1. 拼接字符串:

{timestamp}_{device_id}

    1. ,timestamp是当前Unix时间戳(毫秒)2. 用设备密钥作为key,对上面的字符串做HMAC-SHA2563. 把HMAC结果做Base64编码4. 在前面加上时间戳:

{timestamp}_{base64_result}

AI-WB2-01S的AT固件有些版本会自动计算密码,只需要填设备ID和密钥。如果固件不支持自动计算,就要在STM32上用软件实现HMAC-SHA256。可以移植mbedTLS或者找一个轻量级的SHA256实现,代码量不大。

具体的密码计算规则以华为云官方文档为准,不同接入方式(密钥认证、证书认证)算法可能不同。

4.19.8 离线重连机制

实际使用中WiFi可能断开或者MQTT连接断开,需要重连机制。在WifiTask里可以加一个连接状态检测:

/* 伪代码:在WifiTask的主循环中加状态检测 */
if (!g_data.wifi_connected) {
    /* WiFi断开,重新连接路由器 */
    if (WiFi_ConnectAP(WIFI_SSID, WIFI_PASSWORD) == 0) {
        g_data.wifi_connected = 1;
    } else {
        osDelay(10000);  /* 10秒后重试 */
        continue;
    }
}

if (!g_data.mqtt_connected) {
    /* MQTT断开,重新连接 */
    if (WiFi_MQTT_Connect(...) == 0) {
        g_data.mqtt_connected = 1;
        WiFi_MQTT_Subscribe(MQTT_TOPIC_SUB);
    } else {
        osDelay(5000);
        continue;
    }
}

怎么检测连接是否断开?AI-WB2-01S在MQTT连接断开时会通过串口输出+MQTTDISCONNECTED消息。在WiFi_CheckCommand里同时检测这个消息,如果收到就把mqtt_connected置0。WiFi断开时会输出WIFI DISCONNECTED,同样处理。

重连时要有退避机制,不要死循环疯狂重连。第一次失败等5秒,第二次等10秒,最多等60秒,避免网络不好时浪费资源。

4.19.9 看门狗防止死机

长时间运行的设备建议开启独立看门狗(IWDG)。IWDG是一个硬件定时器,如果不在规定时间内喂狗,就会自动复位芯片。

在CubeMX里开启IWDG,超时时间设为10秒。在所有任务都正常运行时喂狗,如果某个任务死循环或者硬件卡死导致喂狗停止,10秒后系统自动重启恢复。

喂狗可以放在一个独立的高优先级任务里,检测每个任务是否有正常运行的标志位。只有所有任务都正常才喂狗,有任务异常就不喂,让系统重启。

/* 喂狗任务伪代码 */
void WatchdogTask(void *arg) {
    for (;;) {
        /* 检查各任务心跳标志 */
        if (sensor_alive && oled_alive && wifi_alive) {
            HAL_IWDG_Refresh(&hiwdg);
        }
        osDelay(5000);  /* 每5秒喂一次,看门狗超时10秒 */
    }
}
4.19.10 低功耗考虑

如果用电池供电,需要考虑低功耗。STM32F103没有特别好的低功耗模式(STOP模式电流也要几百微安),而且外挂的传感器和WiFi模组本身功耗不低。建议用USB供电或者稳定的5V电源。

如果一定要电池供电,可以考虑:

    • 换用STM32L系列低功耗MCU• WiFi模组平时休眠,定时唤醒上报数据• 传感器用MOS管控制电源,不采集时断电• 用中断方式替代轮询,让MCU大部分时间在STANDBY模式

但这个项目功能比较多,持续监测和联网的话电池不耐用,建议直接接电源。


五、万能红外遥控功能详解

红外遥控是这个项目的一个亮点。市面上绝大多数家电遥控器都是红外的,空调、电视、风扇、投影仪……如果能把这些遥控器的功能集中到手机上,用微信小程序控制,会方便很多。

5.1 为什么用成品红外学习模组

做红外遥控一般有两种方案:

方案一:分立元件
用HS0038B一体化红外接收头接收信号,用940nm红外发射管发射信号。STM32用定时器输入捕获功能测量红外波形的高低电平持续时间,自己解析NEC、RC5等协议。发射的时候用定时器PWM产生38K载波,按照协议时序翻转IO。

这个方案成本低,但是开发难度大。不同厂家的遥控器协议不一样,NEC协议有标准的也有扩展的,空调遥控器的码值很长,还有的协议根本不公开。调试的时候要用逻辑分析仪抓波形,费时费力。

方案二:成品红外学习模组
模块内部集成了红外接收头、发射管和主控芯片。STM32只需要通过串口发命令,模块自动完成信号的接收、解码、存储和发射。不用管是什么协议,模块内部会把原始波形记录下来,发射的时候原样发出去。

选的是方案二。做项目以实现功能为目标,能站在巨人肩膀上就不要自己从零造轮子。成品模块几块钱到十几块钱,省下的开发时间值多了。

[图片:成品红外学习模块正反面照片]

5.2 模块工作原理

成品红外学习模组的工作过程分两步:

学习过程:

    1. STM32通过串口发送学习命令给模块2. 模块进入学习状态,等待接收红外信号(一般有LED指示灯快闪提示)3. 拿遥控器对准模块的红外接收头,距离5到10厘米,按一个键4. 模块接收到红外信号,内部解调并记录波形数据5. 模块通过串口把学习到的码值数据返回给STM326. STM32把码值保存到Flash里7. 模块LED常亮或慢闪表示学习成功,如果超时没收到信号会返回失败

发射过程:

    1. 微信小程序发送红外发射命令到华为云2. 华为云下发命令给STM323. STM32从Flash里读取对应按键的红外码值4. STM32通过串口把发射命令加码值数据发给模块5. 模块驱动红外发射管,把信号原样发射出去6. 家电接收到信号,执行对应操作

模块内部的原理大致是:红外接收头把38K调制的红外信号解调出来,主控芯片用定时器测量每个高低电平的持续时间,把这些时间数据存储起来。发射的时候,按照记录的时间数据控制红外发射管的开关,同时产生38K载波调制,还原出原始信号。

5.3 串口通信协议

下面定义一个通用的协议框架。注意:不同厂家的模块协议可能不同,具体协议以所购模块的手册为准。拿到模块后先用USB转串口测试,确认协议后再改代码。

5.3.1 帧格式
字段 字节数 说明
帧头 2 固定0xAA 0x55
命令字 1 见下表
数据长度 1 数据域字节数
数据域 N 命令相关数据
校验和 1 从帧头到数据域所有字节的累加和,取低8位
5.3.2 命令字定义
命令字 方向 名称 数据域
0x01 STM32→模块 开始学习 1字节:槽位号(0-7)
0x02 模块→STM32 学习成功 1字节槽位号 + N字节红外码值
0x03 STM32→模块 发送红外 N字节红外码值
0x04 模块→STM32 发送完成 无(数据长度=0)
0x05 模块→STM32 学习失败 1字节:失败原因(0=超时,1=信号太弱)
0x06 STM32→模块 清除码值 1字节:槽位号
0x07 模块→STM32 操作应答 1字节:0=成功,1=失败
5.3.3 学习命令示例

STM32发送(学习槽位1):

AA 55 01 01 01 A3

解析:

    • 帧头:AA 55• 命令字:01(学习)• 数据长度:01• 数据域:01(槽位1)• 校验和:AA+55+01+01+01 = 0x1A3 → 取低8位 = 0xA3

模块返回学习成功:

AA 55 02 25 01 [35字节红外码值数据] XX

解析:

    • 帧头:AA 55• 命令字:02(学习成功)• 数据长度:0x25 = 37字节• 数据域:01(槽位1) + 36字节码值数据• 校验和:XX

模块返回学习失败(超时):

AA 55 05 01 00 01
5.3.4 发射命令示例

STM32发送(发射槽位1的红外码):

AA 55 03 24 [36字节红外码值数据] XX

模块返回发送完成:

AA 55 04 00 03

校验和计算:AA+55+04+00 = 0x103 → 取低8位 = 0x03

5.4 学习空调/风扇遥控器操作流程

以学习空调遥控器的开关键为例:

    1. 打开微信小程序,进入红外遥控页面2. 选择"空调"设备,点击"开关"按键旁边的"学习"按钮3. 小程序调用华为云API下发ir_learn命令,slot=14. STM32收到命令,通过串口发送学习指令给红外模块5. 模块进入学习状态(LED快闪)6. 把空调遥控器对准模块,距离5到10厘米,按"开关"键7. 模块接收到信号,返回码值给STM328. STM32把码值保存到Flash的槽位19. 小程序显示"学习成功"10. 以后点击"开关"按钮,STM32从Flash读取码值发给模块,模块发射红外信号控制空调

学习风扇、电视、投影仪的流程完全一样,区别只是slot槽位号不同。建议做一个映射表:

槽位 设备 按键
0 空调 开关
1 空调 温度+
2 空调 温度-
3 风扇 开关
4 风扇 调速
5 电视 开关
6 电视 音量+
7 电视 音量-

8个槽位一般够用。如果需要更多,把Flash存储区域扩大,代码里IR_SLOT_COUNT改大就行。

5.5 Flash存储红外码值方案

STM32F103C8T6有64KB Flash,每页1KB。最后一页地址是0x0800FC00到0x0800FFFF,用来存储红外码值。

Flash写入有几个注意点:

写入前必须先擦除:Flash只能把1写成0,不能把0写成1。擦除后整页变成0xFF。

擦除以页为单位:擦除一页会清掉整页1KB数据。如果所有槽位在同一页,保存一个槽位会擦掉其他槽位。

解决方案:保存前先把整页所有槽位的数据读到RAM,擦除整页,再把所有槽位写回。或者把不同槽位放在不同页(但C8T6只有64页,不划算)。

写入必须是16位(半字)对齐:HAL_FLASH_Program用FLASH_TYPEPROGRAM_HALFWORD,每次写2字节。

数据存储格式:

每个槽位占用:
  2字节:魔数0x4952("IR"),标识有效数据
  2字节:码值长度N
  N字节:码值数据
  填充到偶数字节

具体实现在4.11节的IR_SaveToFlashIR_LoadFromFlash函数里已经给出。

如果担心Flash擦写次数限制(STM32 Flash典型10万次擦写),可以加磨损均衡逻辑。但红外码值学习的次数不会很频繁,一般使用完全够用。

5.6 串口接收状态机

4.11节的IR驱动代码里,串口接收写得比较简化。实际项目中应该用状态机逐字节解析帧,避免半包和粘包问题。下面给一个完整的状态机实现。

/* ir_module.c 串口接收状态机 */

typedef enum {
    IR_RX_WAIT_HEAD0 = 0,  /* 等待帧头第1字节0xAA */
    IR_RX_WAIT_HEAD1,      /* 等待帧头第2字节0x55 */
    IR_RX_WAIT_CMD,        /* 等待命令字 */
    IR_RX_WAIT_LEN,        /* 等待数据长度 */
    IR_RX_WAIT_DATA,       /* 接收数据域 */
    IR_RX_WAIT_CHECKSUM    /* 等待校验和 */
} IR_RxState_t;

static IR_RxState_t rx_state = IR_RX_WAIT_HEAD0;
static uint8_t rx_cmd = 0;
static uint8_t rx_len = 0;
static uint8_t rx_data[128];
static uint8_t rx_index = 0;
static uint8_t rx_checksum = 0;

/* 在串口接收中断中逐字节调用 */
void IR_ParseByte(uint8_t byte)
{
    switch (rx_state) {
    case IR_RX_WAIT_HEAD0:
        if (byte == 0xAA) {
            rx_checksum = byte;
            rx_state = IR_RX_WAIT_HEAD1;
        }
        break;

    case IR_RX_WAIT_HEAD1:
        if (byte == 0x55) {
            rx_checksum += byte;
            rx_state = IR_RX_WAIT_CMD;
        } else {
            rx_state = IR_RX_WAIT_HEAD0;
        }
        break;

    case IR_RX_WAIT_CMD:
        rx_cmd = byte;
        rx_checksum += byte;
        rx_state = IR_RX_WAIT_LEN;
        break;

    case IR_RX_WAIT_LEN:
        rx_len = byte;
        rx_checksum += byte;
        rx_index = 0;
        if (rx_len == 0) {
            rx_state = IR_RX_WAIT_CHECKSUM;
        } else {
            rx_state = IR_RX_WAIT_DATA;
        }
        break;

    case IR_RX_WAIT_DATA:
        rx_data[rx_index++] = byte;
        rx_checksum += byte;
        if (rx_index >= rx_len) {
            rx_state = IR_RX_WAIT_CHECKSUM;
        }
        break;

    case IR_RX_WAIT_CHECKSUM:
        if (byte == (rx_checksum & 0xFF)) {
            /* 校验通过,处理帧 */
            IR_HandleFrame(rx_cmd, rx_data, rx_len);
        }
        rx_state = IR_RX_WAIT_HEAD0;
        rx_checksum = 0;
        break;
    }
}

/* 处理完整帧 */
static void IR_HandleFrame(uint8_t cmd, uint8_t *data, uint8_t len)
{
    switch (cmd) {
    case 0x02:  /* 学习成功 */
        if (len >= 2 && len <= IR_MAX_CODE_LEN + 1) {
            learned_code.len = len - 1;
            memcpy(learned_code.data, &data[1], len - 1);
            /* 保存到Flash */
            IR_SaveToFlash(data[0], &learned_code);
            last_response = 1;
        }
        break;
    case 0x04:  /* 发送完成 */
        last_response = 2;
        break;
    case 0x05:  /* 学习失败 */
        last_response = 3;
        break;
    default:
        break;
    }
}

在HAL_UART_RxCpltCallback里调用IR_ParseByte,每收到一个字节解析一次。这样不管数据分几包到达,都能正确拼帧。

5.7 小程序红外遥控界面

小程序端的红外遥控页面设计成一个遥控器的样子,每个按键可以学习和发送。具体实现在第六章。

[图片:小程序红外遥控页面UI截图]

页面布局:

    • 顶部设备选择(空调/风扇/电视)• 中间按键网格,每个按键有名称和状态指示灯• 长按按键进入学习模式• 短按按键发送红外码

学习的时候,小程序先调用华为云API下发ir_learn命令,然后轮询设备影子(或者等命令响应)确认学习结果。实际操作中可以在小程序端加一个5秒倒计时,提示用户"请在5秒内按下遥控器按键"。

5.8 常见问题

Q:学习不成功怎么办?
A:检查遥控器和模块之间的距离,5到10厘米最佳。确认遥控器电池有电。模块的红外接收头不要被遮挡。学习的时候按住遥控器按键1到2秒再松开。

Q:发射距离不够怎么办?
A:成品模块一般自带红外发射管,发射距离3到5米。如果不够,可以外接一个大功率红外发射管,或者在发射管前面加一个凸透镜聚光。

Q:空调遥控器学习后控制不了?
A:有些空调遥控器的红外码比较特殊,带有滚动码或者校验。可以用逻辑分析仪抓一下波形,确认模块记录的波形是否完整。如果是滚动码,每次发射的码值不同,学习一次可能不够。

Q:最多学习多少个按键?
A:取决于Flash存储空间。每个按键的红外码长度不同,NEC协议的码大概几十字节,空调遥控器的码可能上百字节。8个槽位在STM32F103C8T6最后一页(1KB)存得下。

5.9 红外模块选购建议

市面上成品红外学习模块种类不少,选购时注意以下几点:

通信接口:选串口TTL电平的,3.3V或5V逻辑。不要选USB接口的,不好跟STM32连接。

供电电压:确认模块供电是3.3V还是5V,跟STM32共地。如果模块是5V供电但TX是3.3V逻辑,可以直连。

红外发射功率:发射管的数量和角度决定控制距离。单管一般3到5米,双管或者加透镜能到8到10米。

学习容量:不同模块能存储的红外码数量不同,有的模块内部自带Flash能存几十组,有的需要STM32外部存储。用的这款把码值通过串口返回给STM32,存在STM32 Flash里,容量只受STM32 Flash限制。

支持协议:问清楚模块是否支持空调码。空调遥控器的红外码比较长,有些便宜模块只支持NEC等标准协议,不支持空调的长码。

技术文档:一定要买有串口协议文档的模块。没有文档的买回来靠猜协议太浪费时间。

在Datasheet5上查过几款红外模块的数据手册,对比了协议格式和电气参数。与非AI上搜"红外学习模块 串口"也有参考方案和选型建议。选型阶段多花点时间比较,后面少走弯路。

买回来后第一件事是用USB转串口模块接电脑,打开串口调试助手,按照文档发命令测试。确认模块能正常学习和发射,再接STM32调试。这样出了问题能快速定位是模块的问题还是代码的问题。

5.10 红外协议小知识

虽然用成品模块不用自己解析红外协议,但了解一些基础原理有助于调试。

红外遥控通信的基本原理:

    载波频率一般是38kHz(也有36kHz、40kHz的)• 发送端用载波调制二进制数据• 接收端一体化接收头(如HS0038)解调输出数字信号• 不同协议用不同的高低电平时长表示0和1

常见的红外协议:

NEC协议

    • 最常见的协议,电视、风扇、机顶盒遥控器大多用这个• 协议格式:引导码 + 地址码 + 地址反码 + 命令码 + 命令反码• 逻辑0:0.56ms高 + 0.56ms低• 逻辑1:0.56ms高 + 1.68ms低• 一次完整码约67ms

RC5协议

    • 飞利浦的协议,用曼彻斯特编码• 14位数据:2位起始 + 1位翻转 + 5位地址 + 6位命令• 每个位时长1.778ms

空调长码

    • 空调遥控器的码值一般很长,几十到上百字节• 包含开关、模式、温度、风速、扫风等所有状态• 每次按键发送完整状态帧• 不同品牌格式差异很大

成品模块把这些协议差异都屏蔽了,不管什么协议,模块记录原始波形后原样发射。所以理论上任何红外遥控器都能学习。但有些空调遥控器有校验机制,如果模块记录的波形有微小偏差,空调可能不认。这种情况多学习几次,或者用手机摄像头确认发射管有闪光。

5.11 多设备红外控制的考虑

家里要控制多个设备(空调、电视、风扇),一个红外发射模块够不够?理论上够,因为红外是广播式的,发射出去所有对着的设备都能收到。但有个问题:不同设备在不同房间,红外信号穿不了墙。

解决方案有几种:

每个房间放一个设备:每个房间放一个STM32节点,各自带红外发射模块。成本高但效果好。

外接红外发射管延长线:模块上插一个3.5mm接口,外接红外发射线贴在设备前面。一个模块可以拖多个发射管。

红外转发器:用433MHz或者WiFi做中转,客厅的节点收到命令后通过射频转发到卧室的红外发射器

这个项目先做单节点,控制同一房间内的设备。如果需要全屋控制,后续扩展多节点方案。

5.12 红外码值管理

学到的红外码值存在STM32 Flash里,断电不丢失。但有个问题:如果重新烧录程序,Flash最后一页会不会被擦除?

Keil默认会擦除整个Flash再下载程序。如果红外码存在最后一页,每次烧录程序都会擦掉码值,需要重新学习。

解决方法:

在Keil里设置Flash下载范围:把最后一页排除在下载范围外。在Keil的Flash→Configure Flash Tools→Flash Download里,把Erase Sectors改成只擦除程序用到的扇区。

用Option Bytes做数据存储:把某段Flash标记为只读,下载时不擦除。但配置比较麻烦。

码值存到外部EEPROM:比如AT24C02(I2C接口),存在EEPROM里,完全不受程序烧录影响。

码值上传到云端:学习成功后把码值通过华为云上报,服务器存储。换设备或者重新烧录后从云端同步。

最简单的是方法1,改一下Keil设置就行。如果频繁调试代码,建议用方法3外接EEPROM,一劳永逸。


六、微信小程序开发

STM32端数据上报到华为云之后,需要一个手机端界面来查看数据和控制设备。微信小程序是最好的选择,不用下载安装APP,微信里打开就能用,还能分享给家人。

6.1 开发准备

6.1.1 注册小程序账号

去微信公众平台注册一个小程序账号。个人开发者就能注册,免费的。注册完成后在"开发管理→开发设置"里拿到AppID。

如果只是本地开发调试,也可以先用测试号。在微信开发者工具里选"测试号"登录。

6.1.2 安装微信开发者工具

下载微信开发者工具安装后用微信扫码登录。

6.1.3 创建工程

打开微信开发者工具,新建小程序项目:

    • 项目名称:STM32环境监测• 目录:选一个空文件夹• AppID:填自己的AppID,或者选测试号• 开发模式:小程序• 后端服务:不选云服务

创建完成后能看到默认的工程结构:

pages/
├── index/          首页
│   ├── index.js
│   ├── index.wxml
│   ├── index.wxss
│   └── index.json
├── setting/        设置页
├── ir/             红外遥控页
└── history/        历史数据页
utils/
├── huawei.js       华为云API封装
└── util.js
app.js
app.json
app.wxss
6.1.4 下载开发工具

6.1.5 安装开发工具

双击安装。

点击下一步。

安装完毕了找到开发工具的位置。

如果桌面没有自动创建快捷方式。可以自己创建一个。

6.1.6 打开使用开发工具

进去之后,可以微信扫码登录。

关闭默认打开的工程。就会看到下面的页面。

选择 +  号。

6.1.7 新建工程

APPID可以注册一个,也可先使用测试号。 然后,选择小程序,模版选择基础,选择-JS-基础模版。

选择创建。

工程打开之后就可以点击预览测试。

微信扫码就可以看默认的效果了。

6.2 华为云API封装:huawei.js

小程序通过华为云API获取Token、查询设备影子、下发命令。把这些接口封装成一个工具模块。

// utils/huawei.js

// 华为云配置,替换成自己的
const config = {
  // IAM获取Token的地址(根据区域选择)
  iamUrl: 'https://iam.cn-north-4.myhuaweicloud.com/v3/auth/tokens',
  // IoT设备接入地址
  iotUrl: 'https://{endpoint}/v5/iot/{project_id}/devices/{device_id}',
  // 华为云账号信息
  domain: 'YOUR_DOMAIN_NAME',       // 华为云账号名
  username: 'YOUR_USERNAME',        // IAM用户名
  password: 'YOUR_PASSWORD',        // IAM密码
  projectId: 'YOUR_PROJECT_ID',     // 项目ID
  endpoint: 'YOUR_ENDPOINT',        // IoT接入点
  deviceId: 'YOUR_DEVICE_ID',       // 设备ID
};

// Token缓存
let token = '';
let tokenExpireTime = 0;

/**
 * 获取Token
 * Token有效期24小时,缓存起来避免重复请求
 */
function getToken() {
  return new Promise((resolve, reject) => {
    // 如果Token还没过期,直接返回
    if (token && Date.now() < tokenExpireTime) {
      resolve(token);
      return;
    }

    wx.request({
      url: config.iamUrl,
      method: 'POST',
      header: { 'Content-Type': 'application/json' },
      data: {
        auth: {
          identity: {
            methods: ['password'],
            password: {
              user: {
                name: config.username,
                password: config.password,
                domain: { name: config.domain }
              }
            }
          },
          scope: {
            project: { name: 'cn-north-4' }  // 区域
          }
        }
      },
      success(res) {
        if (res.statusCode === 201) {
          // Token在响应Header的X-Subject-Token里
          token = res.header['X-Subject-Token'] || res.header['x-subject-token'];
          // 24小时后过期,提前1小时刷新
          tokenExpireTime = Date.now() + 23 * 60 * 60 * 1000;
          resolve(token);
        } else {
          reject(new Error('获取Token失败'));
        }
      },
      fail(err) {
        reject(err);
      }
    });
  });
}

/**
 * 查询设备影子(获取设备最新状态)
 */
function getDeviceShadow() {
  return new Promise((resolve, reject) => {
    getToken().then(tok => {
      const url = `https://${config.endpoint}/v5/iot/${config.projectId}/devices/${config.deviceId}/shadow`;
      wx.request({
        url: url,
        method: 'GET',
        header: {
          'X-Auth-Token': tok
        },
        success(res) {
          if (res.statusCode === 200) {
            resolve(res.data);
          } else {
            reject(new Error('查询设备影子失败'));
          }
        },
        fail(err) {
          reject(err);
        }
      });
    }).catch(reject);
  });
}

/**
 * 下发命令给设备
 * @param {string} commandName 命令名
 * @param {object} paras 命令参数
 */
function sendCommand(commandName, paras) {
  return new Promise((resolve, reject) => {
    getToken().then(tok => {
      const url = `https://${config.endpoint}/v5/iot/${config.projectId}/devices/${config.deviceId}/commands`;
      wx.request({
        url: url,
        method: 'POST',
        header: {
          'Content-Type': 'application/json',
          'X-Auth-Token': tok
        },
        data: {
          service_id: 'env_monitor',
          command_name: commandName,
          paras: paras
        },
        success(res) {
          if (res.statusCode === 200 || res.statusCode === 201) {
            resolve(res.data);
          } else {
            reject(new Error('下发命令失败'));
          }
        },
        fail(err) {
          reject(err);
        }
      });
    }).catch(reject);
  });
}

// 导出模块
module.exports = {
  config,
  getToken,
  getDeviceShadow,
  sendCommand
};

注意:config里的账号密码是敏感信息,不建议直接硬编码在小程序前端代码里。正式项目应该搭一个后端服务器,小程序请求自己的服务器,服务器再调华为云API。这里为了简化教程,直接写在前端。个人使用问题不大,不要把代码上传到公开的GitHub仓库。

6.3 app.js全局配置

// app.js
App({
  globalData: {
    userInfo: null,
    // 设备最新数据
    deviceData: {
      temperature: 0,
      humidity: 0,
      light: 0,
      tvoc: 0,
      eco2: 0,
      human_present: 0,
      human_duration: 0,
      night_light: 0
    },
    // 自动刷新间隔
    refreshInterval: 5000
  },

  onLaunch() {
    // 启动时获取一次数据
    this.refreshData();
    // 定时刷新
    setInterval(() => {
      this.refreshData();
    }, this.globalData.refreshInterval);
  },

  refreshData() {
    const huawei = require('./utils/huawei.js');
    huawei.getDeviceShadow().then(shadow => {
      // 解析设备影子数据
      if (shadow.shadow && shadow.shadow.length > 0) {
        const props = shadow.shadow[0].reported;
        if (props) {
          this.globalData.deviceData = {
            temperature: props.temperature || 0,
            humidity: props.humidity || 0,
            light: props.light || 0,
            tvoc: props.tvoc || 0,
            eco2: props.eco2 || 0,
            human_present: props.human_present || 0,
            human_duration: props.human_duration || 0,
            night_light: props.night_light || 0
          };
        }
      }
    }).catch(err => {
      console.error('刷新数据失败', err);
    });
  }
});

6.4 app.json页面注册

{
  "pages": [
    "pages/index/index",
    "pages/setting/setting",
    "pages/ir/ir",
    "pages/history/history"
  ],
  "window": {
    "backgroundTextStyle": "light",
    "navigationBarBackgroundColor": "#1890ff",
    "navigationBarTitleText": "室内环境监测",
    "navigationBarTextStyle": "white"
  },
  "tabBar": {
    "color": "#999",
    "selectedColor": "#1890ff",
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "首页"
      },
      {
        "pagePath": "pages/setting/setting",
        "text": "设置"
      },
      {
        "pagePath": "pages/ir/ir",
        "text": "遥控"
      }
    ]
  },
  "style": "v2",
  "sitemapLocation": "sitemap.json"
}

6.5 首页:实时数据展示

首页显示所有传感器数据,用卡片式布局,数据每隔5秒自动刷新。

<!-- pages/index/index.wxml -->
<view class="container">
  <!-- 顶部状态栏 -->
  <view class="status-bar">
    <view class="status-item">
      <text class="status-dot {{online ? 'online' : 'offline'}}"></text>
      <text>{{online ? '设备在线' : '设备离线'}}</text>
    </view>
    <text class="update-time">{{updateTime}}</text>
  </view>

  <!-- 温度卡片 -->
  <view class="card temp-card">
    <view class="card-icon">️</view>
    <view class="card-info">
      <text class="card-label">环境温度</text>
      <view class="card-value-row">
        <text class="card-value">{{deviceData.temperature}}</text>
        <text class="card-unit">℃</text>
      </view>
    </view>
  </view>

  <!-- 湿度卡片 -->
  <view class="card humi-card">
    <view class="card-icon"></view>
    <view class="card-info">
      <text class="card-label">环境湿度</text>
      <view class="card-value-row">
        <text class="card-value">{{deviceData.humidity}}</text>
        <text class="card-unit">%RH</text>
      </view>
    </view>
  </view>

  <!-- 光照卡片 -->
  <view class="card light-card">
    <view class="card-icon">☀️</view>
    <view class="card-info">
      <text class="card-label">环境光照</text>
      <view class="card-value-row">
        <text class="card-value">{{deviceData.light}}</text>
        <text class="card-unit">Lux</text>
      </view>
    </view>
  </view>

  <!-- CO2卡片 -->
  <view class="card co2-card {{deviceData.eco2 > co2Limit ? 'alarm' : ''}}">
    <view class="card-icon">🫁</view>
    <view class="card-info">
      <text class="card-label">二氧化碳</text>
      <view class="card-value-row">
        <text class="card-value">{{deviceData.eco2}}</text>
        <text class="card-unit">ppm</text>
      </view>
    </view>
    <view wx:if="{{deviceData.eco2 > co2Limit}}" class="alarm-tag">超标</view>
  </view>

  <!-- TVOC卡片 -->
  <view class="card tvoc-card {{deviceData.tvoc > tvocLimit ? 'alarm' : ''}}">
    <view class="card-icon">️</view>
    <view class="card-info">
      <text class="card-label">TVOC挥发气体</text>
      <view class="card-value-row">
        <text class="card-value">{{deviceData.tvoc}}</text>
        <text class="card-unit">ppb</text>
      </view>
    </view>
    <view wx:if="{{deviceData.tvoc > tvocLimit}}" class="alarm-tag">超标</view>
  </view>

  <!-- 人体状态和小夜灯 -->
  <view class="card-row">
    <view class="card-small">
      <view class="card-icon-sm"></view>
      <text class="card-label-sm">人体状态</text>
      <text class="card-value-sm {{deviceData.human_present ? 'active' : ''}}">
        {{deviceData.human_present ? '有人' : '无人'}}
      </text>
    </view>
    <view class="card-small">
      <view class="card-icon-sm"></view>
      <text class="card-label-sm">小夜灯</text>
      <text class="card-value-sm {{deviceData.night_light ? 'active' : ''}}">
        {{deviceData.night_light ? '开启' : '关闭'}}
      </text>
    </view>
  </view>

  <!-- 活动时长 -->
  <view class="card">
    <view class="card-icon">⏱️</view>
    <view class="card-info">
      <text class="card-label">今日活动时长</text>
      <view class="card-value-row">
        <text class="card-value">{{deviceData.human_duration}}</text>
        <text class="card-unit">分钟</text>
      </view>
    </view>
  </view>

  <!-- 手动刷新按钮 -->
  <button class="refresh-btn" bindtap="manualRefresh" loading="{{loading}}">
    刷新数据
  </button>
</view>
/* pages/index/index.wxss */
.container {
  padding: 20rpx;
  background: #f5f5f5;
  min-height: 100vh;
}

.status-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10rpx 20rpx;
  margin-bottom: 20rpx;
}

.status-item {
  display: flex;
  align-items: center;
  font-size: 26rpx;
  color: #666;
}

.status-dot {
  width: 16rpx;
  height: 16rpx;
  border-radius: 50%;
  margin-right: 10rpx;
}

.status-dot.online { background: #52c41a; }
.status-dot.offline { background: #ff4d4f; }

.update-time {
  font-size: 24rpx;
  color: #999;
}

.card {
  background: #fff;
  border-radius: 16rpx;
  padding: 30rpx;
  margin-bottom: 20rpx;
  display: flex;
  align-items: center;
  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.08);
}

.card-icon {
  font-size: 60rpx;
  margin-right: 30rpx;
}

.card-info {
  flex: 1;
}

.card-label {
  font-size: 28rpx;
  color: #999;
  display: block;
  margin-bottom: 10rpx;
}

.card-value-row {
  display: flex;
  align-items: baseline;
}

.card-value {
  font-size: 48rpx;
  font-weight: bold;
  color: #333;
}

.card-unit {
  font-size: 28rpx;
  color: #999;
  margin-left: 8rpx;
}

.card.alarm {
  border: 2rpx solid #ff4d4f;
  background: #fff1f0;
}

.alarm-tag {
  background: #ff4d4f;
  color: #fff;
  font-size: 24rpx;
  padding: 4rpx 16rpx;
  border-radius: 8rpx;
}

.card-row {
  display: flex;
  gap: 20rpx;
  margin-bottom: 20rpx;
}

.card-small {
  flex: 1;
  background: #fff;
  border-radius: 16rpx;
  padding: 30rpx;
  text-align: center;
  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.08);
}

.card-icon-sm {
  font-size: 48rpx;
  display: block;
  margin-bottom: 10rpx;
}

.card-label-sm {
  font-size: 24rpx;
  color: #999;
  display: block;
  margin-bottom: 8rpx;
}

.card-value-sm {
  font-size: 32rpx;
  font-weight: bold;
  color: #999;
}

.card-value-sm.active {
  color: #52c41a;
}

.refresh-btn {
  margin-top: 30rpx;
  background: #1890ff;
  color: #fff;
  border-radius: 12rpx;
}
// pages/index/index.js
const app = getApp();
const huawei = require('../../utils/huawei.js');

Page({
  data: {
    deviceData: {},
    online: false,
    updateTime: '',
    loading: false,
    co2Limit: 1000,
    tvocLimit: 1000
  },

  onShow() {
    this.loadData();
  },

  loadData() {
    huawei.getDeviceShadow().then(shadow => {
      if (shadow.shadow && shadow.shadow.length > 0) {
        const props = shadow.shadow[0].reported || {};
        this.setData({
          deviceData: {
            temperature: (props.temperature || 0).toFixed(1),
            humidity: (props.humidity || 0).toFixed(1),
            light: props.light || 0,
            tvoc: props.tvoc || 0,
            eco2: props.eco2 || 0,
            human_present: props.human_present || 0,
            human_duration: props.human_duration || 0,
            night_light: props.night_light || 0
          },
          online: shadow.online || true,
          updateTime: this.formatTime(new Date())
        });
      }
    }).catch(err => {
      console.error(err);
      wx.showToast({ title: '获取数据失败', icon: 'none' });
    });
  },

  manualRefresh() {
    this.setData({ loading: true });
    this.loadData();
    setTimeout(() => {
      this.setData({ loading: false });
    }, 1000);
  },

  formatTime(date) {
    const h = date.getHours().toString().padStart(2, '0');
    const m = date.getMinutes().toString().padStart(2, '0');
    const s = date.getSeconds().toString().padStart(2, '0');
    return `${h}:${m}:${s}`;
  }
});

6.6 设置页:参数配置

设置页用来配置亮度下限、CO2报警上限、TVOC报警上限和智能模式开关。改完点保存,调用华为云API下发命令给设备。

<!-- pages/setting/setting.wxml -->
<view class="container">
  <!-- 智能模式 -->
  <view class="setting-card">
    <view class="setting-row">
      <view class="setting-label">
        <text class="label-icon">🤖</text>
        <text class="label-text">智能模式</text>
      </view>
      <switch checked="{{smartMode}}" bindchange="onSmartModeChange" color="#1890ff"/>
    </view>
    <view class="setting-desc">开启后,亮度低于下限时检测到人自动开小夜灯</view>
  </view>

  <!-- 亮度下限 -->
  <view class="setting-card">
    <view class="setting-title"> 小夜灯亮度下限</view>
    <view class="setting-value">{{lightThreshold}} Lux</view>
    <slider
      min="0" max="100" step="1" value="{{lightThreshold}}"
      bindchanging="onLightThresholdChange"
      activeColor="#1890ff"
    />
    <view class="setting-desc">环境亮度低于此值且检测到人时,小夜灯自动开启</view>
  </view>

  <!-- CO2报警上限 -->
  <view class="setting-card">
    <view class="setting-title">🫁 二氧化碳报警上限</view>
    <view class="setting-value">{{co2Limit}} ppm</view>
    <slider
      min="400" max="5000" step="100" value="{{co2Limit}}"
      bindchanging="onCo2LimitChange"
      activeColor="#ff4d4f"
    />
    <view class="setting-desc">CO2超过此值时蜂鸣器和报警灯启动</view>
  </view>

  <!-- TVOC报警上限 -->
  <view class="setting-card">
    <view class="setting-title">️ TVOC报警上限</view>
    <view class="setting-value">{{tvocLimit}} ppb</view>
    <slider
      min="0" max="5000" step="50" value="{{tvocLimit}}"
      bindchanging="onTvocLimitChange"
      activeColor="#ff4d4f"
    />
    <view class="setting-desc">TVOC超过此值时蜂鸣器和报警灯启动</view>
  </view>

  <!-- 小夜灯手动控制 -->
  <view class="setting-card">
    <view class="setting-row">
      <view class="setting-label">
        <text class="label-icon"></text>
        <text class="label-text">小夜灯</text>
      </view>
      <switch checked="{{nightLight}}" bindchange="onNightLightChange" color="#faad14"/>
    </view>
    <view class="setting-desc">手动开关小夜灯(智能模式关闭时生效)</view>
  </view>

  <!-- 保存按钮 -->
  <button class="save-btn" bindtap="saveSettings" loading="{{saving}}">
    保存设置
  </button>
</view>
/* pages/setting/setting.wxss */
.container {
  padding: 20rpx;
  background: #f5f5f5;
  min-height: 100vh;
}

.setting-card {
  background: #fff;
  border-radius: 16rpx;
  padding: 30rpx;
  margin-bottom: 20rpx;
  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.08);
}

.setting-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.setting-label {
  display: flex;
  align-items: center;
}

.label-icon {
  font-size: 40rpx;
  margin-right: 16rpx;
}

.label-text {
  font-size: 32rpx;
  color: #333;
}

.setting-title {
  font-size: 30rpx;
  color: #333;
  font-weight: bold;
  margin-bottom: 10rpx;
}

.setting-value {
  font-size: 40rpx;
  color: #1890ff;
  font-weight: bold;
  margin-bottom: 20rpx;
}

.setting-desc {
  font-size: 24rpx;
  color: #999;
  margin-top: 10rpx;
}

.save-btn {
  margin-top: 30rpx;
  background: #1890ff;
  color: #fff;
  border-radius: 12rpx;
}
// pages/setting/setting.js
const huawei = require('../../utils/huawei.js');

Page({
  data: {
    smartMode: true,
    lightThreshold: 10,
    co2Limit: 1000,
    tvocLimit: 1000,
    nightLight: false,
    saving: false
  },

  onLoad() {
    // 从设备影子读取当前设置
    this.loadCurrentSettings();
  },

  loadCurrentSettings() {
    huawei.getDeviceShadow().then(shadow => {
      if (shadow.shadow && shadow.shadow.length > 0) {
        const desired = shadow.shadow[0].desired || {};
        const reported = shadow.shadow[0].reported || {};
        // 优先读desired(待生效配置),没有则读reported
        this.setData({
          lightThreshold: desired.light_threshold || reported.light_threshold || 10,
          co2Limit: desired.co2_alarm_limit || reported.co2_alarm_limit || 1000,
          tvocLimit: desired.tvoc_alarm_limit || reported.tvoc_alarm_limit || 1000,
          smartMode: (desired.smart_mode || reported.smart_mode || 1) === 1,
          nightLight: (reported.night_light || 0) === 1
        });
      }
    }).catch(err => {
      console.error(err);
    });
  },

  onSmartModeChange(e) {
    this.setData({ smartMode: e.detail.value });
  },

  onLightThresholdChange(e) {
    this.setData({ lightThreshold: e.detail.value });
  },

  onCo2LimitChange(e) {
    this.setData({ co2Limit: e.detail.value });
  },

  onTvocLimitChange(e) {
    this.setData({ tvocLimit: e.detail.value });
  },

  onNightLightChange(e) {
    this.setData({ nightLight: e.detail.value });
  },

  saveSettings() {
    this.setData({ saving: true });

    // 依次下发所有设置命令
    const promises = [
      huawei.sendCommand('set_light_threshold', {
        value: this.data.lightThreshold
      }),
      huawei.sendCommand('set_co2_alarm', {
        value: this.data.co2Limit
      }),
      huawei.sendCommand('set_tvoc_alarm', {
        value: this.data.tvocLimit
      }),
      huawei.sendCommand('set_smart_mode', {
        state: this.data.smartMode ? 1 : 0
      })
    ];

    // 手动控制小夜灯
    promises.push(huawei.sendCommand('control_night_light', {
      state: this.data.nightLight ? 1 : 0
    }));

    Promise.all(promises).then(() => {
      wx.showToast({ title: '保存成功', icon: 'success' });
    }).catch(err => {
      console.error(err);
      wx.showToast({ title: '保存失败', icon: 'none' });
    }).finally(() => {
      this.setData({ saving: false });
    });
  }
});

6.7 红外遥控页

红外遥控页模拟一个遥控器界面,可以选择设备、学习按键、发送红外码。

<!-- pages/ir/ir.wxml -->
<view class="container">
  <!-- 设备选择 -->
  <view class="device-tabs">
    <view
      wx:for="{{devices}}" wx:key="id"
      class="device-tab {{currentDevice === item.id ? 'active' : ''}}"
      bindtap="selectDevice" data-id="{{item.id}}"
    >
      {{item.name}}
    </view>
  </view>

  <!-- 遥控器按键区域 -->
  <view class="remote">
    <view class="remote-screen">
      <text class="remote-title">{{devices[currentDevice].name}}遥控器</text>
      <text wx:if="{{learning}}" class="learning-tip">
        请在{{countdown}}秒内按下遥控器按键...
      </text>
    </view>

    <!-- 按键网格 -->
    <view class="btn-grid">
      <view
        wx:for="{{devices[currentDevice].keys}}" wx:key="slot"
        class="remote-btn"
        bindtap="sendIR"
        bindlongpress="startLearn"
        data-slot="{{item.slot}}"
        data-name="{{item.name}}"
      >
        <text class="btn-name">{{item.name}}</text>
        <text wx:if="{{item.learned}}" class="btn-status learned">已学习</text>
        <text wx:else class="btn-status unlearned">未学习</text>
      </view>
    </view>
  </view>

  <!-- 提示 -->
  <view class="tips">
    <text class="tips-title">使用说明:</text>
    <text class="tips-item">• 短按按键:发送红外信号</text>
    <text class="tips-item">• 长按按键:进入学习模式</text>
    <text class="tips-item">• 学习时将遥控器对准设备,距离5-10cm</text>
  </view>
</view>
/* pages/ir/ir.wxss */
.container {
  padding: 20rpx;
  background: #f5f5f5;
  min-height: 100vh;
}

.device-tabs {
  display: flex;
  background: #fff;
  border-radius: 12rpx;
  padding: 8rpx;
  margin-bottom: 30rpx;
}

.device-tab {
  flex: 1;
  text-align: center;
  padding: 20rpx 0;
  border-radius: 8rpx;
  font-size: 28rpx;
  color: #666;
}

.device-tab.active {
  background: #1890ff;
  color: #fff;
}

.remote {
  background: #2c3e50;
  border-radius: 24rpx;
  padding: 30rpx;
  margin-bottom: 30rpx;
}

.remote-screen {
  background: #1a252f;
  border-radius: 12rpx;
  padding: 30rpx;
  margin-bottom: 30rpx;
  text-align: center;
}

.remote-title {
  color: #2ecc71;
  font-size: 32rpx;
  font-weight: bold;
}

.learning-tip {
  display: block;
  color: #e74c3c;
  font-size: 26rpx;
  margin-top: 10rpx;
}

.btn-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20rpx;
}

.remote-btn {
  background: #34495e;
  border-radius: 16rpx;
  padding: 30rpx 10rpx;
  text-align: center;
}

.remote-btn:active {
  background: #1890ff;
}

.btn-name {
  color: #ecf0f1;
  font-size: 28rpx;
  display: block;
  margin-bottom: 8rpx;
}

.btn-status {
  font-size: 20rpx;
}

.btn-status.learned {
  color: #2ecc71;
}

.btn-status.unlearned {
  color: #95a5a6;
}

.tips {
  background: #fff;
  border-radius: 12rpx;
  padding: 30rpx;
}

.tips-title {
  font-size: 28rpx;
  color: #333;
  font-weight: bold;
  display: block;
  margin-bottom: 16rpx;
}

.tips-item {
  font-size: 26rpx;
  color: #666;
  display: block;
  line-height: 1.8;
}
// pages/ir/ir.js
const huawei = require('../../utils/huawei.js');

Page({
  data: {
    currentDevice: 0,
    learning: false,
    countdown: 0,
    devices: [
      {
        id: 0,
        name: '空调',
        keys: [
          { slot: 0, name: '开关', learned: false },
          { slot: 1, name: '温度+', learned: false },
          { slot: 2, name: '温度-', learned: false },
          { slot: 3, name: '模式', learned: false },
          { slot: 4, name: '风速', learned: false },
          { slot: 5, name: '扫风', learned: false }
        ]
      },
      {
        id: 1,
        name: '风扇',
        keys: [
          { slot: 6, name: '开关', learned: false },
          { slot: 7, name: '调速', learned: false }
        ]
      }
    ]
  },

  onLoad() {
    // 从本地存储读取学习状态
    this.loadLearnStatus();
  },

  loadLearnStatus() {
    const status = wx.getStorageSync('ir_learn_status');
    if (status) {
      const devices = this.data.devices;
      status.forEach(s => {
        devices.forEach(dev => {
          dev.keys.forEach(key => {
            if (key.slot === s.slot) {
              key.learned = s.learned;
            }
          });
        });
      });
      this.setData({ devices });
    }
  },

  saveLearnStatus() {
    const status = [];
    this.data.devices.forEach(dev => {
      dev.keys.forEach(key => {
        status.push({ slot: key.slot, learned: key.learned });
      });
    });
    wx.setStorageSync('ir_learn_status', status);
  },

  selectDevice(e) {
    this.setData({ currentDevice: e.currentTarget.dataset.id });
  },

  // 短按发送红外
  sendIR(e) {
    const slot = e.currentTarget.dataset.slot;
    const name = e.currentTarget.dataset.name;

    // 检查是否已学习
    let learned = false;
    this.data.devices.forEach(dev => {
      dev.keys.forEach(key => {
        if (key.slot === slot) learned = key.learned;
      });
    });

    if (!learned) {
      wx.showToast({ title: '请先长按学习', icon: 'none' });
      return;
    }

    wx.showLoading({ title: '发送中...' });
    huawei.sendCommand('ir_send', { slot: slot }).then(() => {
      wx.hideLoading();
      wx.showToast({ title: name + ' 已发送', icon: 'success' });
    }).catch(() => {
      wx.hideLoading();
      wx.showToast({ title: '发送失败', icon: 'none' });
    });
  },

  // 长按学习
  startLearn(e) {
    const slot = e.currentTarget.dataset.slot;
    const name = e.currentTarget.dataset.name;

    wx.showModal({
      title: '红外学习',
      content: `即将学习"${name}"按键,请将遥控器对准设备,按确定后5秒内按下遥控器按键`,
      success: (res) => {
        if (res.confirm) {
          this.doLearn(slot);
        }
      }
    });
  },

  doLearn(slot) {
    this.setData({ learning: true, countdown: 5 });

    // 下发学习命令
    huawei.sendCommand('ir_learn', { slot: slot }).then(() => {
      // 倒计时
      const timer = setInterval(() => {
        let cd = this.data.countdown - 1;
        this.setData({ countdown: cd });
        if (cd <= 0) {
          clearInterval(timer);
          this.setData({ learning: false });
          // 标记为已学习
          const devices = this.data.devices;
          devices.forEach(dev => {
            dev.keys.forEach(key => {
              if (key.slot === slot) key.learned = true;
            });
          });
          this.setData({ devices });
          this.saveLearnStatus();
          wx.showToast({ title: '学习成功', icon: 'success' });
        }
      }, 1000);
    }).catch(() => {
      this.setData({ learning: false });
      wx.showToast({ title: '学习失败', icon: 'none' });
    });
  }
});

6.8 历史数据页

历史数据页可以展示温湿度变化趋势图。需要开通华为云数据转发服务,把设备数据转发到时序数据库或者自己的服务器。这里给一个简单的图表页面框架,用wx-charts组件画折线图。

<!-- pages/history/history.wxml -->
<view class="container">
  <view class="chart-card">
    <text class="chart-title">温度趋势(24小时)</text>
    <canvas canvas-id="tempChart" class="chart-canvas"></canvas>
  </view>
  <view class="chart-card">
    <text class="chart-title">湿度趋势(24小时)</text>
    <canvas canvas-id="humiChart" class="chart-canvas"></canvas>
  </view>
</view>

历史数据功能涉及数据存储和转发服务,篇幅有限不展开了。如果只是做数据展示,可以用云函数定时读取设备影子存到云数据库,再查询出来画图。

6.9 小程序调试注意事项

域名白名单:小程序正式发布需要在微信公众平台配置request合法域名。开发阶段可以在开发者工具里勾选"不校验合法域名"。2.

华为云API地址:确认endpoint和projectId正确,不同区域的地址不一样。3.

Token获取:确认IAM用户名、密码、域名正确。华为云的账号名和IAM用户名可能不一样,在"统一身份认证服务"里查看。4.

命令下发超时:华为云命令下发默认超时5秒,设备必须在线才能收到。如果设备离线,命令会下发失败。5.

真机测试:开发者工具里能正常请求后,用手机预览测试。注意手机和设备要能访问华为云API(公网可访问,不需要同一局域网)。


七、系统测试与效果

代码写完之后,不要急着组装,分模块逐一测试,确定每个部分都正常工作再整体联调。

7.1 传感器数据测试

先只接STM32最小系统板加三个传感器,不接WiFi和其他模块。下载程序后看OLED第一页是否有数据显示。

测试SHT30:

    • OLED第一行显示温度,第二行显示湿度• 用手握住SHT30模块,温度应该慢慢上升• 对着模块哈气,湿度应该明显上升• 如果显示0或者乱码,检查I2C接线和地址

测试BH1750:

    • • 用手机手电筒照BH1750,光照数值应该变大• 用手捂住模块,数值应该变小接近0• 注意BH1750需要初始化,代码里

BH1750_Init()

测试SGP30:

    • 上电后前15秒是预热期,CO2显示400,TVOC显示0,这是正常的• 预热完成后,对着模块哈气,CO2和TVOC都会升高• SGP30读数会随环境自动校准,前几小时波动大是正常的,长期运行后会稳定

[图片:OLED第一页显示环境数据实拍]

如果三个传感器中有一个读不到数据:

    1. 用万用表量VCC和GND之间电压,确认供电正常2. 用逻辑分析仪抓I2C波形,看STM32有没有发出时钟和地址3. 确认模块地址正确:SHT30=0x44,BH1750=0x23,SGP30=0x584. 检查I2C上拉电阻,SDA和SCL空闲时应该是高电平(3.3V)

7.2 OLED三页显示测试

程序下载后,OLED默认显示第一页。按K1按键(PB12),应该切换到第二页;再按切换到第三页;再按回到第一页,循环切换。

第一页(环境数据页):

环境温度  25C
环境湿度  60%
二氧化碳  450PPM
挥发气体  120ppm

[图片:OLED第一页实拍-环境温度湿度CO2 TVOC]

第二页(状态控制页):

人体监测  无人
智能模式  开启
亮度下限  10Lux
起夜灯    关闭

[图片:OLED第二页实拍-人体监测智能模式亮度下限起夜灯]

第三页(报警上限页):

报警上限设置
二氧化碳  1000ppm
TVOC气体  1000ppm

[图片:OLED第三页实拍-报警上限设置]

如果中文显示乱码或者缺字,检查中文字库数据是否正确。用PCtoLCD2002重新取模,注意取模设置要跟代码里的解析方式一致:阴码、逐行式、顺向。

7.3 人体感应测试

SR602接上后,在模块前方挥手或者走过,OLED第二页的"人体监测"应该从"无人"变成"有人"。人离开后延时几秒变回"无人"。

SR602上有两个电位器,可以调节灵敏度和延时时间:

    • 灵敏度电位器:顺时针增加感应距离(最大约3米)• 延时电位器:顺时针增加输出延时(约2秒到几分钟)

测试活动时长累计功能:人在前面待几分钟,看华为云设备影子里human_duration数值是否增加。

[图片:SR602人体感应模块特写]

7.4 小夜灯自动控制测试

小夜灯自动开灯的条件:环境亮度低于亮度下限 AND 检测到有人。

测试方法:

    1. 先在小程序设置亮度下限为10Lux2. 用手捂住BH1750模拟低亮度环境(光照降到10以下)3. 同时在SR602前面挥手(检测到有人)4. 两个条件同时满足,起夜灯(PA8)应该自动亮起5. 手拿开BH1750,亮度恢复,灯延时后自动关闭

用万用表量PA8电压,灯亮时应该是高电平(3.3V),灯灭时是低电平。如果用PWM调光,可以用示波器看PA8的PWM波形。

按K3按键(PB14)可以手动测试起夜灯,按一下亮,再按一下灭。

[图片:小夜灯自动亮起的实拍照片]

7.5 报警功能测试

报警条件:CO2超过CO2报警上限 OR TVOC超过TVOC报警上限。

测试方法:

    1. 1. 先在小程序设置CO2报警上限为500ppm(设低一点方便测试)2. 对着SGP30模块哈气,让CO2浓度升高3. 当CO2超过500ppm时:

      • • 蜂鸣器(PA15)开始间歇鸣叫• 报警灯(PC15)开始闪烁• 小程序首页CO2卡片显示"超标"红色标签

4. 等CO2降回500以下,蜂鸣器和报警灯停止

按K2按键(PB13)可以测试蜂鸣器是否正常,按一下响一声。

[图片:报警时蜂鸣器和报警灯工作的实拍]

7.6 WiFi和华为云连接测试

烧录完整程序后,打开华为云控制台,进入设备详情页。如果设备状态变成"在线",说明WiFi和MQTT连接成功。

如果设备一直不在线:

    1. 确认WiFi名称和密码正确,注意大小写2. 确认路由器是2.4G频段,AI-WB2-01S不支持5G3. 确认MQTT Broker地址、端口、设备ID、密码正确4. 用USB转串口接模组的TXD/RXD,用串口调试助手看AT指令交互过程5. 检查MQTT密码计算是否正确(有些AT固件会自动算,有些需要自己算)

设备在线后,在"设备影子"页面能看到STM32上报的数据:

[图片:华为云设备影子页面-显示实时上报的温度湿度光照等数据]

7.7 数据上报测试

设备在线后,每隔5秒上报一次数据。在设备影子页面刷新,能看到数据更新。上传指示灯(PC14)每次上报时会闪一下。

上报的JSON格式:

{
  "services": [{
    "service_id": "env_monitor",
    "properties": {
      "temperature": 25.3,
      "humidity": 60.2,
      "light": 120,
      "tvoc": 344,
      "eco2": 450,
      "human_present": 1,
      "human_duration": 120,
      "night_light": 0
    }
  }]
}

在华为云"设备详情→运行状态"页面可以看到属性历史曲线:

[图片:华为云运行状态页面-温湿度历史曲线图]

7.8 命令下发测试

用华为云控制台的"命令下发"功能测试:

    1. 进入设备详情,点"命令下发"2. 选择服务:env_monitor3. 选择命令:set_light_threshold4. 参数填:{"value": 5}5. 点发送

设备收到命令后,亮度下限变成5Lux。OLED第三页显示的数值会更新(第三页显示的是报警上限,亮度下限在第二页)。

也可以直接测试控制小夜灯:

    • 命令:control_night_light• 参数:{"state": 1}• 发送后小夜灯应该亮起来

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

7.9 红外学习与控制测试

    1. 给红外模块接上5V电源和串口(PB10/PB11)2. 用USB转串口模块先在电脑上测试模块是否正常工作3. 在STM32上运行完整程序4. 在小程序红外遥控页长按"开关"按键,弹出学习提示5. 点确定后5秒内,拿空调遥控器对准模块的接收头,按开关键6. 小程序提示"学习成功"7. 短按"开关"按键,模块发射红外信号,空调应该响应

如果空调没反应:

    • 确认遥控器对准模块接收头,距离5到10厘米• 学习时模块指示灯是否闪烁• 用手机摄像头对着红外发射管,按发送时能看到紫色闪光(人眼看不到红外,摄像头能看到)• 检查模块发射管是否朝向家电方向

[图片:红外学习操作实拍-遥控器对准模块]

[图片:小程序红外遥控页学习成功截图]

7.10 小程序远程控制测试

    1. 手机打开微信,进入小程序2. 首页能看到实时数据,每5秒自动刷新3. 进入设置页,修改亮度下限和报警上限,点保存4. 看OLED显示是否更新5. 进入遥控页,学习并发送红外码6. 关掉手机WiFi用4G网络测试,确认在外网也能控制

[图片:手机小程序首页截图]

[图片:手机小程序设置页截图]

[图片:手机小程序遥控页截图]

7.11 整机联调

所有模块单独测试通过后,进行整机联调:

    1. 把所有模块接到STM32上,检查接线无误2. 上电,观察运行指示灯(PC13)是否闪烁3. 等15秒SGP30预热完成,看OLED第一页数据4. 按K1翻页确认3页显示正常5. 确认华为云设备在线6. 用小程序查看数据7. 测试小夜灯自动控制8. 测试报警功能9. 测试红外学习和发射

连续运行24小时,观察系统稳定性。检查是否有死机、重启、数据断连等问题。如果一切正常,这个项目就算完成了。

[图片:整机系统运行实拍-所有模块连接正常工作]

[图片:整机装外壳后的成品照片]

7.12 常见问题排查手册

把调试过程中遇到的典型问题整理成一个排查手册,遇到问题按表查。

现象 可能原因 排查方法
OLED完全不亮 供电问题、I2C地址错、初始化失败 量VCC电压3.3V,用逻辑分析仪看I2C波形,确认地址0x3C
OLED亮但花屏 字库数据错、I2C时序太快 降低I2C延时,检查字库取模设置
SHT30读数固定为0 I2C通信失败 检查地址0x44,量SDA/SCL上拉电压
SHT30读数跳动大 接线过长、电源纹波 缩短I2C线,加0.1uF去耦电容
BH1750读数一直65535 未初始化或测量命令没发成功 确认发送了0x01和0x10命令
SGP30读数不变 未预热或I2C通信失败 等15秒预热,检查地址0x58和CRC
人体感应一直触发 灵敏度太高、模块正对热源 调低SR602灵敏度电位器,避开空调出风口
人体感应不触发 距离太远、供电不足 量VCC电压5V,调大灵敏度
WiFi连不上路由器 密码错、5G频段、信号弱 确认2.4G频段,靠近路由器,用串口看AT返回
MQTT连接失败 设备ID/密钥错、Broker地址错 在华为云控制台复制参数,检查密码计算
MQTT频繁掉线 供电不足、心跳超时 换大电流3.3V LDO,检查MQTT KEEPALIVE设置
小程序获取数据失败 Token过期、域名未配置 看开发者工具Console报错,配置request合法域名
命令下发无反应 设备离线、未订阅Topic 确认设备在线,确认订阅了commands Topic
红外学习失败 距离太远、接收头方向不对 遥控器距模块5-10cm,对准接收头
红外发射无反应 码值没存对、发射管方向不对 用手机摄像头看发射管有没有闪光
蜂鸣器不响 PA15未释放JTAG、触发电平不对 调用__HAL_AFIO_REMAP_SWJ_NOJTAG,试高低电平
小夜灯不亮 PWM没启动、占空比为0 确认HAL_TIM_PWM_Start调用,检查CCR值
FreeRTOS死机 栈溢出、优先级反转、死锁 开栈溢出检测,增大任务栈,检查互斥锁使用

7.13 用串口调试的技巧

调试STM32程序最方便的工具是串口。代码里虽然用了USART2和USART3接WiFi和红外模块,但STM32F103C8T6还有USART1(PA9/PA10)可以用来打调试日志。

在CubeMX里把USART1配置成异步串口,波特率115200。然后重定向printf到USART1:

/* 在usart.c中添加 */
#include "stdio.h"

int fputc(int ch, FILE *f)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100);
    return ch;
}

这样就能用printf("temperature=%.1frn", temp);在串口调试助手里看变量了。Keil里需要勾选Target选项的Use MicroLIB,否则printf可能不工作。

调试时建议打印以下关键信息:

    • 每个传感器读取的原始值和换算后的值• I2C通信的ACK状态(0成功1失败)• AT指令发送和接收的完整内容• MQTT连接状态变化• 按键事件和命令解析结果• FreeRTOS任务切换信息(configUSE_TRACE_FACILITY)

用USB转TTL模块(CH340或者CP2102)接USART1的TX到模块RX,共地。打开串口调试助手,波特率115200,就能看到调试输出。

7.14 用逻辑分析仪抓I2C波形

如果传感器读不到数据,串口打印又看不出问题,逻辑分析仪是最好的调试工具。淘宝上十几块钱的USB逻辑分析仪(8通道24MHz)就够用,配合sigrok或者Saleae Logic软件。

抓取I2C波形的方法:

    1. 把逻辑分析仪的CH0接SDA(PA6),CH1接SCL(PA7)2. GND接开发板GND3. 在软件里设置采样率1MHz,开始采集4. 触发条件设为SCL下降沿5. 看波形确认:起始条件、地址字节、ACK位、数据字节、停止条件

正常的I2C读取SHT30的流程:

    • START → 0x88(地址0x44写)→ ACK → 0x2C → ACK → 0x06 → ACK → STOP• 等20ms• START → 0x89(地址0x44读)→ ACK → 读6字节 → NACK → STOP

如果地址字节后没有ACK(从机没拉低SDA),说明地址错或者模块没工作。如果SCL没有脉冲,说明STM32没有发时钟,检查GPIO配置。

7.15 焊接和组装注意事项

如果自己画PCB打样回来焊接,注意以下几点:

焊接顺序:先焊矮的器件(电阻电容),再焊高的(排针、端子),最后焊模块。芯片LQFP48用拖焊:先在一个焊盘上锡固定芯片,然后所有引脚上锡,用吸锡带吸掉多余的焊锡,检查有没有桥接。

检查短路:焊完后用万用表蜂鸣档量3.3V和GND之间有没有短路,5V和GND之间有没有短路。确认没短路再接电源。

先测电源:接电后先不插芯片和模块,量3.3V和5V电压是否正常。确认电源没问题再插芯片。

模块用排针排母:传感器模块和WiFi模组不要直接焊死在PCB上,用排母座,方便更换和调试。

留测试点:PCB上给I2C的SDA/SCL、串口TX/RX留测试点或者排针,方便接逻辑分析仪和USB转串口。

天线区域:WiFi模组的天线下方不要铺铜,不要走信号线,否则影响信号。

7.16 功耗和稳定性测试

如果长期运行,需要关注稳定性。做了以下测试:

连续运行测试:设备连续运行72小时,每5秒上报一次数据,没有出现死机、重启、数据断连。运行灯正常闪烁,华为云数据连续。

WiFi断连恢复测试:拔掉路由器电源1分钟再插上,设备检测到WiFi断开后自动重连,MQTT重新连接成功,数据恢复上报。重连过程大约10到20秒。

传感器稳定性测试:在同一环境下连续读取24小时,温度波动在±0.3℃以内,湿度波动在±2%以内。SGP30在稳定环境下CO2读数在400到500ppm之间,TVOC在0到50ppb之间,属于正常范围。

报警响应测试:对着SGP30哈气到CO2超过1000ppm,蜂鸣器在2秒内开始鸣叫,报警灯同步闪烁。停止哈气后等读数回落到1000以下,报警在3秒内解除(SGP30读数本身有延迟)。

小夜灯响应测试:用手捂住BH1750同时在SR602前挥手,小夜灯在1秒内亮起。手拿开后,亮度恢复,小夜灯在1秒内关闭。

整体稳定性满足日常使用需求。如果用在更重要的场景,建议加上独立看门狗和异常日志记录功能。

7.17 成本核算

整个项目的物料成本大致如下(仅供参考,价格随市场波动):

器件 参考价格
STM32F103C8T6最小系统板 12元
SHT30模块 8元
BH1750模块 5元
SGP30模块 25元
SR602人体感应 5元
AI-WB2-01S WiFi模组 12元
红外学习模组 15元
0.96寸OLED 8元
蜂鸣器模块 2元
LED起夜灯板 3元
按键、LED、电阻电容等 5元
AMS1117电源模块 2元
PCB打样(5片) 5元
排针排母杜邦线 5元
合计 约112元

如果自己画PCB把芯片直接贴上去,成本还能再低一些。对比市面上几百上千的商用环境监测仪,自己做性价比很高,还能根据需求定制功能。

7.18 数据对比验证

为了验证传感器读数的准确性,找了几个参考设备做对比:

温度对比: 用水银温度计和SHT30同时测量室内温度。SHT30读数25.3℃,水银温度计25.5℃,偏差0.2℃,在SHT30的精度范围内。室外温差大的情况下偏差可能稍大,但室内用完全够用。

湿度对比: 用一个家用电子温湿度计做参考。SHT30读数60%RH,参考设备62%RH,偏差2%RH,也在标称精度内。湿度受通风影响较大,不同位置读数会有差异。

光照对比: 用手机的光线传感器APP做粗略对比。室内开灯状态下BH1750读到180Lux,手机传感器读到165Lux,趋势一致。光照传感器受角度和入射方向影响大,具体数值有差异正常,重点是相对变化趋势。

CO2对比: SGP30是eCO2(等效CO2),不是真实的NDIR CO2传感器测量值。它根据VOC浓度推算CO2,在有人员活动的环境下趋势是对的,但绝对值跟专业仪器有偏差。日常监测看趋势够用,不要当实验室级别的数据看。如果需要精确CO2测量,建议用NDIR传感器如SCD30或者MH-Z19B。

TVOC对比: TVOC没有便宜的参考设备,SGP30的读数单位是ppb。一般认为室内TVOC低于600ppb属于可接受范围。SGP30对酒精、香水、油烟都很敏感,刚喷完香水读数会飙升,这个特性用来做空气质量报警倒是很合适。

7.19 使用体验

系统装好放在卧室用了一段时间,说几个实际体验:

小夜灯功能很实用。晚上起夜,脚一落地灯就亮了,不用摸黑找开关。亮度适中不刺眼,人走了自动灭,比那种常亮的小夜灯省电。2.

空气质量数据有意思。能直观看到关窗一晚上CO2浓度升高,开窗通风后很快降下来。冬天开暖气不愿意开窗,看到CO2到1500ppm以上就知道该通风了。3.

红外控制方便。手机控制空调开关和温度,不用到处找遥控器。学习一次之后一直存在Flash里,断电也不丢。4.

小程序刷新偶尔慢。华为云API有时候响应慢一点,数据刷新要等一两秒。可以在小程序里加本地缓存,先显示上次的数据,新数据到了再更新。5.

蜂鸣器报警有用但也烦。SGP30有时候会误报,比如喷个酒精消毒它就叫。可以把报警上限设高一点,或者加持续时间判断(超标持续30秒再报警)。

7.20 项目迭代记录

简单记录一下开发过程中的版本迭代:

v1.0:只实现了SHT30温湿度加OLED显示,串口打印数据•

v1.1:加入BH1750和SR602,实现小夜灯自动控制•

v1.2:加入SGP30,实现空气质量监测和蜂鸣器报警•

v1.3:加入AI-WB2-01S WiFi模组,数据上报华为云•

v1.4:加入红外学习模块,实现远程红外控制•

v1.5:微信小程序开发完成,数据展示和远程控制•

v1.6:优化代码结构,加入FreeRTOS多任务,修复若干bug•

v2.0:重新画PCB,所有模块集成到一块板上,加外壳

每加一个功能模块就单独测试,测试通过再加下一个。这样出了问题知道是哪个模块引起的,不会一堆bug混在一起没法查。


八、总结

8.1 项目回顾

这个项目从硬件选型到软件开发,涉及的技术栈比较广,做完一遍之后能学到不少东西。

硬件方面:

    • 学会了STM32多外设协同工作,两组I2C分别挂传感器和OLED,两个串口分别接WiFi和红外模块• 掌握了SHT30、BH1750、SGP30三个I2C传感器的驱动编写• 学会了用成品模块简化开发,红外学习模块通过串口通信,不用自己研究各种红外协议• 用嘉立创EDA画原理图和PCB,了解了布局布线的注意事项

软件方面:

    • 用FreeRTOS多任务管理不同功能模块,任务间用互斥锁和队列通信• 用软件模拟I2C时序,理解了I2C协议的起始、停止、ACK、读写时序• 通过AT指令驱动WiFi模组连接MQTT服务器,掌握了华为云IoTDA的接入流程• 实现了JSON数据构造和命令解析,设备和云端双向通信• 用STM32内部Flash存储红外码值,了解了Flash擦写操作

云端和小程序方面:

    • 学会了华为云物联网平台的产品创建、模型定义、设备注册• 掌握了MQTT协议的Topic订阅和发布• 用微信小程序调用华为云API,实现了数据展示和远程控制• 小程序页面布局、事件处理、网络请求等基础开发

8.2 与非AI对开发效率的帮助

回头看整个开发过程,与非AI(https://url.eefocus.com/4e73)的数据查询和方案推荐确实在几个关键环节帮了大忙,做硬件的朋友可以试试。

项目初期找方案: 在与非AI搜索"STM32 环境监测 MQTT 华为云",很快找到了参考方案和电路设计。近4万个电路方案和参考设计的数据支撑,比自己在网上东找西找效率高太多。以前做项目光确定技术路线就要花好几天查资料,现在跟AI对话几轮就能把思路和方案定下来。

器件选型查手册: 查SHT30、SGP30、BH1750等等的数据手册非常方便,不用在各个厂家官网到处注册下载。最实用的是替代料建议,某个件缺货或者价格太高,很快能找到pin to pin替代。甚至还有ECAD模型可以直接导入EDA软件,画原理图的时候能节省自己画封装的时间。

8.3 踩过的坑

项目过程中踩了不少坑,列出来给大家参考:

PA15不能用:PA15默认是JTAG的JTDI引脚,初始化时必须关闭JTAG保留SWD,否则PA15输出不受控制。代码里加了__HAL_AFIO_REMAP_SWJ_NOJTAG()解决。2.

I2C总线冲突:一开始把OLED也挂在传感器那组I2C上,传感器采集和OLED刷新同时操作总线会冲突。后来分开两组I2C解决。3.

SGP30预热:SGP30上电前15秒读数不准,代码里加了15秒延时才开始读取。基线校准也很重要,建议运行12小时后保存基线到Flash。4.

WiFi供电不足:AI-WB2-01S发射时电流峰值大,一开始用USB转串口的3.3V供电反复重启,换成AMS1117-3.3就好了。5.

BH1750首次读数为0:BH1750上电后发送测量命令,第一次读取前需要等待至少180ms,否则读到0。6.

OLED中文字库:中文字模取模设置必须跟代码解析方式一致,否则显示乱码。PCtoLCD2002用阴码、逐行式、顺向。7.

Flash写入对齐:STM32 Flash写入必须16位对齐,不能逐字节写。写入前必须擦除整页。8.

华为云MQTT密码:MQTT连接需要HMAC-SHA256计算密码,不同AT固件处理方式不同,有的自动算有的手动算,要看模组手册。

8.4 扩展方向

这个项目还有很多可以扩展的地方:

功能扩展:

    • 增加PM2.5传感器(PMS5003,串口输出)• 增加甲醛传感器(ZE08-CH2O,串口输出)• 增加烟雾报警器和燃气泄漏检测• 增加温湿度自动控制,接继电器控制加湿器、空调• 加RTC时钟实现定时任务,比如定时开关小夜灯• 加入OTA远程升级功能,不用拆机就能更新固件

云端扩展:

    • 用华为云函数工作流(FunctionGraph)处理数据转发• 数据存入InfluxDB时序数据库,做更丰富的历史图表• 配置华为云规则引擎,CO2超标时自动发微信/短信通知• 接入华为云智能语音服务,用语音控制设备

小程序扩展:

    • 增加用户登录和多设备管理• 数据分享功能,家人一起看• 报警消息推送(微信订阅消息)• 增加场景模式,比如"回家模式"一键开灯开空调• 做一个平板版界面,挂在墙上当智能家居中控

硬件扩展:

    • PCB重新设计,把所有模块集成到一块板上• 加锂电池和充电管理,做成无线便携设备• 3D打印外壳,做成成品• 加蜂鸣器播放简单提示音(无源蜂鸣器加PWM)

8.5 结语

这个项目从确定方案到调试完成,前后花了大约两周的业余时间。过程中查了不少资料,也踩了不少坑,但看到系统跑起来的那一刻,所有的付出都值得。

写这篇文章的目的是把整个过程完整记录下来,让0基础的读者也能跟着做出来。如果你按照文章一步步来,应该能做出一个功能完整的室内环境监测加红外遥控物联网系统。如果中间遇到问题,大概率在文章里能找到答案。实在解决不了的,欢迎来微信公众号"DS小龙哥嵌入式技术资讯"找交流。

做嵌入式开发,动手比光看不练强一百倍。板子焊起来,代码写起来,bug调起来,做着做着就会了。

感谢你看到这里,祝大家都能做出自己的物联网项目。

相关推荐