我把一个停运的充电桩小程序,改成了自己的 ESP32 控制台 / From a Dead Charger Mini-Program to an ESP32 Control Console
这篇记录一次有点“考古”的项目:原来的充电桩小程序已经停止运营,但充电桩本身还在车库里工作。我想知道它到底是怎么和设备通信的,也想把日常控制从一个随时可能失效的小程序,换成自己能维护的硬件。
最后的结果是,一块 ESP32-C3 通过 BLE 连接充电桩,再通过家庭 Wi-Fi 提供一个局域网 WebUI,板载的小 OLED 同步显示充电状态、功率、端口和最近事件。
这里不会放真实 Wi-Fi、设备地址、应用密码、抓包文件名,也不会放能直接控制陌生设备的完整原始载荷。重点是思路、排错顺序,以及哪些地方最容易被误判。
我一开始以为只是“找几个蓝牙命令”
真正开始做以后,才发现事情完全不是把几段十六进制复制出来那么简单。
同一个“连接失败”,可能分别代表:手机根本没扫描到设备、GATT 链路没建立、服务没枚举出来、通知没订阅成功,或者链路都正常了,只是应用层还没有完成认证。要是把这些状态混在一起,日志越多,结论反而越乱。
所以我先给自己定了一个很朴素的状态机:
| 层级 | 我需要确认的事情 | 能说明什么 |
|---|---|---|
| 扫描 | 是否能看到目标设备的广播 | 设备在附近,并且还在广播 |
| GATT 连接 | 是否拿到有效连接 | 链路建立,但还不能说明业务可用 |
| 服务枚举 | 是否找到目标服务和特征 | 找到了正确的设备协议入口 |
| 通知订阅 | 是否能持续收到设备上行状态 | 具备读取运行状态的条件 |
| 应用握手 | 认证和时间同步是否被接受 | 应用协议真正开始工作 |
这张表后来几乎贯穿了整个项目。每次遇到问题,我都先问“卡在哪一层”,而不是马上换库、改 UUID 或者重刷固件。
静态代码分析:先看小程序自己说了什么
Mac 上的小程序可以正常打开,但自动化操作很快触发了微信的风控。既然界面不适合继续自动点,我转而查看本机缓存的小程序代码,先做静态分析。
从设备搜索页面开始,代码会根据设备名称把目标路由到对应的型号页面。目标型号使用标准的 BLE GATT 流程:创建连接、枚举服务、选择一个业务服务,再从里面找到一个通知特征和一个写入特征。
连接成功后,小程序不会立刻让用户按按钮,而是故意等一小段时间,再做两件事:发送应用层认证信息,以及把手机当前时间同步给设备。这个延时很关键,它说明充电桩的固件需要一点时间完成连接后的初始化。
我把源码里的行为整理成了下面这种“脱敏后的伪协议”,只保留语义,不保留真实字段:
connect()
discover_service()
subscribe_notify()
write("AUTH:<device-secret>")
write("TIME:<local-time>")
控制动作也能在页面逻辑里对应起来:开始/停止电源,以及两种“插入后是否自动充电”的模式切换。这里同样只记录成语义名称:
write("POWER_ON")
write("POWER_OFF")
write("MODE_PLUG_PLAY")
write("MODE_ANTI_THEFT")
这一步给了我一个很重要的判断:它不是一个需要复杂加密解包的神秘协议,而是“BLE 负责承载,设备自己定义了一层简单的文本命令”。但这只代表源码里这么写,仍然需要动态证据来验证。
Android 日志:把每一次操作拆开做
我准备了一台 Android 手机,打开开发者日志和蓝牙日志,用原来的小程序按固定顺序操作。没有一上来反复点击,而是每一轮只改变一个变量:
- 只打开自动连接,观察扫描、连接、服务发现和通知订阅;
- 在连接稳定后发送一次开始充电;
- 单独切换“即插即充”和“防盗充”;
- 最后反复发送开始和停止,确认它们不是偶然的背景流量。
这样做的好处是,日志里每一段写入都能和一个明确动作对应起来。抓包里有些写操作只显示了字符串的开头,那是 Android bugreport 对长写包的截断,不是协议本身只有那几个字符。静态源码和动态日志对上之后,我才敢把它称为“已确认的业务动作”。
动态过程也验证了前面的分层判断:扫描到设备不代表已经连上;连上以后还要找到正确服务;找到服务以后还要订阅通知;只有通知开始持续出现,功率、计时、插头、门状态和充电状态才有意义。
应用密码,不等于 BLE 配对密码
中间有一个很容易混淆的点:手机第一次使用时出现过密码输入框,我一度以为这是 BLE 的标准配对流程。
后来从代码和日志看,当前这台设备是在 GATT 连接建立之后,由小程序把应用层认证信息作为普通写入发送给充电桩。它和系统蓝牙弹出的六位配对 PIN 不是一回事。也就是说:
- BLE 配对属于链路层安全;
- 小程序的密码属于设备自定义的应用层认证;
- 不能因为看到一个密码,就把它直接填进 ESP32 的 BLE passkey 配置。
这个区别不只是概念问题。把两者混用,会得到“手机能用、开发板永远连不上”的假象。
ESP32 第一次连不上:我一开始也以为是信号问题
协议已经看懂以后,我把它移植到 ESP32-C3。扫描很快能看到设备,但连接经常失败,或者连接回调已经成功,后面的服务发现却卡住。
第一反应当然是距离、天线和地址过滤。我把开发板挪到充电桩旁边,换过几种扫描参数,结果都不稳定。后来对照手机日志,才发现这个充电桩在连接后会主动调整连接参数,而且对 MTU 交换的响应并不符合常见设备的习惯。
当时使用的 Arduino-ESP32 BLE 客户端库,会把“没有收到预期的 MTU 响应”当成连接不可用,导致上层根本走不到服务发现。于是出现了一个很迷惑的状态:物理链路其实已经存在,但库把它判死了。
最后的处理不是改业务协议,而是给本机核心库做一个很小的兼容性修补:接受已经完成或正在进行的 MTU 流程,保留充电桩请求的连接参数;即使没有 MTU 响应,只要连接句柄有效,也继续尝试服务发现。
这件事让我重新确认了一遍边界:协议逆向和 BLE 栈兼容是两类问题。前者解决“应该发什么”,后者解决“有没有机会把它发出去”。
从“能连上”到“能长期用”
开发板稳定收到通知以后,我没有马上把所有按钮都暴露出来,而是先做了一个只读状态面板。它会显示:
- 当前是否连接到充电桩;
- 是否正在充电;
- 功率和设备上报的计时字段;
- 充电端口是否插入;
- 门状态和当前充电模式;
- BLE 信号、Wi-Fi 状态和最近一条通知。
确认这些状态能持续更新后,才加入四个手动控制按钮。每个按钮都需要浏览器二次确认,接口使用 POST,并要求显式确认参数;固件还加了最小命令间隔,避免网页重复点击造成连续写入。上电时不会自动开始或停止充电,控制动作只在我明确点击之后发生。
OLED 不是装饰,是现场的第二个日志窗口
参考那块 0.42 英寸 OLED 开发板的文章后,我发现它的可见区域比控制器的完整分辨率小,直接按 128×64 画会有内容落在玻璃外面。因此固件按实际可见窗口来渲染,并把页面拆成几页轮播:
- 大字显示“充电中/未充电”和当前功率;
- 单独放大功率数字;
- 显示模式、端口和门状态;
- 显示 BLE、Wi-Fi 和局域网连接状态;
- 横向滚动最近一条通知。
信息太多时,滚动比把字体缩成一排小蚂蚁更实用。网页适合完整查看,OLED 适合我站在车旁边一眼确认,这两个界面承担的是不同场景。
WebUI 的取舍:只做家庭局域网设备
日用版本连接家庭 Wi-Fi,不启动临时 AP,也不做 Basic Auth。这个选择不是因为安全不重要,而是因为它的边界很明确:设备只放在可信的家庭局域网里,不做端口映射,不接公共 Wi-Fi。
WebUI 是固件内置的单页页面,定时轮询状态接口;控制接口只接受有限的几个语义动作,成功后等待充电桩的通知更新,而不是假装“请求返回 200 就已经充电”。如果未来要把它放到更复杂的网络环境,我会优先加网络隔离、访问控制和 HTTPS,而不是把这个轻量方案直接暴露出去。
这次项目真正做完的是什么
现在这块板子已经可以在开机后连接 Wi-Fi,扫描并连接目标充电桩,完成应用层握手,持续接收充电状态,并通过网页和 OLED 同步展示。对我来说,最有价值的不是那几个控制按钮,而是终于把原来依赖停运小程序的黑盒,拆成了一条可以观察、验证和维护的链路。
整个过程也没有得到一个“所有同类设备通用”的协议。当前结果只针对已经确认过的一个硬件型号;其他版本可能使用不同的服务、特征或连接行为,不能把这份记录直接套过去。
如果以后再遇到类似设备,我大概会沿用同一套顺序:先保存证据,再分层验证;先做只读状态,再做控制;先把凭据和地址隔离,再考虑长期运行。逆向的终点不是把命令抄出来,而是让设备重新变成一个可理解、可维护、也知道边界在哪里的系统。
最后也提醒一句:这类分析只应该用于自己拥有或明确获授权的设备。不要把真实抓包、应用密码、设备地址和控制接口上传到公开仓库,更不要把一个没有身份认证的局域网控制器直接暴露到公网。
若无法加载请检查网络环境。
若无法加载请检查网络环境,或切回 Disqus 稍后再试。