ESP32-C3 双控网关:一块板子接管当贝投影仪和尼特利滤桶
上一篇《ESP32-C3 完美实现当贝投影仪纯本地 HA 接入》里,那块板子已经老老实实挂在鱼缸柜子上当投影仪的蓝牙键盘了。今年鱼缸升级,换了个尼特利 1 号变频滤桶,控制方式只有微信小程序 + 蓝牙——不能进 HA,不能定时,不能语音,出差在外想调个档位都不行。
正常思路是再买一块 ESP32,专机专用。但我盯着柜子上那块已经通着电、连着 WiFi、还有富余算力的 C3,觉得没道理再插一个电源适配器。能不能一块板子干两份活?
折腾完的结果是:可以,而且两块业务互不干扰。这篇复盘从逆向小程序协议到 HA 卡片落地的全过程,重点写那些"文档里查不到、只有真机才暴露"的坑。
一、先算清楚:为什么能复用
蓝牙协议里有个关键区别,想清楚这一层,复用方案才成立:
| 角色 | 谁在扮演 | BLE 术语 | 能不能并存 |
|---|---|---|---|
| 投影仪遥控 | ESP32 是"假键盘",投影仪来找它 | Peripheral(外设) | ✅ 可以 |
| 滤桶控制 | ESP32 主动去找滤桶 | Central(中心) |
一个是等着被连接,一个是主动去连接,方向相反,NimBLE 协议栈默认支持 3 路并发连接。所以只要两边都用同一套协议栈(NimBLE),就不存在抢占。
⚠️ 前提是两边真的在同一套协议栈里。如果 BleKeyboard 走 Bluedroid、你手写的滤桶代码走 NimBLE,就是两个协议栈抢一块射频,表现为随机连不上、内存告急。这条坑在上一篇的"补一刀"里详细写了,这里不重复。
二、把小程序拆开:协议是这么来的
尼特利没有公开协议,官方也不打算给。但它的控制逻辑全在微信小程序里,而小程序本质上就是一个打包好的前端资源包(.wxapkg)。
1. 找到包
Windows 版微信把小程序缓存在这个路径下,按 appid 分目录:
C:\Users\<你的用户名>\AppData\Roaming\Tencent\xwechat\radium\users\<一长串hash>\applet\packages\wx<appid>\<版本号>\__APP__.wxapkg
找不到就用 Everything 搜 __APP__.wxapkg,按修改时间排序,最近打开过的那个就是。
2. 解密
PC 微信 4.x 的包是加密的,文件头是 V1MMWX。解密规则:前 1024 字节走 AES-256-CBC,后面的字节走单字节 XOR。密钥由你的微信 wxid 派生:
import hashlib
from Crypto.Cipher import AES
WXID = "你的wxid" # 就是那个 hash 目录名
KEY = hashlib.pbkdf2_hmac("sha1", WXID.encode(), b"saltiest", 1000, 32)
IV = b"the iv: 16 bytes"
raw = open("__APP__.wxapkg", "rb").read()
head = AES.new(KEY, AES.MODE_CBC, IV).decrypt(raw[6:1030])
xor = ord(WXID[-2])
tail = bytes(b ^ xor for b in raw[1030:])
open("dec.wxapkg", "wb").write(head + tail)
解出来的是标准 wxapkg 格式:开头 4 字节魔数 0xBE,然后是文件数、索引表、数据区。按索引表切出来就是完整的小程序源码(这里 166 个文件,3.3 MB)。
3. 找到蓝牙那一层
在 app-service.js 里搜 createBLEConnection,顺着往下就能找到两个关键模块:utils/cmddata.js(帧编解码)和 utils/bleSdk.js(GATT 收发)。协议全在这两个文件里。
三、协议速查
服务与特征值
| 特征值 | 属性 | 用途 |
|---|---|---|
0003CBBB-0000-1000-8000-00805F9BFFF0 | Service | 主服务 |
...9BEEE1 | read / writeNoResponse | 控制帧(开关、档位、喂食) |
...9BEEE2 | read / writeNoResponse | 定时计划表(106 字节) |
...9BEEE4 | read / writeNoResponse / notify | 状态回读 |
...9BEEE5 | read / writeNoResponse | 语音 / 交还蓝牙控制权 |
...9BEEEF | read / writeNoResponse | 保留 |
设备名形如 NL NO.1S-G1,公开地址(addr type 0)。
帧加密:一个 CRC 加一个异或
没有加密算法,就是自定义的 CRC 加逐字节异或,密钥是硬编码的 ASCII Leds&Fun:
KEY = [76, 101, 100, 115, 38, 70, 117, 110] # "Leds&Fun"
def crc(data):
n = 0
for b in data:
r = b
for k in KEY:
if r & 1:
n ^= k
r = (r >> 1) & 0xFF
return n & 0xFF
def encode(plain): # 明文 -> 空口帧
c = crc(plain)
return bytes([c] + [c ^ b for b in plain])
def decode(frame): # 空口帧 -> 明文
c = frame[0]
plain = bytes([c ^ b for b in frame[1:]])
return plain if crc(plain) == c else None
控制帧(12 字节明文 → 13 字节空口)
02 01 00 01 01 model gear level timing 00 58 02
| 下标 | 含义 | 取值 |
|---|---|---|
| [0] | 固定 | 0x02 |
| [1] | 开关 | 0/1 |
| [3] | 固定 | 0x01 |
| [4] | 固定 | 0x01 |
| [5] | 模式 model | 1=按档位 2=档位+流量等级 3=喂食(timing=分钟) 6=强制关闭 |
| [6] | 档位 gear | 1–20 |
| [7] | 流量等级 | 0 |
| [8] | 喂食时长 timing | 0 = 普通控制 |
| [10] [11] | 固定 | 0x58 0x02 |
状态帧(9 字节明文)
| 下标 | 含义 |
|---|---|
| [1] | 恒为 1 |
| [2] | 工作模式:1=档位运行 2=档位+流量 3=喂食中 6=已关闭 |
| [3] | 档位(0–20) |
| [4] | 流量等级 |
| [5] | 喂食剩余分钟(非喂食时为 0) |
| [7] | 错误码 |
读状态的姿势有点反直觉:往 9BEEE4 写两个字节 02 02,然后等 notify 回 9 字节。写成 read 或者往 EEE1 写都拿不到。
四、固件:三个让成功率从 33% 到 100% 的改动
改动 1:长连接 + 空闲断开
第一版是"连上→发一帧→断开",成功率高的时候也就三成。原因是这个模组的广播间隔长,每次重连都要等好几秒,稍微急一点就超时。
改成保持长连接,空闲 20 秒再主动断开(把连接让给手机小程序,否则你回家想用小程序会发现连不上):
bool filterConnect() {
if (fClient && fClient->isConnected()) { fLastUse = millis(); return true; }
fClient = NimBLEDevice::createClient();
fClient->setConnectTimeout(5); // ⚠️ 单位是「秒」,不是毫秒
if (!fClient->connect(NimBLEAddress(mac, 0))) { ... }
// 订阅 EEE4 的 notify
...
}
⚠️
setConnectTimeout()的参数类型是uint8_t,单位秒。传 5000 会被截断成 136 秒,然后你会觉得"怎么连不上还不报错"。
改动 2:三层重试
HA 侧调用 → 固件侧重连 → 上层脚本重试,任何一层失败都不影响最终结果:
#!/bin/sh
# netlea_cmd.sh "<url>" 最多重试 4 次
url="$1"; i=1; r=""
while [ "$i" -le 4 ]; do
r=$(curl -s -m 20 "$url")
case "$r" in
*'"sent":true'*) echo "$r"; exit 0 ;;
*'"len":'*) echo "$r"; exit 0 ;;
esac
i=$((i+1)); sleep 2
done
echo "FAILED: $r"; exit 1
改动 3:写完立刻回读
控制帧发完,设备状态是异步变的。如果不回读,HA 拿到的是几秒前的旧值,开关图标会"点了没反应"。所以每个写操作后面跟一次 02 02 读:
bool ok = filterSendCtl(false, 6, gear, 0, 0);
filterReadStatusNow(); // 立刻回读,让上层拿到新状态
五、HA 侧:一个 REST 传感器喂饱所有实体
不要给每个字段建一个 REST 传感器,那会变成 6 个请求。做成一个带属性的:
- resource: "http://10.0.0.54/filter/state"
scan_interval: 30
timeout: 20
sensor:
- name: "netlea_filter"
unique_id: netlea_filter_state
value_template: "{{ value_json.gear }}"
json_attributes:
- gear
- model
- feed
- err
- connected
其余实体(开关、档位下拉、按钮)全部从属性里取,改一行配置就能加新字段。
开关实体的状态别只看档位
- switch:
- name: "滤桶"
state: >-
{% set m = state_attr('sensor.netlea_filter','model')|int(0) %}
{% set g = state_attr('sensor.netlea_filter','gear')|int(0) %}
{{ (m in [1,2,3]) and (g > 0 or m == 3) }}
turn_on:
- action: shell_command.netlea_on_cmd
- action: homeassistant.update_entity
target: {entity_id: sensor.netlea_filter}
这里的 model 判断不是洁癖,是必须的——原因见下面坑 2。
六、三个只有真机才暴露的坑
坑 1:喂食开始后 25 秒内,设备拒绝一切控制
喂食(model=3)启动后,设备会进入一个约 25 秒的保护窗口,这期间发任何控制帧都被丢弃。而且它没有"取消喂食"这条命令——小程序的做法就是重发一条普通控制帧(timing=0),把档位设回去。
所以"取消喂食"的正确实现是:先等过保护窗口,再重发当前档位。等待时间不能写死,要按已过时长算,否则喂了十分钟再取消也要白等 25 秒:
- delay:
seconds: >-
{% set fin = state_attr('timer.netlea_feed','finishes_at') %}
{% set dur = state_attr('timer.netlea_feed','duration') %}
{% if fin and dur and states('timer.netlea_feed') == 'active' %}
{% set total = as_timedelta(dur).total_seconds() | int(0) %}
{% set left = (as_timestamp(fin) - as_timestamp(now())) | int(0) %}
{{ [0, 26 - (total - left)] | max }}
{% else %}0{% endif %}
- action: timer.cancel
target: {entity_id: timer.netlea_feed}
- repeat:
count: 4
sequence:
- action: shell_command.netlea_speed_cmd
data: {n: "{{ states('sensor.lu_tong_dang_wei_ji_yi') | int(8) }}"}
- delay: {seconds: 4}
坑 2:关泵之后,档位字段还留着上次的值
这是最难查的一个。关泵后设备回报的明文是:
02 01 06 08 00 00 00 00
↑ ↑
│ └── 档位 = 8 ← 泵已经停了,但这里还是 8(记忆值)
└───── 模式 = 6 ← 真正的"已关闭"标志
我一开始按 档位 > 0 判断开关状态,结果:关泵后图标不变灰、还显示在转;再点一下想开泵,HA 以为它还开着,又发了一次"关闭"。死循环。
正确的是只看 model。顺便说,这个字段我一开始也解析错了——小程序里读的是 getInt8(4) 和 getInt8(6),照抄到这台设备上全部错位,得靠改档位、改开关、喂食,一帧一帧对比反推出来的。
坑 3:喂食和取消会抢同一条 BLE 连接
取消脚本要重试 4 次,最长跑 20 秒。这期间如果你又点了喂食,两条命令抢同一条连接,喂食会静默失败——HA 里 timer 已经变成"喂食中",设备却根本没停泵。
两处修正:
取消脚本用
mode: restart,否则连喊两次"取消"第二次会报Already running被直接丢弃。三个喂食脚本开头都先
script.turn_off掉取消脚本,让最新意图赢。
七、卡片:照小米鱼缸那套玻璃拟态抄
家里还有个米家智能鱼缸,它的卡片是 custom:button-card + card_mod 做的玻璃拟态。滤桶这张直接沿用同一套视觉语言,一行四个:
┌────────┬────────┬────────┬────────┐
│ 💡 │ 🌀 │ 🐟 │ 🔵 │
│ 60缸灯 │ 滤桶8档│ 喂食10分│蓝牙待命│
└────────┴────────┴────────┴────────┘
点滤桶卡 = 开关泵,运行时泵图标 2.5 秒转一圈;
长按滤桶卡 = 弹出档位选择(
hold_action: more-info指向select实体,不用自己写弹窗);点喂食卡 = 喂 10 分钟,喂食中再点 = 取消;
长按喂食卡 = 选 10/20/30 分钟。
关键样式(和小米鱼缸那张保持一致):
styles:
card:
- border-radius: 15px
- height: 80px
- background: rgba(255,255,255,0.05)
- box-shadow: 0 8px 32px 0 rgba(0,0,0,0.37)
- backdrop-filter: blur(10px)
- border: none
长按选时长那个下拉有个小机关:选项列表要包含一个占位的"选时长",并且让它的 state 永远停在这个值。这样每次下拉选同一个时长都算"状态变化",一定会触发——否则连续两次选 10 分钟,第二次没反应。
八、语音:小爱同学说"大缸喂鱼"
思路和《小爱同学 + HA 一键语音控制》那篇一样:监听小爱对话传感器的 content 属性做关键词匹配。但这次踩了三个新坑。
坑 1:别在网页 UI 里改模板
我用 HA 网页编辑器把"鱼缸"改成"大缸",它把模板里的换行重新序列化成了字面的 \n 和 \ ,渲染直接报 TemplateSyntaxError: unexpected char '\'。这条自动化从此永远不满足条件——症状是"第一次能用,之后怎么喊都没反应"。
模板一律用 >- 块标量写,改关键词改文件,别碰 UI 编辑器。
坑 2:连说两遍同一句话,第二次不触发
对话传感器的 state 就是这句话本身。第二遍说"大缸喂鱼",state 文本没变,trigger: state 认为"没变化"就不触发。改成监听 timestamp 属性:
triggers:
- trigger: state
entity_id: [sensor.xiaomi_lx06_ac65_conversation, sensor.xiaomi_lx04_5d57_conversation]
attribute: timestamp
坑 3:ASR 把「大缸」听成了「大纲」
翻对话历史才发现:大纲喂食、大纲喂鱼、大纲位于……关键词写死 '大缸喂鱼' 根本匹配不上。放宽成"含喂鱼/喂食 且 含缸/大纲":
{{ ('喂鱼' in c or '喂食' in c)
and ('缸' in c or '大纲' in c)
and '取消' not in c
and '不喂' not in c
and '完' not in c
and msg_ts > 0
and (as_timestamp(now()) - msg_ts) < 40 }}
最后那个 40 秒时效窗口别省。对话传感器是普通状态实体,HA 重启、集成重连都会把最后一次内容重写一遍,没有时效判断的话,重启一次就自动喂一次鱼。
最终三条自动化:
| 实体 | 触发 | 动作 |
|---|---|---|
automation.da_gang_wei_shi | 含"喂鱼/喂食"且含"缸/大纲" | 停泵 10 分钟 |
automation.da_gang_huan_shui | 含"换水",不含"取消/完" | 停泵 30 分钟 |
automation.qu_xiao_wei_shi | 含"取消/不喂/换水完" | 恢复泵运行 |
"取消"那条要额外 or 上本地的 timer 状态做条件,因为 REST 传感器 30 秒才更新一次,刚喂完就喊取消时它的 model 还是旧值,会被条件拦掉。
九、成果与成本
整件事的增量成本是 0 元——没有新硬件,只是把已经在那儿的板子多写了几百行代码。换来的是:
滤桶 20 个档位、开关、喂食,全部进 HA,能进自动化、能上仪表盘;
"大缸喂鱼"一句话停泵 10 分钟,喂完喊"取消喂鱼"恢复,不用掏手机;
手机小程序照样能用——固件空闲 20 秒自动断开,把连接让出来。
投影仪那一侧的遥控完全没受影响,这是设计时就划死的红线:双控可以,但不能让任何一边变差。
十、源码
完整可编译固件(双控 + OTA + Web 配置门户 + 协议文档 + HA 配置片段):
https://github.com/lee1985-cn/dangbei-netlea-hub
仓库里有一份 docs/PROTOCOL.md,把上面所有帧格式、位布局、以及"哪些字段小程序读错了"都记下来了。如果你手上的不是 1 号而是别的型号,建议先跑一遍 /filter/dump 把 GATT 表打出来对一遍——尼特利不同型号的特征值 UUID 可能不一样。
⚠️ 最后提醒一句:拆包、逆向、自己写固件只针对自己买的设备。这套东西不涉及破解付费功能,只是把已经在你手里的硬件接进自己的自动化系统。别拿去做批量改机或者转卖。