起因:一个开关的距离

宿舍的布局是这样:门边一面墙上有开关,然后是对面上铺,中间隔着两米多的过道和一地拖鞋。

白天这不叫事。但有两个时刻它非常要命:

  • 熄灯之后。走廊还亮着,宿舍里一片黑,回来全靠手机屏幕那点光穿过去。
  • 已经躺下了。冬天被子捂热了,突然想起来灯没关。

这两种情况下要做的事,本质上都是"站起来走两米,按一下"。人类在这个距离上的懒惰是无穷的,于是有了这个项目。

先排除掉"正经"方案

正常人会想到智能开关、智能灯泡。但宿舍有宿舍的规矩:

方案为什么不行
换智能开关面板要拆墙上的开关,属于改线路,宿管看见要说话
换智能灯泡天花板上的灯管是公家的,换了别人也得用,而且断电后不亮
智能插座开关控制的是灯管,不是插座的回路,插不上
宿舍整体改想都别想

结论:不能动电。 那就只剩一条路——找个东西替我去按那个开关。

机械动作对机械装置,这事反而简单了:一个舵机,一根橡皮筋或者一坨双面胶,把舵机臂贴在开关旁边,转个角度就按下去。

硬件:一共不到 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() 那行不能忘。它是给米家回执的,不回报的话小爱会一直卡在"正在为您操作",然后告诉你没响应——灯其实已经开了,只是它不知道。

这套东西用起来是这样:冬天躺床上说一句"小爱同学,关灯",舵机咔哒一下。成就感极高。

但它有三个问题,一路用一路憋:

  1. 依赖云。ESP8266 → 点灯服务器 → 手机。宿舍断外网,整套失效。
  2. 免费额度。Blinker 免费版对设备数、消息量、数据保留都有限制,玩久了会碰到。
  3. 数据不在自己手上。想加个"记录开关时间"“接入自己的网页”,没有出口。

于是有了第二版。

第二版:自己写一个 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。