本章讲 App 与中继盒从发现到断开的完整链路,对应协议层 v4.2 §BLE 通信通道 的 AE03 / AE05。CM4 与 legacy 的差异(family-specific 心跳 / MTU / 命令编码)由 lib/core/transport/ 抽象掉,本章除特别说明外按 family 分别给出。
lib/core/services/pending_intent_scan_service.dart —— Android PendingIntent 背景扫描桥接:封装 culinatech.app/pending_intent_scan MethodChannel,注册/停止 OS 托管的 BluetoothLeScanner.startScan(..., pendingIntent),并把命中的扫描结果回推给 Dart 层。前文提到的后台 pending-intent-match 直连链路,源码实际由这个服务承接。
lib/core/services/ble_service.dart(2122 行)—— BLE 底层 I/O:连接 / 特征发现 / R/W / 订阅 + _connectGate 串行化连接 + 锁刷新写按 deviceId.hashCode.abs() % (heartbeatIntervalSeconds * 1000) 毫秒相位错峰(ble_service.dart:1422)
lib/core/services/ble_device_service.dart(4177 行)—— 多设备编排、心跳、重连、入仓关机处理、staleness 检查、readRssi() 周期采样
lib/core/transport/ —— family 抽象:booster_transport.dart 接口 + booster_family.dart 心跳元数据 + legacy_booster_transport.dart / cm4_booster_transport.dart 两个实现
lib/core/protocol/ —— 包编解码与 LUT
cm4_data_parser.dart(343 行)—— CM4 ASCII 推送解析(D=/PENON/PENOFF/SETT==/WIFI_STS=)legacy_data_parser.dart(429 行)—— legacy 二进制解析(12B/15B/2B/0x55AA)temperature_lookup.dart(199 行)—— tempIntArray / tempExtArray 共享 LUTcm4_protocol.dart(208 行)/ legacy_protocol.dart(157 行)—— 各自的命令常量booster_message.dart(389 行)—— 统一的消息类型层(CM4Message 家族)补充:Add Device 页的首次用户扫描还有一层冷启动节流保护。代码会在第一次进入扫描时读取上次扫描时间;如果距离上次扫描未超过 35 秒,则先额外等待 5 秒,再启动本轮扫描,用来避开 Android 的 BLE 扫描节流窗口。只有当持久化的上次扫描时间为空,或已经超过 35 秒时,才会跳过这段 5 秒等待。
补充:当前扫描受 BleService 的全局仲裁控制,不是任意调用都能立刻开始。代码把扫描分成 idle / debugScan / reconnectScan / userScan 四种优先级;其中 12 秒的 guardScan() 冷却当前只用于用户扫描和调试扫描。补充:调试扫描(scanForAllDevices())还有一层额外门控——若当前已有 legacy indicate 设备连接(hasIndicateConnections),会直接记录 SCAN_TRIGGER_BLOCKED 并关闭扫描流,不再调用 guardScan() / startScan(),以避免扫描与 indicate 订阅互相干扰导致断连。重连扫描路径明确不调用 guardScan(),仅受 acquireScan/releaseScan 的优先级互斥控制。也就是说,前台用户扫描、调试扫描和重连扫描之间存在显式的抢占/拒绝关系。
补充:当前代码在 Android 进入后台后,还会注册一条 OS 托管的 PendingIntent 扫描链路。这条链路按 MAC 列表注册过滤,并使用 FIRST_MATCH 语义:同一设备在同一可见性窗口内只回调一次;另外 Dart 侧会缓存当前已注册的 MAC 列表;只有当新旧列表长度一致且每个位置的 MAC 都相同,才会跳过再次下发 start,因此这里不是无序集合比较。它会把已跟踪设备的已保存 MAC 列表交给 PendingIntentScanService,命中后直接走 directConnect(..., trigger: 'pending-intent-match');回到前台时再停止这条后台扫描。也就是说,后台重连并不只依赖应用进程内的普通 startScan() 循环。
ScanPage 会启动扫描;App 启动时 seedReconnectQueue 通常会先对有已保存 remoteId 的已知设备走 directConnect,仅无保存地址或直连失败的设备才落入扫描式重连;但若启动期蓝牙适配器在 5 秒内未到 BluetoothAdapterState.on,代码会直接把这批设备整体转入扫描式重连,不先逐台发起 directConnect。advName 识别;若 advName 为空,则回退到 BluetoothDevice.platformName,再按名称前缀 CM / MW 判断是否接受。BluetoothAdapterState.on,超时 5 秒
CBManagerStateUnknown,此时 connect 必失败FlutterBluePlus.startScan(...),单次扫描超时 8 秒,随后先等待 2 秒;但扫描启动还受 BleService.guardScan() 的 12 秒全局冷却限制,若距离上次扫描过近会直接阻止本轮,并按 3 秒步进继续等待直到允许下一轮启动。;同时启用 continuousUpdates: true 与 removeIfGone: 20s。另外,当前实现对 iOS 与 Android 分支做了区分:iOS 扫描时额外传 withKeywords: ['CM', 'MW'],而 Android 分支不下发这组 OS 级关键字过滤,仍由 Dart 层在扫描结果上按设备名规则筛选。重连循环的扫描是另一条路径:会按已保存 remoteId 传入 withRemoteIds 做 OS 级过滤,单次扫描窗口为 6 秒。Strategy A / Strategy B 实际是 AE05 接收阶段的两种读数处理策略,不属于扫描逻辑。_scanEntryTtl = 20s 内未再次广播的条目从列表清除;_scanReemitInterval = 2s 控制 re-emit 节奏补充:当前 scan-based connect() 不是单次 15 秒后直接结束。代码会在非 timeout 的瞬时 GATT 错误上最多尝试 3 次,每次失败后先 disconnect() 释放半开的原生 GATT client,再等待 400 ms 重试;只有真正的 timeout 才直接返回 ConnectTimeout,不进入重试。
FlutterBluePlus.lastScanResults,再查 FlutterBluePlus.systemDevices([]),最后查已保存的 remoteId;命中任一层后再拿到对应 BluetoothDevice 并继续连接。补充:若三层都未命中,connect() 会直接返回 ConnectDeviceUnreachable,不会调用 device.connect() 发起 GATT 连接。这条结果表示设备当前不可达(例如未广播、超出范围或已关机),与后续真正发起连接后才可能出现的 ConnectTimeout / ConnectGattError 是不同分支。
补充:如果第 3 层已保存 remoteId 的连接最终返回 ConnectTimeout,代码会立即清除该设备持久化的 remoteId,避免下次再次在失效地址上白等一个 15 秒超时;普通 ConnectGattError 不会触发这一步。
2. _connectGate 串行化(ble_service.dart:351)—— 7 台已知设备启动 seed reconnect 时不会并发抢 CCCD 写 / MTU 协商 / indicate 订阅
补充:当前 connect() 在排队等待 _connectGate 时,会在真正进入 gate 后再次检查该设备是否已经连上;如果自动重连等并发路径已先一步成功,代码会直接返回 ConnectSuccess,避免对同一中继盒再开第二个 GATT 连接。
3. 调 device.connect(),超时 15 秒(directConnect 路径为 5 秒)
4. CM4 在 Android 平台协商 512 字节 MTU(BoosterFamilyExt.requestsLargeMtu 且 Platform.isAndroid,iOS 由系统自动协商);legacy 走默认 MTU
5. 发现服务 AE30 → 解析特征 AE03(write)+ AE05;代码依据 AE05 的 characteristic properties(notify / indicate / read)选择数据路径,不是按 advertised 属性
6. AE05 数据路径由 characteristic properties 决定:仅当 notify 不可用而 indicate 可用时,先尝试 AE05.read(),成功则每 400 ms 轮询;读取失败才使用 lastValueStream + setNotifyValue(true) 的缓冲路径。其余情形走 onValueReceived + setNotifyValue(true) 的直接通知路径。
轮询路径虽每 400 ms 发起一次读取,但同一探针距上次已处理读数不足 800 ms 时会以 THROTTLE_DROP 丢弃;读取失败后的缓冲路径则每 800 ms 调用一次 _flushProbeBuffer(),每个 probe bucket 在一个刷新窗口内只保留最新包。
7. 标记设备为 connected,放入 BleService 的 _connections map,开始发射遥测
8. 立即发一次心跳,由 BleService._sendHeartbeat 直接通过 BoosterFamilyExt.heartbeatBytes 获取字节并发送:
CNT_00x55B1(即锁刷新命令;同时充当初始心跳)BoosterFamilyExt.heartbeatIntervalSeconds 是单一来源:
| Family | 心跳字节 | 间隔 | 语义 |
|---|---|---|---|
| Legacy(CM1/2/3 / MW3/4/5) | 0x55B1 |
45 s | 独占锁刷新(60 s TTL,超时其他手机可抢) |
| CM4 | ASCII CNT_0 |
45 s | 独占锁刷新,同时也用于保持 BLE 链路存活。当前代码将 CM4 CNT_0 作为保持 BLE 链路存活的心跳:60 秒内未收到时,固件会主动关闭 BLE 链路。legacy 0x55B1 则是独占锁刷新,不是 keepalive;停止刷新 60 秒后锁会过期。两者虽同为 45 s 周期,但语义不同。 |
锁刷新写按 deviceId.hashCode.abs() % (heartbeatIntervalSeconds * 1000) 毫秒相位错峰(ble_service.dart:1422)——多台中继盒同时连上时,第一次周期写不会撞在同一墙钟时刻,避开 Xiaomi/Huawei 浅 GATT 写队列饱和而丢写(per Liang 2026-04-30 audit reply)。Liang 原话:「不同的中继盒,按顺序发 0x55B1 就好,中继盒已经保留了微小的容错机制」。
CM4 与 legacy 的 RX payload 完全不同——CM4 是 ASCII,legacy 是 raw bytes。两个 parser 分文件处理,输出统一到 CM4Message 家族(booster_message.dart)供上层消费。
legacy_data_parser.dart)补充:当前 legacy parser 除了 2/3/8 字节 0x55AA 入仓通知外,还会单独识别 8 字节 0x55B0 探针状态通知([0x55, 0xB0, a0..a5])。这类包会解析出后 6 字节作为探针 MAC,并产出 CM4ProbeAddressNotification 交给设备服务层处理;它不会被当作 dock 事件。
| 包类型 | 长度 | 频率 | 作用 |
|---|---|---|---|
| 设置响应 / 状态 | 12B | 每 3 秒 | 心跳 + 目标温度 + 报警状态 |
| 探针温度 | 15B | 每 3–6 秒(探针活跃时) | 内温 + 环境温 + 电量 + MAC |
入仓通知 0x55AA |
2/3/8B | 事件驱动 | 探针入仓 → 触发中继盒关机链 |
| 心跳(仅电量 + 版本) | 2B | 每 3 秒(无探针活跃时) | 保活 |
字节级布局见 v4.2 §一、App 接收。
cm4_data_parser.dart)补充:当前代码在 CM4 建链完成后,会先立即通过 transport.queryStatus() 主动发送一次 SET_RD,预期拉回一轮 SETT== 设置快照;因此连接后的首批设置同步并不只依赖用户在中继盒上物理改设置后的 unsolicited push。
| 形式 | 触发 | 作用 |
|---|---|---|
D=<至少30 hex>\r\n |
每根活跃探针 ~3 s 一帧(不合并,per Beta 2026-05-11 8b47f3e) | 探针遥测;基础布局与 legacy 15-byte 包相同,但当前代码还支持在 RSSI 后追加 3 字节 H/M/S cook timer 的 18-byte 变体,仍走 temperature_lookup.dart 共享 LUT |
PENOFF <id> |
探针物理下线 | 当前固件只会主动 push PENOFF;代码保留 PENON 解析仅为兼容旧固件/旧抓包。id 当前只按数字(1..4)解析;cm4_data_parser.dart 的 _parseProbeIdentifier() 对其它字符串返回 null。 |
SETT==<32 hex>\r\n |
用户在中继盒物理按键改设置 → unsolicited push(BLE + MQTT);也是 SET_RD 查询的响应 |
16-byte 解码;当前 App 已实现按 probe 目标温度的同步采纳(CM4_ALARM_SYNC_ADOPT,回写缓存并触发 onProbeTargetReconciled)(详见 CM4 协议 §Q3) |
WIFI_STS=N |
固件可能 push | parser 会解析为 CM4WifiStatus,供上层更新 WiFi 状态/信号 |
MQTT_STS=N |
固件按 10 s push | 解析为 CM4MqttStatus,更新中继盒自己的 broker 连接状态 |
M_ID OK / M_PD OK |
配网写入确认 | 解析为 CM4CredAck,表示 MQTT 账号 / 密码已写入中继盒 |
Ver=...+MAC... / RI_CNT=N |
查询响应 | 当前代码至少在连接后主动查询并解析 VER 与 RI_CNT;其中 RI_CNT=N 是蜂鸣器 ring count 的回包 |
补充:当前代码还会在连接后再延迟 900 ms 主动发送 TEMP=?,并把 TEMP_INT / TEMP_EXT / TEMP_ALL 回包解析为 CM4DisplayMode,通过 onDisplayModeReported 同步上层所显示的中继盒 LCD 显示模式。
CM4 数据通道双轨:BLE 与 MQTT 都可达时固件同时往两个通道推(per Beta 2026-05-11,55d699a),App 在 provider 层通过 DeviceConnectionManager.cloudActiveDeviceIds 判定某设备当前是否由 cloud 作为主通道;当设备处于 cloud-active 且中继盒自身上行未下线时,代码不会把对应的 BLE 遥测直接整帧丢弃,而是先将其中更鲜的探针 telemetry slot 覆写到缓存的 cloud 快照(_applyBleFreshPatch),随后才提前返回。也就是说,cloud 仍是中继盒级字段与连接记账的主通道,但探针级温度新鲜度会优先吸收 BLE 的更新。详见 MQTT 与云端 §双通道遥测。
temperature_lookup.dart)reverseLookup(intRaw, tempIntArray) 反查得到 °CreverseLookup(extRaw, tempExtArray) 反查得到 °CisInternalTempBelowRange / isInternalTempAboveRange),UI 显示 "LO" / "HI"详见 v4.2 §四、温度查表。
15B 包携带完整 6 字节 MAC(on-wire 反序),12B 包携带压缩 4 字节(去掉固定的 0x50 / 0x32)。legacy_protocol.dart 有 reconstructMacFromCompressed() 做还原。交叉比对:App 每次收到 12B 时对比重构 MAC 与 15B 反转 MAC,不一致打 SETTINGS_MAC_MISMATCH 警告——这是 v4.1 发现 MAC 字节序 bug 的关键手段,保留,不要删。
BoosterTransport 抽象了大部分 family 命令差异;但切单位是一个例外:CM4 会走 transport.setUnit(...),legacy 则在 BleDeviceService.setUnit() 中直接跳过,不下发 0x55AB,仅等待下一次 SET_TARGET 再让中继盒物理屏跟随单位。
补充:CM4 连接建立后,若 appUnitCelsius 已知,代码还会在连接后延迟 1200 ms 主动发送一次 SET_C 或 SET_F,把中继盒显示单位与 App 当前单位对齐;这不是只在用户手动切单位时才发生。
补充:当前代码另外提供了 BleDeviceService.sendBoosterSettingTo() 用于中继盒级设置命令(如 CM4 的 RI_CNT=、显示模式等)。这条路径会走 transport 的 sendSetting(...):对 CM4 而言,若当前处于 cloud-primary 模式则会优先经 MQTT /CM4/<id>/apps 发布;若不是 cloud-primary,则 GATT 在线时走 BLE,BLE 不在线时再回退到 MQTT /CM4/<id>/apps,因此并非所有这类设置命令都一定经 AE03 写出。
legacy_protocol.dart)| 命令 | 字节 | 作用 |
|---|---|---|
querySettings |
0x55 AE 00 00 |
查询设置(立即回 12B) |
lockRefresh |
0x55 B1 |
锁刷新 + 初始心跳 |
muteAlarm |
0x55 AD 00 00 |
静音报警 |
setFahrenheit / setCelsius |
0x55 AB 00 [00/01] |
切换中继盒物理屏单位 |
setTarget |
[0x55, cmd, unit, tempByte, a0..a5] |
10 字节 SET_TARGET(详见 BLE 协议 §独占锁机制 的 2026-04-30 final clarification) |
颜色 → 命令字节映射(commandByteFromAddress):0x0A/0x0B → 0xAF(黑)、0x0D → 0xB0(白)、0x0E → 0xB2(蓝)。legacy 物理上从无 yellow probe——probeCommandByte(probe4) fallback 也返 0xB2 但实际 unreachable,详见 v4.2 §二 · 探针颜色 与 legacy_protocol.dart:72-87。
⚠️
0x55B1是锁刷新,不是蓝针命令。Beta 早期曾误说蓝针字节是0x55B1——Liang 已确认蓝针是0x55B2。
cm4_protocol.dart)ASCII 字符串:SET_RD / SET_F / SET_C / RING_OFF / CNT_0 / SET_<color>=ABCDEF / BL_LVL=N / RI_LVL=N / SSID=… / PSWD=… / HOST=? / HOST=… / OTA / BT_STS=N / SET_H=XY / SET_L=XY / CLK=XY。当前代码已将旧的 USUS=N 区域索引命令退役,broker 地址改由 HOST 读写。
补充:cm4_protocol.dart 里还定义了 BL_LVL=? / RI_LVL=? / RI_CNT=? / TEMP=? 查询命令,以及 BLE-only 的 M_ID= / M_PD= / WIFI=0|1。其中 WIFI=0|1 用于开关中继盒 WiFi radio,M_ID= / M_PD= 用于写入中继盒自身的 MQTT 账号密码。
探针配置 SET_<color>=ABCDEF[G]:A=报警开关(1=开启并解析 B-F,0=关闭并丢弃后续字段)、B=单位、CD=目标温度(两位大写 hex)、E=肉种(1–9)、F=熟度(1–5)、可选 G=Cm4WorkState(0=stop,1=start,2=keep)。当前 App 的 CM4 探针配置发送路径默认会补 Cm4WorkState.keep(G=2),因此常规写出也是 7 位 SET_<color>=ABCDEFG;并非未传 workState 就仍发送旧 6 位格式。<color> 当前是色名前缀 SET_BL/WH/BU/YE(per cloud doc 2026-05-19,6e5dfb7 反向恢复——本周内已 flip-flop 两次,新 agent 在改 prefix 之前请重读 CM4 协议 §探针配置)。同设备相邻 AE03 写之间至少 ≥200 ms——CM4 固件无接收缓冲,过短 burst 会丢;当前 App 无 burst loop 但 BleService.sendCommandTo dartdoc 已警示。
完整列表见 CM4 协议 §App → 中继盒命令。
补充:除单设备/全设备 disconnect() 外,BleService 还提供显式 shutdown() 作为应用退出路径。它会先尝试停止当前 BLE 扫描,再并行释放全部连接;整体等待上限为 1.5 秒,而单设备内部的 teardown 与底层 disconnect() 也各自有 500 ms 超时保护,因此这里是一个幂等且有界的退出流程,不是简单的 fire-and-forget 断开。
disconnect():置标志位、清 grace timer、停心跳 / staleness / RSSI / no-probe-telemetry 各种 timer分两类处理(核心在 ble_device_service.dart 的 _onUnexpectedDisconnect):
Per Liang 2026-04-28:每个中继盒型号的
0x55AA → 0x07延迟是固定值(典型 ~70 ms,最高 1 秒以内属正常)。_dockShutoffWindow = 1s收紧后窗口尾部的无关0x07不会被误分类为入仓关机;旧 5 秒窗口太宽,会绕过 90 秒宽限期、直接给用户显示 "Booster off",而链路其实还能恢复。1 秒覆盖四个 CM 型号所有合理的固件延迟。
A. 入仓触发关机(_lastDockEventAt 在 1 秒内)
DOCK_TRIGGERED_SHUTOFFDeviceStatus.boosterShuttingDown_dockShutoffUiDelay)结束后发 DeviceStatus.boosterOff_dockShutoffReconnectDelay)初始延迟排队(中继盒大约 4 s 关机 + 4~8 s 启动)B. 非入仓断开(没有近期 0x55AA)
gracePeriod,原理:legacy 锁 TTL 60 s + 30 s 重连 margin),在底层持续静默重试device_providers.dart 的 _lostConnectionAfter,per Liang 2026-07-02 TAPD #1003347 起与 cloud 分支统一为同一常量,取代旧的 15 秒判据)即翻 DeviceStatus.lostConnection + UI banner "Reconnecting…" + AlarmType.boosterDisconnected 单声+通知——这早于 90 秒 BLE 宽限期结束;BLE 服务层仍在底层继续静默重试到 90 秒整,60–90 秒之间任何一次重连成功都会让卡片无声地翻回在线态、alarm 清除。详见 重连与宽限期 §90 秒宽限期0x07 断开可以在 0x55AA 字节到达后 ~65 ms 就发生_onAe05Rx 钩子在字节层(parser 之前)就给 _lastDockEventAt[deviceId] 打戳(ble_device_service.dart:308-329)_DeviceState.stalenessTimer 每 3 s 跑一次:
| 触发 | 阈值 | 动作 |
|---|---|---|
| 任一类包到达 | — | lastBoosterData 重置;条件不命中 |
| 15B 沉默 | 20 s(_probeDropoutThreshold,旧 10 s 阈值因 12-15 s 正常 jitter 误报已上调) |
探针 Probe.isConnected = false、UI 卡片置灰、emit PROBE_DROPOUT |
| 12B & 15B & 2B 全沉默 | 60 s(v4 时 15 s,Liang 2026-04-30 audit 提高) | DeviceStatus.lostConnection、AlarmType.boosterDisconnected |
中继盒 RSSI 由 BluetoothDevice.readRssi() 每 10 s 采样一次(ble_device_service.dart:3516),写入 Booster.rssi。
除静默诊断外,统计 timer 还会在 5 秒窗口内发现原始 AE05 回调数大于已处理数时记录 BLE_BACKLOG;日志会带出 raw、processed 与差值,用于指示节流丢弃、解析提前退出,或数据仍在 flushTimer 缓冲等待处理。
AE05 链路还有静默诊断:统计 timer 每 5 秒检查一次;若连续两次均无原始回调和已处理数据,会记录 BLE_SILENT,表示该连接已 10 秒没有 AE05 流量、链路可能停滞。
所有 BLE 事件都通过 DevLogService 记录,带 packet seq #<seq> 关联。开发者模式下可导出全部日志给 Liang 诊断。详见开发与调试。