小程序停运了,充电桩还在车库里正常工作。

这事有点尴尬:硬件好好的,但能不能用它,取决于一个我控制不了的服务器还愿不愿意活着。所以我想把控制权拿回来——一块 ESP32-C3 通过 BLE 连桩,接家里的 Wi-Fi,起一个局域网网页,再拿板载那块小 OLED 显示状态。

最后做成了。但过程里踩的坑,和我一开始设想的完全不是一类。

我以为难点是"找到几条蓝牙命令"

不是。

难点是"连接失败"这四个字底下藏了五种完全不同的失败:手机没扫到设备、GATT 链路没建起来、服务没枚举出来、通知没订阅上、链路全通但应用层还没认证。这五种在日志里长得很像,混在一起看,日志越多越糊涂。

所以我先画了一张表,逼自己每次只回答一个问题——卡在哪一层。

层级 要确认什么 说明什么
扫描 能不能看到目标广播 设备在附近,还在播
GATT 连接 有没有拿到有效连接 链路通了,业务不一定
服务枚举 找没找到目标服务和特征 找对了协议入口
通知订阅 能不能持续收到上行 具备读状态的条件
应用握手 认证和对时有没有被接受 应用协议才真正开始

这张表没什么技术含量,但它省掉了我大量"换个库试试""要不要重刷固件"的无效动作。

先看小程序自己怎么说

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,打开开发者选项里的蓝牙日志,用原来的小程序按固定顺序操作。

关键是忍住别乱点。我分了四轮,每轮只改一件事:

  1. 只让它自动连,看扫描、连接、服务发现、通知订阅;
  2. 连稳之后,发一次开始充电;
  3. 单独切"即插即充"和"防盗充";
  4. 反复开始/停止,确认这两条不是背景流量里蒙对的。

这样之后,日志里每一次写入都能对上一个我明确做过的动作。

这里有个差点骗到我的地方:抓包里有些写操作只显示了字符串开头,看着像是命令真就那么几个字符。其实是 Android bugreport 对长写包做了截断。要不是静态代码那边有完整版本,我可能就照着截断的去实现了。

应用密码不是 BLE 配对密码

第一次用小程序的时候弹过一个密码框,我理所当然以为那是 BLE 标准配对。

不是。那个密码是在 GATT 连接建立之后,由小程序当作普通写入发给桩的,属于设备自定义的应用层认证。系统蓝牙弹的六位 PIN 是另一回事,那是链路层的。

分不清这两个的后果很具体:你会把应用密码填进 ESP32 的 BLE passkey 配置里,然后得到"手机能用、开发板死活连不上",而且完全看不出为什么。

ESP32 连不上,我以为是信号问题

协议看懂了,搬到 ESP32-C3 上。扫描秒出,连接经常失败;偶尔连上了,回调也说成功,服务发现却卡死。

第一反应当然是硬件:距离、天线、地址过滤。我把板子端到桩旁边,换了几组扫描参数,还是时好时坏。

后来把开发板日志和手机日志摆在一起对,才看出来:这台桩在连上之后会主动改连接参数,而且它对 MTU 交换的回应跟常见设备不一样。

问题出在我当时用的 Arduino-ESP32 BLE 客户端库——它把"没收到预期的 MTU 响应"直接当成连接不可用,于是上层压根走不到服务发现。链路其实是活的,库自己把它判死了。

最后改的不是业务协议,是给本机的核心库打了个很小的补丁:MTU 流程只要是已完成或进行中就接受,保留桩请求的连接参数,只要连接句柄还有效就继续往下做服务发现。

这跟协议本身没关系,纯粹是 BLE 栈的兼容问题。但当时看现象,两者几乎分不出来。

先做只读,再给按钮

板子能稳定收通知之后,我没急着把开关做出来,先只做了一个只读面板:连没连上、在不在充电、功率和设备上报的计时、插头插没插、门的状态、当前模式,加上 BLE 信号、Wi-Fi 状态和最近一条通知。

确认这些字段是真的在持续更新、不是偶尔诈尸,才加那四个控制按钮。

按钮这边做了几个限制:网页上要二次确认;接口只收 POST,且必须带显式确认参数;固件里有最小命令间隔,防止连点打出一串连续写入。上电不会自动开始或停止充电——所有控制动作都得我按了才发生。

OLED 当第二个日志窗口

板载那块 0.42 寸 OLED 有个坑:可见区域比控制器的完整分辨率小,你按 128×64 画,有一部分内容会落到玻璃外面看不见。所以固件是按实际可见窗口渲染的。

内容拆成几页轮播:

  1. 大字显示充电中/未充电和当前功率;
  2. 只放大功率数字;
  3. 模式、端口、门状态;
  4. BLE、Wi-Fi、局域网状态;
  5. 横向滚动最近一条通知。

字太多的时候,轮播比把字号缩成一排小蚂蚁强。网页用来慢慢看,OLED 用来站在车边扫一眼。

WebUI 只打算给家里的局域网用

日用版本直接连家里的 Wi-Fi,不起临时 AP,也没做 Basic Auth。

这是个明确的取舍,不是忘了做:这东西只待在家里的局域网,不做端口映射,不接公共 Wi-Fi。哪天要放到更复杂的网络里,我会先加网络隔离、访问控制和 HTTPS,而不是把现在这套直接丢出去。

页面是固件内置的单页,定时轮询状态接口。控制接口只认那几个语义动作,而且发出去之后是等桩的通知回来才更新状态——不是请求返回 200 就当已经在充电了。

现在

板子开机自己连 Wi-Fi,扫到桩,握手,持续收状态,网页和 OLED 同步显示。

对我来说值钱的不是那四个按钮,是原来那个"小程序说什么就是什么"的黑盒,现在变成了一条我能看见、能验证、能修的链路。

最后说清楚两件事。这份记录只对我确认过的这一个型号成立,别的版本可能用不同的服务、特征或者连接行为,照抄大概率不通。另外,这类分析请只对自己的设备做——真实抓包、应用密码、设备地址别往公开仓库传,没有身份认证的局域网控制器也别直接挂到公网。