1. 从一个"温度到了风扇自己转"的需求说起
夏天最烦的事情不是热,是热的时候人不在家、或者半夜懒得起来开风扇。我最初做这个项目的动机特别朴素:家里有个小书房,朝西,下午三四点太阳一晒,温度能比客厅高五六度,但人往往在客厅或者出门了,等想起来的时候书房已经闷成蒸笼。市面上带温控的智能插座不少,但要么依赖厂商云、要么联动逻辑死板,要么就是"温度高于X就开"这种一刀切,完全不考虑湿度、变化趋势和滞后。
MQTT Climate-Triggered Cooling这个标题拆开看,核心就三件事:MQTT负责消息的订阅与发布,Climate指温湿度这类环境量的采集与判断,Triggered Cooling是最终的执行动作——触发制冷设备。它不是一个具体的成品,而是一套"环境数据驱动设备动作"的自动化范式。适合谁来参考?三类人:一是刚接触 MQTT、想找个真实场景练手的嵌入式或物联网初学者;二是家里已经有若干智能设备、想自己写联动逻辑的折腾党;三是做小型机房、温室、宠物房、酒柜这类需要环境保障场景的从业者,逻辑是通用的,只是阈值和执行器不同。
我前后迭代了三版,第一版用现成的云平台规则引擎,第二版改成自己写订阅端做判断,第三版才把滞后、防抖、失联保护这些工程细节补齐。下面我把整套思路、选型理由、踩过的坑和最终跑稳的方案完整讲一遍。你不需要完全照抄我的硬件,逻辑拿走,换成你自己的传感器和执行器一样能用。
2. 为什么把判断逻辑放在本地而不是云平台规则里
2.1 云平台规则引擎的便利与它的天花板
一开始我用的是某云平台的"设备联动"功能,传感器上报温度,平台侧配置一条规则:温度大于28度,打开插座。五分钟就配好了,看起来很美。但用了两周问题就来了。第一,规则引擎的判断周期是固定的,通常最短也要一分钟轮询一次,而且它看的是"最近一次上报值",如果传感器上报间隔是30秒,那平台拿到的数据其实是有延迟的。第二,我想加"连续三次超过28度才触发"这种防抖逻辑,平台规则里根本表达不了,只能靠"持续时间"这种粗糙条件凑合。第三,也是最要命的,一旦家里网络抖动或者平台侧服务波动,规则就静默失效了,你根本不知道它没执行。
这里就引出一个关键判断:环境触发的实时性和可靠性要求,决定了判断逻辑应该放在离数据源最近的地方。MQTT 的发布订阅模型天然适合这件事——传感器只管往一个主题发消息,判断端只管订阅这个主题,两者解耦,谁也不依赖谁的实现。你把判断端跑在本地一台常开的小主机(树莓派、旧笔记本、软路由、甚至一块 ESP32)上,网络断了它照样能基于最后状态做保守处理,这比云端的"黑盒规则"可控得多。
2.2 MQTT 在这个场景里到底扮演什么角色
很多人一上来就纠结"用 MQTT 还是 HTTP",其实这俩解决的不是一回事。HTTP 是请求-响应,你要主动去问传感器"现在多少度",问一次答一次;MQTT 是发布-订阅,传感器有变化就主动推,订阅方被动接收。环境监测这种场景,数据是周期性、单向、多消费者的——温度数据既要给制冷逻辑用,又要给手机 App 显示,还要存进数据库画曲线。用 HTTP 你得轮询三次,用 MQTT 一次发布三个订阅者各取所需。
具体到主题设计,我建议按home/room/sensor/type这种层级来,比如home/study/climate/temperature和home/study/climate/humidity。层级化的好处是订阅时可以用通配符,home/study/climate/#一次订阅该房间所有环境量,将来加个co2或者lux不用改订阅端代码。这里有个新手常犯的错:主题名用中文或者带空格,某些客户端会出问题,老老实实用小写英文加斜杠。
2.3 判断端放本地后,谁来做"大脑"
判断端我最终选的是 Python 脚本跑在树莓派上,用paho-mqtt这个库。为什么不用 Node.js 或者 Go?因为 Python 的生态在数据处理和快速迭代上最省事,paho-mqtt的 API 也足够简单,一个on_message回调就能接住所有消息。如果你更熟悉 JavaScript,mqtt.js一样能做,逻辑完全一致。核心不在于语言,而在于状态机怎么设计——这是整个项目真正的技术含量所在,下一节展开。
3. 温度触发的核心不是"大于28就开",而是状态机
3.1 单阈值判断为什么一定会抖动
假设你设"温度大于28度开风扇,小于28度关风扇"。现实是温度在28度附近会来回跳:27.9、28.1、27.8、28.2……风扇就会疯狂开关,继电器咔咔响,压缩机(如果是空调)更是会被搞坏。这就是阈值抖动,是所有基于模拟量的控制都要面对的第一个问题。
解决办法是引入回差(Hysteresis),也叫滞回区间。开和关用两个不同的阈值:温度升到28度才开,但要降到26度才关。中间这2度就是缓冲区,温度在28和26之间波动时,风扇保持当前状态不变。这个2度怎么定?取决于你的执行器惯性和环境热惯性。风扇这种即开即停的,1到2度够了;空调因为制冷有延迟、停机后余冷还在,回差要设大一点,3到4度比较稳。我书房用的是循环扇,回差设了1.5度,实测很稳。
3.2 用代码把状态机写清楚
下面是我判断端的核心逻辑骨架,用 Python 写的,注释里标了每个设计点的意图:
import paho.mqtt.client as mqtt import time # 阈值配置:开阈值和关阈值分开,形成回差 TEMP_ON = 28.0 # 高于此值触发制冷 TEMP_OFF = 26.5 # 低于此值停止制冷 HYSTERESIS = TEMP_ON - TEMP_OFF # 防抖:连续N次超阈值才动作,避免单次异常值误触发 DEBOUNCE_COUNT = 3 class ClimateController: def __init__(self): self.current_temp = None self.cooling_on = False self.over_count = 0 # 连续超上限计数 self.under_count = 0 # 连续低于下限计数 self.last_msg_time = time.time() def on_temperature(self, temp): self.current_temp = temp self.last_msg_time = time.time() if not self.cooling_on: # 当前是关的,判断要不要开 if temp > TEMP_ON: self.over_count += 1 if self.over_count >= DEBOUNCE_COUNT: self.turn_on() self.over_count = 0 else: self.over_count = 0 # 没超就清零,必须是"连续" else: # 当前是开的,判断要不要关 if temp < TEMP_OFF: self.under_count += 1 if self.under_count >= DEBOUNCE_COUNT: self.turn_off() self.under_count = 0 else: self.under_count = 0 def turn_on(self): self.cooling_on = True publish_command("ON") print(f"[{time.strftime('%H:%M:%S')}] 触发制冷,当前温度 {self.current_temp}") def turn_off(self): self.cooling_on = False publish_command("OFF") print(f"[{time.strftime('%H:%M:%S')}] 停止制冷,当前温度 {self.current_temp}")这段代码里有两个容易被忽略但极其重要的点。第一,over_count和under_count在条件不满足时必须清零,否则就不是"连续N次"而是"累计N次",防抖就失效了。第二,开和关的判断是互斥的,用if not self.cooling_on / else分开,避免同一时刻既想开又想关的逻辑冲突。
3.3 防抖次数和上报频率要匹配
DEBOUNCE_COUNT = 3意味着要连续三次超阈值才动作。如果你的传感器每30秒上报一次,那就是要持续90秒高温才开风扇,这个响应速度对书房场景可以接受,但对机房这种要求快速响应的就太慢了。反过来,如果传感器每2秒上报一次,3次才6秒,防抖效果又太弱,一个瞬时热源飘过就触发了。
我的经验是:防抖窗口的总时长控制在1到3分钟比较合理。算法是DEBOUNCE_COUNT × 上报间隔。30秒上报配3次是90秒,5秒上报配12次是60秒,你按这个公式调。另外传感器本身最好在固件侧做一次滑动平均,把原始ADC读数的毛刺先滤掉,这样上报出来的值本身就干净,判断端压力小很多。
4. 从传感器到执行器:整条链路的搭建细节
4.1 传感器选型与数据质量
我用的是 DHT22 和 SHT30 两种都试过。DHT22 便宜,但精度一般(温度±0.5度,湿度±2%到5%),而且采样频率不能太高,官方建议不低于2秒一次,否则读数会漂。SHT30 贵一些,但精度高(温度±0.3度)、I2C接口、响应快,长期稳定性也好。如果你只是做风扇联动,DHT22 够用;如果要做精密环境控制,直接上 SHT30 或者 BME280(还能顺便测气压)。
这里有个血泪教训:DHT22 千万别接在发热元件旁边。我第一版把传感器和 ESP32 主板挤在一个小盒子里,结果测出来的温度永远比实际高3度,因为主板自己发热。后来把传感器用延长线引出来,离主板至少10厘米,数据立刻就准了。传感器要放在"能代表目标区域真实环境"的位置,别图省事塞在设备盒里。
4.2 执行器:继电器、红外还是直接控制
执行器分三种情况。最简单的是智能插座+继电器,风扇插在智能插座上,判断端通过 MQTT 发指令给插座控制通断。这种方式零改造,但只能控制开关,不能调速。第二种是红外发射,如果你的风扇或空调是红外遥控的,用红外发射模块模拟遥控信号,能控制开关、风速、模式,灵活性高很多,但需要先抓取遥控码。第三种是直接控制,比如用继电器接风扇的供电线,或者用 PWM 调速模块,控制最精细但需要动电路,有安全风险。
我最终用的是红外方案,因为书房那台风扇支持遥控,红外发射模块几块钱,抓码用现成的库跑一遍就拿到了。红外的好处是"非侵入",不破坏原设备,坏处是没有状态反馈——你发了"开"的指令,但设备到底开没开你不知道。所以我在判断端加了一个"指令去重"逻辑:如果当前状态已经是开,就不重复发开指令,避免红外信号堆积。
4.3 主题设计与消息格式约定
整条链路的主题我这样规划:
| 主题 | 方向 | 载荷示例 | 说明 |
|---|---|---|---|
home/study/climate/temperature | 传感器发布 | 27.6 | 纯数值,单位摄氏度 |
home/study/climate/humidity | 传感器发布 | 58.2 | 纯数值,单位百分比 |
home/study/cooling/command | 判断端发布 | ON/OFF | 执行指令 |
home/study/cooling/state | 执行端发布 | ON/OFF | 实际状态回执 |
home/study/climate/status | 判断端发布 | JSON | 心跳与当前决策状态 |
载荷格式上我踩过一个坑:一开始温度直接发27.6这种裸数值,简单是简单,但后来想加时间戳和传感器ID就没地方放。后来改成 JSON{"value": 27.6, "ts": 1690000000, "src": "sht30"},扩展性好了很多。但 JSON 的解析开销比裸数值大,在 ESP32 这种资源紧张的设备上要权衡。我的做法是:传感器到判断端用裸数值省资源,判断端到执行端和状态上报用 JSON 保信息量。
5. 那些让我熬夜排查的坑
5.1 消息丢失与 retained 消息的妙用
MQTT 默认的 QoS 0 是"最多一次",发出去就不管了,网络一抖消息就没了。我遇到过判断端重启后,传感器还在发,但判断端因为订阅建立晚了几秒,错过了那几帧,导致它以为"没有数据",一直不动作。解决办法有两个:一是把传感器上报的 QoS 提到 1(至少一次),保证消息能到达;二是给传感器主题设置retained 标志,这样 broker 会保留每个主题的最后一条消息,新订阅者一连上立刻就能收到当前值,不用干等下一次上报。
retained 这个特性在环境监测里简直是神器。判断端启动的瞬间就能拿到"现在多少度",而不是等30秒后的下一帧。但要注意:retained 消息只保留最后一条,而且如果你发了空载荷(payload 为空)到某个主题,会清除该主题的 retained 消息。这个特性可以用来做"设备离线"标记,但别误操作把正常数据清了。
5.2 判断端"假死":心跳与看门狗
判断端跑在树莓派上,理论上很稳,但我遇到过两次它"假死"——进程还在,但不再处理消息了。原因一次是网络栈卡住,一次是某个异常没被捕获导致回调线程挂了。这种问题最阴险,因为从外面看一切正常,但制冷逻辑已经失效了。
我的对策是加双向心跳。判断端每隔30秒往home/study/climate/status发一条带时间戳的心跳,同时监控自己"最后一次收到传感器消息的时间"。如果超过5分钟没收到任何温度数据,就判定为数据源异常,主动发一条告警(可以发到手机通知),并且保持当前执行状态不变——注意,这里不能盲目关掉制冷,因为可能是传感器坏了而不是真的不热了,保守策略是维持现状并告警,让人来判断。
# 心跳与失联检测 def check_health(self): now = time.time() # 超过300秒没收到温度数据,判定数据源异常 if now - self.last_msg_time > 300: if not self.alerted: publish_alert("传感器数据超时,请检查设备") self.alerted = True else: self.alerted = False5.3 断电恢复后的状态一致性
还有一个场景:停电了,恢复供电后,风扇可能还插在插座上处于"上次的状态",但判断端重启后cooling_on默认是False,它以为风扇是关的。如果实际风扇是开的,就会出现"明明不热但风扇还在转"或者"很热但判断端以为已经开了不发指令"的错乱。
解决思路是启动时先同步真实状态。如果执行器支持状态回执(比如智能插座能查开关状态),判断端启动后先订阅home/study/cooling/state,拿到真实状态再初始化self.cooling_on。如果执行器不支持回执(比如红外),那就启动时无条件发一次"关"指令,把状态归零,再重新开始判断。这个"归零"动作虽然粗暴,但能保证逻辑起点是确定的。
6. 让这套逻辑更耐用的几个进阶思路
6.1 用体感温度而不是干球温度
单纯看温度其实不够准。同样是28度,湿度40%和湿度80%体感完全不一样,后者闷得多。所以我后来把湿度也纳入判断,用简化的**热指数(Heat Index)**公式算一个体感温度,用体感温度去触发。公式不复杂,网上有现成的近似算法,输入温度和相对湿度输出体感值。这样在潮湿天气里,可能27度就触发了,干燥天气里29度才触发,更符合人的真实感受。
6.2 分时段策略:白天和夜间不同阈值
夜里人对温度的耐受度高一些,而且风扇噪音影响睡眠,所以我把阈值做成随时间变化的。晚上11点到早上7点,开阈值提高到29度,关阈值降到26度,减少夜间启停次数。实现上就是判断当前小时数,动态选一组阈值。这个改动很小,但体验提升明显,尤其是风扇不会半夜反复启停吵人。
6.3 数据留痕:把环境曲线存下来
判断端顺手把每条温度消息写进本地 SQLite 或者时序数据库(比如 InfluxDB),时间长了就能看出规律:书房每天几点最热、周末和工作日有没有差异、开风扇后温度多久降下来。这些数据反过来能帮你优化阈值。我用 SQLite 存了三个月的数据,画出来发现下午三点到五点是绝对高峰,于是把防抖窗口在这个时段调短,响应更快。数据留痕这件事,做的时候觉得多余,回头看全是价值。
6.4 手动优先:别忘了留个人工开关
自动化最怕的就是"人想干预但干预不了"。我加了一个手动主题home/study/cooling/manual,往这个主题发ON或OFF可以强制覆盖自动判断,并且设置一个"手动优先时长",比如30分钟内自动逻辑不插手,30分钟后恢复自动。这样临时想开风扇或者想关掉,不用去改代码或者拔插头。这个设计思路来自工业控制里的"手动/自动切换",非常实用。
7. 我在这套方案里最看重的三个原则
折腾完这三版,我最大的体会是:环境触发类项目的难点从来不在"连上"和"发消息",而在"判断得准、执行得稳、异常时不出乱子"。MQTT 只是管道,真正决定项目好不好用的是管道两端的状态管理。
第一个原则是任何基于模拟量的判断都必须有回差,没有回差的阈值控制迟早会抖动,这是物理世界的规律,不是代码能绕过去的。第二个原则是防抖和响应速度要平衡,防抖窗口太短会误触发,太长会迟钝,用"次数×间隔"去量化它,别凭感觉设。第三个原则是永远假设会失联、会断电、会假死,然后为每种异常设计一个保守的默认行为——数据没了就维持现状加告警,重启了就归零再同步,这些兜底逻辑才是项目能长期无人值守运行的关键。
如果你刚开始做,我建议先用 DHT22 加一块 ESP32 把"传感器发消息、电脑订阅打印"这条最小链路跑通,确认 MQTT 的发布订阅你理解了,再往上加判断逻辑和执行器。别一上来就追求全自动,先把每一段单独验证,最后拼起来,出问题的时候你才知道是哪一段的锅。这套东西我跑了小半年,除了那次传感器被我塞盒子里导致读数偏高之外,没再出过需要半夜爬起来处理的问题,算是达到了"设好就不用管"的状态。