一、前言
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。
- • 杜邦线买公对母、母对母、公对公各一包,长度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小很多,适合做紧凑型设计。
主要参数:
接线很简单,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的好处:
调试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_Connect、WiFi_MQTT_Subscribe、WiFi_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. 拼接字符串:
{timestamp}_{device_id}
-
- ,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电源。
如果一定要电池供电,可以考虑:
但这个项目功能比较多,持续监测和联网的话电池不耐用,建议直接接电源。
五、万能红外遥控功能详解
红外遥控是这个项目的一个亮点。市面上绝大多数家电遥控器都是红外的,空调、电视、风扇、投影仪……如果能把这些遥控器的功能集中到手机上,用微信小程序控制,会方便很多。
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_SaveToFlash和IR_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 红外协议小知识
虽然用成品模块不用自己解析红外协议,但了解一些基础原理有助于调试。
红外遥控通信的基本原理:
常见的红外协议:
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. 先在小程序设置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超标时自动发微信/短信通知• 接入华为云智能语音服务,用语音控制设备
小程序扩展:
- • 增加用户登录和多设备管理• 数据分享功能,家人一起看• 报警消息推送(微信订阅消息)• 增加场景模式,比如"回家模式"一键开灯开空调• 做一个平板版界面,挂在墙上当智能家居中控
硬件扩展:
8.5 结语
这个项目从确定方案到调试完成,前后花了大约两周的业余时间。过程中查了不少资料,也踩了不少坑,但看到系统跑起来的那一刻,所有的付出都值得。
写这篇文章的目的是把整个过程完整记录下来,让0基础的读者也能跟着做出来。如果你按照文章一步步来,应该能做出一个功能完整的室内环境监测加红外遥控物联网系统。如果中间遇到问题,大概率在文章里能找到答案。实在解决不了的,欢迎来微信公众号"DS小龙哥嵌入式技术资讯"找交流。
做嵌入式开发,动手比光看不练强一百倍。板子焊起来,代码写起来,bug调起来,做着做着就会了。
感谢你看到这里,祝大家都能做出自己的物联网项目。
150