上一篇《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-00805F9BFFF0Service主服务
...9BEEE1read / writeNoResponse控制帧(开关、档位、喂食)
...9BEEE2read / writeNoResponse定时计划表(106 字节)
...9BEEE4read / writeNoResponse / notify状态回读
...9BEEE5read / writeNoResponse语音 / 交还蓝牙控制权
...9BEEEFread / 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]模式 model1=按档位 2=档位+流量等级 3=喂食(timing=分钟) 6=强制关闭
[6]档位 gear1–20
[7]流量等级0
[8]喂食时长 timing0 = 普通控制
[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 可能不一样。

⚠️ 最后提醒一句:拆包、逆向、自己写固件只针对自己买的设备。这套东西不涉及破解付费功能,只是把已经在你手里的硬件接进自己的自动化系统。别拿去做批量改机或者转卖。