用一块ESP32,做出真正能落地的人体感应灯——从电路抖动到深夜自动亮起的完整实践手记
去年冬天我在老房子的楼梯间装了一盏“智能灯”,结果连续三晚被自己吓醒:刚踏上第一级台阶,灯猛地炸亮,像探照灯扫过脸;还没走到二楼,它又“啪”地灭了,剩下半截楼梯漆黑一片。拆开看,是某宝9.9包邮的“人体感应LED灯”,里面塞着一颗老旧的单片机+模拟比较器+电容延时电路——连个防抖逻辑都没有。
后来我重做了整套系统:用ESP32-WROOM-32、HC-SR501 PIR模块、一颗AO3400 MOSFET和几颗阻容,写了一版MicroPython固件。现在它能在你抬脚的瞬间柔光渐亮,人走后30秒缓缓熄灭,整晚待机电流不到15 μA,CR2032电池撑了整整16个月。没有云平台、不连App、不依赖路由器——但它比市面上大多数“智能灯”更懂你什么时候需要光。
这不是一个理论推演,而是一份踩过所有坑之后整理出的实战笔记。下面我会带你从信号怎么进来、芯片怎么睡醒、光怎么调得舒服、指令怎么远程发过来,一层层剥开这个看似简单、实则处处是细节的ESP32项目。
PIR信号不是“一高就亮”,它是会骗人的
HC-SR501这类PIR模块,表面看就是个数字开关:没人→低电平,有人→高电平保持几秒。但如果你真这么用,很快就会发现:窗帘被风吹起、空调出风口热气流扰动、甚至窗外树影晃过玻璃,都能让它“误报”。
根本原因在于,PIR传感器本身输出的是原始电荷变化信号,经BISS0001芯片内部放大、窗口比较、延时整形后才变成我们看到的“高电平脉冲”。而这个过程受温度、湿度、电源纹波、PCB布线干扰影响极大。
我用示波器抓过几十次真实触发波形:
- 正常人体经过时,高电平稳定持续2.3–4.1秒(取决于延时旋钮);
- 但窗帘飘动时,会出现多个间隔<100ms的窄脉冲(宽度约30–80ms),总持续时间可能不到1秒;
- 更糟的是,有些模块在上电初期或电压不稳时,GPIO会随机抖动出数个毫秒级毛刺。
所以,“检测到高电平就开灯”是典型新手陷阱。真正的做法是:把PIR当作一个事件源,而不是状态源。
关键动作有三个:
- 硬件滤波:在PIR输出端加一个100nF陶瓷电容并联到地(别省这颗料!),可吸收大部分<10μs的高频噪声;
- RTC GPIO唤醒配置:必须用支持RTC功能的引脚(如GPIO13/14/15/27/32/33/34/35/36/39),否则Deep-sleep下无法响应中断;
- 软件两级防抖:中断里只记时间戳,主循环判断“最近一次有效触发是否发生在200ms内”,且两次触发间隔需>2.5秒(避开BISS0001内部封锁时间)。
from machine import Pin, RTC import time pir = Pin(13, Pin.IN, Pin.PULL_DOWN) rtc = RTC() # 全局状态(存于RTC内存,Deep-sleep不丢失) last_valid_trigger = 0 # ms时间戳 trigger_count = 0 # 今日触发次数(用于统计) def pir_isr(pin): global last_valid_trigger, trigger_count now = time.ticks_ms() # 第一级防抖:忽略200ms内重复中断(硬件毛刺过滤) if time.ticks_diff(now, last_valid_trigger) < 200: return # 更新时间戳 & 计数 last_valid_trigger = now trigger_count += 1 # 绑定上升沿中断(仅在非Deep-sleep时启用) pir.irq(trigger=Pin.IRQ_RISING, handler=pir_isr)注意:time.ticks_ms()返回的是单调递增的毫秒计数,不会因NTP校时跳变,也无浮点运算开销——这是MicroPython里最安全的时间测量方式。
ESP32不是“睡着了就醒不来”,它醒得比你眨眼还快
很多人以为Deep-sleep就是关机,其实不然。ESP32的Deep-sleep模式下,RTC控制器、RTC内存(8KB)、ULP协处理器仍在运行,就像电脑的“休眠”而非“关机”。关键是要让PIR信号能“敲开RTC的门”。
这里有个极易被忽略的细节:普通GPIO在Deep-sleep时无法作为唤醒源,必须显式配置为RTC_GPIO。而HC-SR501输出是5V逻辑电平,直接接ESP32 3.3V GPIO有风险——所以实际电路中,我加了一颗10kΩ上拉电阻到3.3V,并用1N4148二极管钳位(阳极接PIR输出,阴极接GPIO),彻底杜绝5V倒灌。
唤醒流程如下:
1. 系统初始化完成后,调用esp32.wake_on_ext1(pins=(Pin(13),), level=esp32.WAKEUP_ANY_HIGH)
2. 执行machine.deepsleep()进入休眠
3. PIR拉高GPIO13 → RTC检测到电平跳变 → 自动唤醒CPU
4. 唤醒后,代码从boot.py或main.py开头重新执行(注意:全局变量全部重置,但RTC内存保留)
唤醒延迟实测约24ms(含PLL锁频、Flash读取、Python解释器加载)。对人眼来说,这比神经信号传导还快——你抬腿的动作还没传到大脑,灯已经亮了。
一个实用技巧:用RTC内存保存“上下文”
既然RTC内存不掉电,何不存点有用信息?比如:
# 唤醒后立即读取RTC内存中的亮度设定值 brightness_level = rtc.memory()[0] if rtc.memory() else 80 # 点亮LED并启动倒计时 led.duty(int((brightness_level / 100) ** 2.2 * 1023)) start_auto_off_timer(30) # 30秒后自动关闭这样即使断电重启,灯也会按上次设定的亮度工作,用户体验瞬间提升。
PWM调光不是“占空比越大越亮”,人眼看到的亮度是另一套算法
我曾用线性PWM(duty = level * 10.23)做过对比测试:当设定亮度为10%时,肉眼几乎看不出光;设到15%,突然“刷”一下变亮,像开了闪光灯。这是因为人眼视网膜感光细胞对光强的响应近似对数关系(Weber-Fechner定律),而非线性。
更准确地说,物理光强(luminance)与人眼感知亮度(brightness)满足Gamma校正模型:
感知亮度 ∝ (物理光强)^γ,其中γ≈2.2(sRGB标准)
所以,要让“亮度滑块拖到20%时,人眼刚好觉得是全亮的1/5”,就必须反向计算PWM占空比:
def set_brightness(level): """ level: 0~100 整数,表示用户感知亮度百分比 返回对应10-bit PWM duty值(0~1023) """ if level == 0: return 0 # Gamma逆变换:duty ∝ level^(1/γ) gamma_inv = 1 / 2.2 duty = int((level / 100) ** gamma_inv * 1023) return max(0, min(1023, duty)) # 实测效果: # level=10 → duty=42(微弱暖光,适合夜灯) # level=30 → duty=156(柔和阅读光) # level=80 → duty=732(明亮环境光)另外,载频选择也很关键。低于1kHz会有明显闪烁感(尤其余光扫过时),高于5kHz则MOSFET开关损耗剧增。实测2kHz是最佳平衡点:人耳听不到啸叫,MOSFET温升可控,LED光效衰减最小。
Wi-Fi管理不必大张旗鼓,一个HTTP GET就够
很多教程教你怎么搭MQTT、接阿里云IoT、做JWT鉴权……但对于一盏楼梯灯,这些全是负累。我的方案极简:手机浏览器输入http://192.168.1.123/api?b=60就能把亮度设为60%,再输一次http://192.168.1.123/api?sleep=1就进入超低功耗模式(仅等待PIR唤醒)。
核心思路是:放弃JSON解析、放弃POST、放弃Session,用GET参数直通控制逻辑。
import uasyncio as asyncio import network import re wlan = network.WLAN(network.STA_IF) wlan.active(True) wlan.connect('HomeWiFi', 'password') # 等待连接(带超时保护) for _ in range(30): if wlan.isconnected(): break asyncio.sleep(1) # 解析URL参数的轻量函数 def parse_query(query_str): params = {} for pair in query_str.split('&'): if '=' in pair: k, v = pair.split('=', 1) params[k.strip()] = v.strip() return params async def handle_client(reader, writer): try: # 读取首行(只取GET路径和参数) line = await reader.readline() if b'GET ' not in line: raise OSError("not GET") path = line.split()[1].decode() if '?' in path: query = path.split('?', 1)[1] args = parse_query(query) if 'b' in args: level = max(0, min(100, int(args['b']))) set_brightness(level) # 同时存入RTC内存,下次唤醒仍生效 rtc.memory(bytes([level])) elif 'sleep' in args and args['sleep'] == '1': # 进入深度睡眠,仅由PIR唤醒 esp32.wake_on_ext1(pins=(Pin(13),), level=esp32.WAKEUP_ANY_HIGH) machine.deepsleep() # 返回极简HTML(无需CSS/JS) html = f"""HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n <h2>Light Control</h2> <p>Brightness: {get_current_brightness()}%</p> <p><a href="/api?b=30">30%</a> | <a href="/api?b=60">60%</a> | <a href="/api?b=100">100%</a></p> <p><a href="/api?sleep=1">🌙 Enter Deep-sleep</a></p> """ await writer.awrite(html.encode()) except Exception as e: await writer.awrite(b"HTTP/1.1 500 Internal Error\r\n\r\nError") finally: await writer.aclose() # 启动服务器(监听80端口) async def main(): server = await asyncio.start_server(handle_client, "0.0.0.0", 80) print(f"Web server running on http://{wlan.ifconfig()[0]}") while True: await asyncio.sleep(3600) # 每小时打印一次IP(调试用) asyncio.run(main())这个实现没有依赖任何第三方库,总代码量<120行,内存占用<45KB。它甚至能在Wi-Fi断连时继续工作——因为PIR唤醒和PWM控制完全独立于网络模块。
最后一点实在建议:别迷信“低功耗”,先搞定你的PCB
我见过太多项目卡在最后一步:明明代码写对了,Deep-sleep电流却始终在80μA以上。查了三天,发现是PCB上一个未切断的LED指示灯供电路径,或者Wi-Fi天线旁的去耦电容选型错误(用了高ESR的电解电容)。
几个血泪经验:
-PIR信号线必须远离Wi-Fi天线和DC-DC开关节点:我最初把PIR模块焊在ESP32开发板背面,结果每天凌晨3:17准时触发(正好是邻居路由器信标帧密集时段);
-MOSFET驱动要用AO3400这类逻辑电平型:不要用IRF540,它的Vgs(th)高达4V,ESP32 3.3V IO根本打不开;
-EEPROM模拟写入前务必加CRC校验:我曾因一次意外断电,导致Wi-Fi密码区被写坏,整机变砖,最后靠串口强制进入固件升级模式才救回来;
-首次上电务必用USB转TTL监控串口输出:很多“没反应”的问题,其实是Wi-Fi连接失败后卡死在while not wlan.isconnected():循环里。
现在这盏灯挂在我家楼梯拐角已经16个月。它没连过一次云,没升级过固件,没换过电池。每次深夜归家,脚步声还没到二楼,光就已铺满台阶——不刺眼、不突兀、不等待。
技术从来不是堆参数,而是让机器学会“恰到好处地响应”。当你把PIR的毛刺、ESP32的休眠唤醒、PWM的视觉曲线、Wi-Fi的极简交互全都揉进一行行代码里,最终呈现给用户的,就不再是“一个ESP32项目”,而是一段无声的默契。
如果你也在做类似的小系统,欢迎在评论区聊聊你遇到的最诡异bug——说不定,我们踩过的同一个坑,正在等着下一个开发者跳进去。