起因:一个开关的距离
宿舍的布局是这样:门边一面墙上有开关,然后是对面上铺,中间隔着两米多的过道和一地拖鞋。
白天这不叫事。但有两个时刻它非常要命:
- 熄灯之后。走廊还亮着,宿舍里一片黑,回来全靠手机屏幕那点光穿过去。
- 已经躺下了。冬天被子捂热了,突然想起来灯没关。
这两种情况下要做的事,本质上都是"站起来走两米,按一下"。人类在这个距离上的懒惰是无穷的,于是有了这个项目。
先排除掉"正经"方案
正常人会想到智能开关、智能灯泡。但宿舍有宿舍的规矩:
| 方案 | 为什么不行 |
|---|---|
| 换智能开关面板 | 要拆墙上的开关,属于改线路,宿管看见要说话 |
| 换智能灯泡 | 天花板上的灯管是公家的,换了别人也得用,而且断电后不亮 |
| 智能插座 | 开关控制的是灯管,不是插座的回路,插不上 |
| 宿舍整体改 | 想都别想 |
结论:不能动电。 那就只剩一条路——找个东西替我去按那个开关。
机械动作对机械装置,这事反而简单了:一个舵机,一根橡皮筋或者一坨双面胶,把舵机臂贴在开关旁边,转个角度就按下去。
硬件:一共不到 25 块
| 件 | 型号 | 价格 | 备注 |
|---|---|---|---|
| 主控 | ESP8266 NodeMCU(带 CH340) | 约 15 元 | 带 USB 下载电路,插上就能烧 |
| 执行器 | SG90 塑料舵机 | 约 5 元 | 最垃圾的那种就行 |
| 固定 | 双面胶 / 扎带 | 忽略 | 贴在开关旁边的墙面上 |
| 供电 | 5V USB 充电头 | 手边有 | 舵机堵转电流不小,别用电脑 USB 口 |
舵机选最便宜的 SG90 是有道理的:开关的行程只有几毫米,需要的力非常小,塑料齿轮完全够。5V 供电下掰一个墙壁开关,实测毫无压力。
接线就三根:VCC → 5V、GND → GND、信号 → 代码里指定的 GPIO。第一版用的是 D0(GPIO16),第二版改到了 D4(GPIO14)。
关键在于"归位角度"
这是整个项目唯一的机械设计,也是唯一需要试错的地方:
servo_16.write(45); // 开灯:按下去
delay(1000);
servo_16.write(95); // 归位
三个角度:
- 45° —— 按开灯那一侧的开关
- 135° —— 按关灯那一侧
- 95° —— 归位,大概在中间
95° 这个中间位置是核心。如果舵机按完还压在开关上,会带来两个后果:一是电机持续受力发热,二是人就没法手动按开关了。归位到中间,舵机臂就悬在那里,随手按一下开关照样能用——智能改造最忌讳的就是"断电之后变砖",这个归位角度保证了断电断网时它只是一坨挂着的塑料。
第一版:点灯科技 + 小爱同学
ESP8266 上手最快的方案是 Blinker(点灯科技):App 端拖个按钮,代码里 attach 一个回调,就能远程点。再加一行宏定义,小爱同学也能直接控:
#define BLINKER_WIFI
#define BLINKER_MIOT_LIGHT // 告诉米家:这是个灯
char auth[] = "<Blinker 设备密钥>";
char ssid[] = "<宿舍 WiFi>";
char pswd[] = "";
BlinkerButton Button1("ddl1"); // App 里的开灯按钮
BlinkerButton Button2("ddl2"); // 关灯按钮
Servo servo_16;
三个入口,最后都落到同一组动作上:
// App 按钮
void button1_callback(const String & state) {
servo_16.write(45);
delay(1000);
servo_16.write(95);
}
// 小爱同学:"小爱同学,开灯"
void miotPowerState(const String & state) {
if (state == BLINKER_CMD_ON) {
servo_16.write(45);
delay(1000);
servo_16.write(95);
BlinkerMIOT.powerState("on");
BlinkerMIOT.print(); // 必须回报,否则小爱一直"正在为您操作"
} else if (state == BLINKER_CMD_OFF) {
servo_16.write(135);
delay(1000);
servo_16.write(95);
BlinkerMIOT.powerState("off");
BlinkerMIOT.print();
}
}
BlinkerMIOT.print() 那行不能忘。它是给米家回执的,不回报的话小爱会一直卡在"正在为您操作",然后告诉你没响应——灯其实已经开了,只是它不知道。
这套东西用起来是这样:冬天躺床上说一句"小爱同学,关灯",舵机咔哒一下。成就感极高。
但它有三个问题,一路用一路憋:
- 依赖云。ESP8266 → 点灯服务器 → 手机。宿舍断外网,整套失效。
- 免费额度。Blinker 免费版对设备数、消息量、数据保留都有限制,玩久了会碰到。
- 数据不在自己手上。想加个"记录开关时间"“接入自己的网页”,没有出口。
于是有了第二版。
第二版:自己写一个 20 行的服务端
第二版的思路非常朴素:设备主动去问服务器"有没有活干"。
ESP32(换了板子)每隔一秒发一个 HTTP GET:
GET /api/device/poll?device_id=esp32_light_001
服务器有指令就返回 {"command":"on"},没有就返回空字符串,并且取走即清空——一次性指令,不会重复执行。
为什么不用长连接 / MQTT / WebSocket?
| 方案 | ESP32 侧成本 | 宿舍 WiFi 下的表现 |
|---|---|---|
| HTTP 轮询(我用的) | 最低,HTTPClient 三行 | 简单,穿过校园网 NAT,1 秒延迟 |
| MQTT | 要引库、要保活、断线重连 | 要好一点,但代码翻倍 |
| WebSocket | 要处理握手和心跳 | 不划算 |
1 秒延迟对"关个灯"来说完全不是问题。代价是一秒钟一次空转请求,一天 8 万多次——但这是局域网内的请求,无感。
服务端用 Go,全部代码不到 150 行,三个接口:
http.HandleFunc("/", indexPage)
http.HandleFunc("/api/device/register", registerDevice) // 设备上线注册
http.HandleFunc("/api/device/poll", pollDevice) // 设备取指令
http.HandleFunc("/api/control", controlDevice) // 网页下指令
存储直接用一个 map 加读写锁,没上数据库:
var (
devices = make(map[string]*Device)
mu sync.RWMutex
)
轮询里最关键的两行:
response := map[string]string{"device_id": deviceID, "command": device.Command}
device.Command = "" // 取走就清空
前端页面用 //go:embed 直接编译进二进制:
//go:embed index.html
var indexHTML string
于是部署形态变成一个文件:go build 出来的 exe 拷到任何一台机器上双击就能用,没有静态目录、没有 Node、没有 nginx。这是 Go 在这类小项目上最好用的地方——单文件交付。
顺手解决了校园网
第二版真正麻烦的地方不是代码,是网络。
宿舍的 WiFi 是校园网,连上之后没有外网,得先打开浏览器,跳出一个认证页面,输学号密码。也就是说:ESP32 能连上 WiFi、能拿到 IP,但所有请求都会被劫持到认证页。
解决方案是把网页认证那个 POST 请求,用代码重放一遍。深澜(eportal)的接口长这样:
const String portal_url = "http://<认证服务器IP>:801/eportal/portal/login";
const String user_account = ",0,<学号>"; // 注意前面要保留 ",0,"
const String user_password = "<密码>";
String wlan_user_ip = ""; // DHCP 拿到的,动态填
const String wlan_ac_ip = "<AC IP>";
const String wlan_ac_name = "<AC 名称>";
拼接成一个 GET 然后发出去:
String url = portal_url + "?callback=dr1004"
+ "&login_method=1"
+ "&user_account=" + user_account
+ "&user_password=" + user_password
+ "&wlan_user_ip=" + wlan_user_ip
+ "&wlan_user_mac=" + WiFi.macAddress()
+ "&wlan_ac_ip=" + wlan_ac_ip
+ "&wlan_ac_name=" + wlan_ac_name
+ "&jsVersion=4.2&terminal_type=1&lang=zh-cn";
几个必须记住的点:
wlan_user_ip一定要用 DHCP 拿到的真实 IP,不能写死。 认证接口是拿 IP 来标记"哪个终端已经通过认证"的,写死了就等于替别人认证。所以要先WiFi.localIP()转成字符串再拼进 URL。user_account前面的",0,"是格式的一部分,不是排版。少了它服务器直接返回参数错误。这个前缀是拿浏览器 F12 看真实请求抄下来的——抓一次真实请求,比读十篇文档快。- 判断成功别看 HTTP 200,接口永远是 200,要看 body 里的
"result":1。 wlan_ac_ip/wlan_ac_name是这台 AC 的标识,换宿舍楼可能会变,从认证页的请求参数里抄。
不过说实话,这段认证逻辑在我最终版本里是注释掉的(if (true || doPortalAuth()),等价于永远走成功分支)。因为认证本身容易掉,掉线之后重连、重认证的时序问题比控制灯本身复杂得多。它现在的作用是"知道原理、要用能拿出来",而不是"每天在跑"。
这套东西的毛病(自己审一遍)
跑起来是一回事,经得起推敲是另一回事。这套代码有几处我当时写得不太对的:
1. 网页会"偷走"指令。 这是我后来才发现的真 bug。前端每 5 秒调一次 checkDeviceStatus() 刷新在线状态,调的是——/api/device/poll。而服务端那个接口的逻辑是"取走即清空"。也就是说:你点了"开灯",指令进了队列,如果刚好网页的轮询先于 ESP32 的轮询到达,指令就被网页取走并清空了,ESP32 什么也拿不到。表现就是"点了没反应,再点一次又好了"。
正确做法是把"查状态"和"取指令"拆成两个接口。当时图省事复用了同一个——这种"接口语义被复用"的省事,通常会在某天以一个很诡异的方式报复你。
2. 设备永远显示"在线"。 Device 里有 LastPollTime,但没有任何地方拿它跟当前时间比过。设备拔电之后,网页上那个绿色小圆点永远不会变红。想做对很简单:poll 的时候顺手算一下,超过 30 秒没上报就标 offline。字段都留好了,逻辑忘了写。
3. 手搓 JSON 解析。 ESP32 那边是这样取命令的:
int cmdIndex = payload.indexOf("\"command\":\"");
cmdIndex += 11;
int endIndex = payload.indexOf("\"", cmdIndex);
command = payload.substring(cmdIndex, endIndex);
能跑,但服务器只要多打一个空格、或者字段顺序变了,立刻失灵。注释里我自己都写了"推荐生产环境使用 ArduinoJson"。这种解析方式适合一次性的玩具,不适合天天用的东西。
4. delay(1000) 是阻塞的。 舵机动作那一秒里,ESP32 完全停止轮询,串口也不响应。真要改,应该用 millis() 做非阻塞计时,或者干脆把"动作"和"通信"拆到两个任务里。
5. 局域网内谁都能控制。 没有鉴权,任何知道 192.168.x.x:8080 的人打开网页就能开关我的灯。对宿舍内部无所谓,但这是个坏习惯——默认没有鉴权的小服务,一旦被搬到公网就是这个样子出名的。
6. IP 是硬编码的。 const String serverUrl = "http://192.168.x.x:8080"; —— 跑服务端那台机器一换 IP,设备就失联。要么在路由器上给服务器做 DHCP 保留,要么上 mDNS。
值不值
成本:ESP8266 + 舵机 + 一根 USB 线,二十块上下。收益:一个冬天不用出被窝关灯。
但真正有意思的不是这个。
第一版是用别人的云:拖几个按钮、绑个语音助手,两小时就能跑起来,代价是离了云就什么都不是。第二版是自己的服务器:一个 exe、一个网页、轮询协议自己定,代码糙但每一层都知道在干什么。
中间那步——“把网页认证的 POST 请求抄下来,用单片机重放一遍”——是这个项目里唯一有点门槛的地方。它教会我一件事:所谓"设备联网",绝大多数时候不是协议难,是你得先知道那个浏览器在背地里发了什么。F12 里那条请求,比任何文档都诚实。
以及一个更小的道理。整个项目里我真正做对的一个决定,是选了舵机而不是继电器——能动机械的,就别动电。这条原则让这套东西在宿管眼里只是一坨贴在墙上的塑料,在断电的时候也只是一坨塑料。
代码(脱敏版,密钥密码全是占位符)放在 https://cnb.cool/t1mmoc/tspace/light-esp32,MIT。