这是 AI 直接读
lib/core/protocol/+lib/core/transport/+mqtt_service.dart反推出来的 CM4 完整协议,作为人写版(CM4 协议 +protocol-docs/CulinaTech_CM4_Protocol_0512.md,Beta 原稿)的交叉校对件。每条都带file:line。两版若有出入,以代码为准、以 Beta 固件实测为最终裁决。
CM4 = 设备名前缀 CM4_ 的 4 探针中继盒,是唯一走 WiFi/MQTT 云端的家族;其余(CM1/CM2/CM3/MW3/MW4/MW5)一律 legacy、仅 BLE。家族判定见 §7。
lib/core/protocol/cm4_protocol.dart(class CM4Command)—— 全部 CM4 ASCII 命令 + SET_=ABCDEF 字段语义lib/core/protocol/cm4_data_parser.dart(约 559 行)—— 全部上行 ASCII 消息形状(D=/SETT==/WIFI_STS=/MQTT_STS=/VER=/ack)lib/core/protocol/legacy_data_parser.dart —— legacy 二进制帧解析;其中与 CM4 D= payload 共用的是 15+ 字节遥测解析:基础 15 字节布局与 legacy 相同,而 CM4 V19+ 的 18 字节包还会在 RSSI 后追加 3 个 cook timer 字节;12B 设置帧、2B 心跳、0x55AA/0x55B0 通知仍是 legacy 专用lib/core/protocol/temperature_lookup.dart —— 电量解码 + ADC→°C 查表lib/core/protocol/booster_message.dart(约 534 行)—— 解析输出的类型化模型lib/core/models/probe.dart —— 颜色↔地址↔肉种↔熟度映射lib/core/services/mqtt_service.dart(约 1401 行)—— MQTT topic / 鉴权 / TLSlib/core/transport/booster_family.dart —— 家族路由 + 心跳元数据CM4 与 legacy 共用一套 GATT 管道(ble_service.dart:35-37,CM4BleConstants):
| 角色 | UUID |
|---|---|
| Service | AE30 |
| 写特征 Write | AE03 |
| 通知特征 Notify | AE05 |
家族分流解析器:ble_service.dart:1786-1787 / 1871-1872 / 1954-1955 —— deviceName.boosterFamily.isCm4 ? Cm4DataParser.parse(...) : LegacyDataParser.parse(...)。CM4 仅在 Android 上请求 512 字节 MTU;代码不会在所有平台统一发起该请求。
⚠️ BLE 与 MQTT 的优先级已反转(
device_connection_manager.dart顶部 dartdoc):Per Liang 2026-06-29,App 现在是 MQTT-primary——这条取代了 2026-06-06(TAPD #1003176)定的 BLE-primary 规则。WiFi 已配置 + broker 可达时,云端是活跃遥测通道;BLE 只保留作CNT_0独占锁 +HOST=切区通道,不是遥测来源。broker 断开时才回落到 BLE 遥测。这是为了给用户 Liang 想要的"中继盒→手机"更大有效范围。07-CM4协议.md 的心跳章节仍写着旧的 "BLE-primary" 规则,已过时,另行修正。
补充:RING_OFF 只是 buzzer-only 的静音命令。若设备当前并未响铃,或已处于 RI_CNT=4 / 定时响铃已自然结束,它会是 no-op;此时闹钟图标与背光闪烁不会被清除。代码注释明确要求在这类场景下配合 SET_{COLOR}=000000<G>(disarmProbe())一起清掉告警呈现。
CM4Command(cm4_protocol.dart)。同一设备的连续 AE03 写在协议上要求 ≥200 ms 间隔;但当前 BleService.sendCommandTo() 没有内建写节流,如果未来需要对同一设备连续发多条命令,调用方必须自行插入 200 ms 延迟。
另外,源码还定义了一个独立于 WIFI_STS= 的 WiFi 射频开关命令:WIFI=1 开启、WIFI=0 关闭;它仍是 BLE-only 命令,WIFI_STS= 也仍然只是状态值写入/上报,不是这个开关的别名。需要注意的是,当前代码注释已明确 App 不再发送 WIFI=0;连接模式切换只改变当前 App 自己使用的通道,盒子 WiFi 保持开启以支持多手机,当前仅在用户切到 WiFi(Cloud)时还可能发送一次 WIFI=1,用于唤醒曾被旧版本 App 设为休眠的盒子。
补充:HOST=<url> 也有明确的运行约束。它是 BLE-only 写命令,只会让盒子重连 MQTT broker,不会重启盒子、也不会断开 BLE/GATT;而 HOST=? 在盒子从未写入 host 时,默认回读的哨兵字符串固定是 Default mqtt broker!。
⚠️ 下表 file:line 已按 cm4_protocol.dart 当前 455 行的实际内容重新核对(此前版本引用的是旧行号,该文件自那以后新增了不少内容,行号已整体后移,不再对应)。
| 命令 | 线上字符串 | 说明 | 位置 |
|---|---|---|---|
| 读设置 | SET_RD |
拉取盒子当前全部设置(回 SETT==) |
:42 |
| 查版本 | VER |
回 Ver=V..;较新固件可带 +MAC..,旧固件可能没有 MAC 段 |
:45 |
| 查 Broker Host | HOST=? |
查询盒子当前 MQTT broker 地址;若从未写入则返回默认哨兵字符串 | :52(哨兵字符串 :56) |
| 单位 | SET_F / SET_C |
切 °F / °C | :61,:64 |
| 停响铃 | RING_OFF |
静音当前正在响的报警 | :75 |
| 单探针撤防 | SET_{COLOR}=000000<G> |
关某探针报警;当前 disarmProbe() 默认发送 G=2(keep) |
:98(disarmProbe()) |
| 心跳/锁 | CNT_0 |
连接计数心跳 = CM4 的独占锁刷新(见 §7) | :105 |
| OTA | OTA |
:110 |
|
| 显示模式 | TEMP_INT / TEMP_EXT / TEMP_ALL |
内温/外温/全显 | :133-135 |
| 显示模式查询 | TEMP=? |
:140 |
|
| 探针目标 | SET_BL / SET_WH / SET_BU / SET_YE |
Black/White/Blue/Yellow 前缀(见 §2.1) | :181,184,187,190 |
| 高/低报警、时钟 | SET_H= / SET_L= / CLK= |
:197-215 |
|
| 背光 | BL_LVL=N / BL_LVL=? |
:223,229 |
|
| 响铃音量 | RI_LVL=N / RI_LVL=? |
:235,241 |
|
| 响铃档/静音 | RI_CNT=N / RI_CNT=? |
N=0 持续 / 1=3 声 / 2=1 分钟 / 3=5 分钟 / 4=Mute | :249,255 |
| 蓝牙状态 | BT_STS=N |
:258 |
|
| MQTT 凭据(配网) | M_ID=<deviceId> / M_PD=<pw> |
配网前置序言,落 NVS(V13 回 M_ID OK/M_PD OK) |
:269,273 |
| WiFi 配网 | SSID=<name> → PSWD=<pw> |
顺序敏感;收 PSWD= 后盒子重启 |
:281,288 |
| WiFi 状态设置 | WIFI_STS=N |
设置 WiFi 状态值(0–4),不是查询命令 | :293 |
| WiFi 射频开关 | WIFI=1(当前 App 实际仅发送这一种) |
BLE-only;仅在用户切到 WiFi(Cloud)且盒子当前自报 WiFi down 时,用于唤醒曾被旧版 App 设为休眠的 WiFi;当前代码已明确不再发送 WIFI=0 |
:306 |
| Broker Host | HOST=<url> |
直接写入完整 broker 连接串(如 mqtts://cm4-hk.culinatech.shop:8883),不再使用区域索引 |
:325 |
✅
RI_CNT=4(Mute)解析已支持:下行可发RI_CNT=4,上行_parseRingCount(cm4_data_parser.dart)已接受0..4为合法值(n > 4才当非法丢弃),App 可正常读回 device-level Mute 状态。⚠️ TAPD #1003138 是响铃时长功能本身的原始 ticket(cm4_protocol.dart:243-244),不是这次解析放宽的 ticket——放宽是 commit6cfc6ee2(2026-06-23),修_parseRingCount原本0..3丢弃RI_CNT=4的 bug,commit message 本身未挂 TAPD 号;此前版本把两者混为一谈已修正。
configureProbe() —— SET_<prefix>=ABCDEF(核心命令)另外,源码还提供了这条命令的逆解析器 ProbeConfigCommand.tryParse(...),用于处理 MQTT /sett 上的多手机设置镜像。它同时接受 6 字段 SET_<color>=ABCDEF 与 7 字段 SET_<color>=ABCDEFG,按前缀把 SET_BL/WH/BU/YE 映射为 1–4 号探针;若第 7 位存在,则 0=stop、1=start,其他任意值都会按 keep 处理。未知前缀、缺失 =、payload 不是 6/7 位、或前 6 位不是十六进制时,都会直接返回 null。
cm4_protocol.dart:340-365(configureProbe());字段语义现直接写在函数自身的 dartdoc + 函数体内联注释里,不再是独立的语义文档块。当前基础格式仍是 SET_<prefix>=ABCDEF,但源码已支持可选第 7 位 G,形成 SET_<prefix>=ABCDEFG:G=0 表示 stop、G=1 表示 start、G=2 表示 keep(仅保持现有 work-state,不改动启停状态)。只有调用方传入 workState 时才会追加该位;未传入时仍发送原 6 字段格式。 但需要补充:当前 App 的实际下发路径 Cm4BoosterTransport.setProbeTarget() 会把缺省 workState 自动补成 Cm4WorkState.keep,所以普通目标温度写入默认也会发送 7 字段 SET_<color>=ABCDEFG,其中 G=2。同一处实现还把未显式提供的 meatType / doneness 默认补成 E=1(Custom)与 F=3(Medium)。6 字段每位含义:
| 位 | 字段 | 含义 |
|---|---|---|
| A | alarm-enable | 必须为 1 才布防;A=0 关报警,且固件会丢弃后面 B–F(per Beta 2026-05-25) |
| B | unit | 显示单位 |
| CD | temp(hex) | 目标温度,2 位 hex;源码在 configureProbe() 中要求 targetTemp 位于 0x00..0xFF(以断言约束该范围)。 |
| E | meat | 肉种 = 固件表索引 1–9(Custom=1 … Turkey=9),与 LCD 顺序无关;E=2..9 时固件用自己的预设温度 |
| F | doneness | 熟度 1–5(rare=1 … wellDone=5) |
源码当前还支持显示模式查询的上行回复:TEMP_INT / TEMP_EXT / TEMP_ALL,以及防御性兼容的 TEMP=INT / TEMP=EXT / TEMP=ALL 形式。对应 TEMP=? 查询时,解析器会把这些回复映射成 CM4DisplayMode;若收到的是自身查询回显 TEMP=?,则会忽略而不产出消息。
当前 BLE 连接流程还会在 CM4 连接建立后再延迟 900 ms 主动发送一次 TEMP=?,用于把设备当前真实显示模式读回并刷新到 App;因此这一步并不是只能依赖用户手动触发查询。这是 connect 时序里的第 4 步,完整序列是(ble_device_service.dart:_finishConnect 附近,每步间隔 ≥200ms 满足 AE03 同设备 write-pacing):SET_RD(t=0)→ VER 查版本+WiFi MAC(+300ms,TAPD #1003141)→ RI_CNT=? 查响铃档(+600ms,TAPD #1003138)→ TEMP=? 查显示模式(+900ms,TAPD #1003204)→ 若本机已解析出温标单位,再发一次 SET_C/SET_F 把 App 的温标单位镜像到中继盒(+1200ms,TAPD #1003334——首次安装时中继盒尚未配对、启动时的全局单位广播漏了它,这一步补上这个 gap,对已经是目标单位的盒子是 no-op)。
Cm4DataParser.parse(cm4_data_parser.dart),识别形状文档在 :1-72:
补充:解析入口在按消息形状分支前还做了两个防御性处理。其一,若首字节 < 0x20 或等于 0x55,会直接判定为误路由到 CM4 解析器的 legacy 二进制帧并返回 null。其二,会先把整条 ASCII 通知中的 \x00 全部去掉再 trim();这是因为 V18 固件会在 \r\n 终止符之后追加 NUL padding,不先剔除会让 D= 的 hex 解码因尾部 \r\n\x00... 抛 FormatException。
另外,源码还支持 OTA 进度上行 OTA=N%。解析器会容忍缺失 % 的形式,并把数值钳到 0..100 后产出 CM4OtaProgress;注释明确该上报同时走 BLE 与 MQTT /sett。
| 上行消息 | 含义 | 位置 |
|---|---|---|
PENON <N> / PENOFF <N> |
探针插入/拔出(当前固件无 PENON,只推 PENOFF) |
_parsePenStatus :262-289 |
SETT==<32hex> |
设置帧(16 字节)—— 物理按键改设置时的 unsolicited push | _parseSettAscii :291-369 |
WIFI_STS=N(+ V13 RSSI=-NNdB) |
WiFi 连接状态(0=断 / 1-4 格) | _parseWifiStatus :371-394 |
MQTT_STS=N(V13 起每 10 s BLE 推) |
盒子自报 broker session 健康(N=1 = Beta 指定「配网成功」信号) | _parseMqttStatus :396-408 |
M_ID OK / M_PD OK |
凭据落 NVS 的 ack(V13) | :148-155 |
RI_CNT=N |
响铃档回复(见 §2.1 缺口) | _parseRingCount :415-427 |
Ver=V08+MAC.. |
版本回复(这里的 V<n> 按十进制解析,如 V19 = 19;它表示 ESP/WiFi 芯片版本,不是 D= 遥测里的 JieLi 固件字节) |
_parseVersionInfo :468-494 |
<id> online |
V14 broker-login 公告(落 /sett) |
:198-211 |
D=<hex> |
遥测帧 → hex 解码后委托 LegacyDataParser.parseDataBytes(每探针 ~3 s,不合并) |
:178-223 |
⚠️ 上表 file:line 已重新核对(此前版本引用的是旧行号——
cm4_data_parser.dart已从早期版本增长到当前 559 行,所有函数位置整体后移,之前每一行引用都对不上;本次按当前文件内容逐一重新定位)。
D= payload,两家族共用)legacy_data_parser.dart。CM4 的 D=<hex> 解码后会复用 legacy 的遥测解码路径,但当前代码已不只限于 15 字节:基础遥测仍是前 15 字节兼容 legacy 布局,而 V19+ 还支持 18 字节包,在 RSSI 之后追加 3 个 cook timer 字节(H/M/S)。字节布局(canonical 表 cm4_data_parser.dart:60-72 / 解码 legacy_data_parser.dart):
另外,这 3 个 cook timer 字节当前按纯二进制解码,不是 BCD;代码常量 _cookTimerIsBcd = false,例如 59 分钟应为 0x3B。
另外,解析后的类型化模型 CM4DataPacket 会把这 3 个字节汇总为 cookTimer: Duration?:18 字节包时有值,15 字节包(旧 CM4 固件与全部 legacy)则为 null。源码还提供 boosterCookActive 派生状态,规则是 cookTimer == null 时返回 null,Duration.zero 视为已停止,> 0 视为正在烹饪。
| 字节 | 含义 |
|---|---|
| 0 | 中继盒电量(0–10,编码见下) |
| 1 | 中继盒固件版本 |
| 2-3 | 内温 ADC(大端)→ tempIntArray 反查 |
| 4-5 | 外温/环境 ADC(大端)→ tempExtArray 反查 |
| 6 | 探针电量(0–10) |
| 7 | 探针固件版本 |
| 8-13 | 探针地址(byte12 = 标识符) |
| 14 | RSSI(-(0x100-raw)) |
电量解码 decodeBoosterBattery(rawByte)(temperature_lookup.dart:48-59):充电位 (rawByte & 0xF0) == 0x80 → 充电中,电量取低半字节;否则电量=整字节;都钳到 0–10。别直接 ×10。
另外,当前解码逻辑对高温不是简单 null 处理:内温 ≤101°C 正常返回;102–120°C 会置 isInternalTempAboveRange=true,但仍保留真实温度值;>120°C 或超出内温查表上界时,则会在置高温标记的同时把内温硬钳到上限。环境温度若超出外温查表上界,也不会返回 null,而是被钳到查表尾值。
⚠️ 0 既是「未初始化」也是「真 0% / V0 固件」:
_DeviceState构造把batteryLevel/firmwareVersion初始化为 0 供首帧前渲染。用firmwareVersion > 0作为「真实数据已到」闸(真硬件固件恒非 0),别用裸<= 阈值判断电量否则每次新连接误报。
lib/core/models/probe.dart。用户面前一律用颜色名(Black/White/Blue/Yellow),槽位号只在协议/线上讨论里用。
ProbeAddress(:32-38):Black 0x0A(命令字 0x0B)、White 0x0D、Blue 0x0E、Yellow 0x0FProbeNumber.colorName(:41-69);LCD 槽位 cm4LcdOrder(Blue→1 / White→2 / Black→3,:72-87)。另外,Yellow→4。fromIdentifierByte(:107-118)同时接受 0x01-0x04 与 0x0A/0B/0D/0E/0FMeatType.cm4WireE(:156-187,Custom=1 … Turkey=9);Doneness.cm4WireF(:197-220,rare=1 … wellDone=5)补充:云端 socket 即使仍处于 connected,若连续 60 秒未收到任何 MQTT 帧,MqttService 也会把遥测标为 stale,并向连接管理层发出 disconnected 状态以触发 BLE fallback;单个探针连续 35 秒未收到自己的 D= 帧则会被标为未连接。这两个超时分别针对设备级云端失活与探针级帧失活。
另外,App 侧 MQTT 连接目标并不是在 MqttConfig 里写死 host/port;源码注释明确说明 broker host/port 运行时由 RegionService 解析,MqttConfig 本身只承载 topic 生成与共享 broker secret 等配置。
另外,当前连接流程还实现了一个 TLS 握手兜底:如果对 broker 主机名的连接表现为 SNI 形态失败(如 HandshakeException / SocketException / TlsException),代码会先解析该 host 的 IPv4,再用 IP 字面量重试同一次连接,从而避免发送 SNI;证书校验仍继续使用打包的 culinatech_ca.crt,不是放宽校验。
mqtt_service.dart(MqttConfig)。详见 MQTT 与云端 与 Supabase 账号与设备同步。
Topic(V14 收发分离,028087a / 2026-06-16):
| Topic | 方向 | App |
|---|---|---|
/CM4/<id>/data |
盒子→broker→App | 订阅(遥测,:52) |
/CM4/<id>/sett |
盒子→broker→App;另有 App→broker→App 的 peer-mirror 设置同步 | 订阅(盒子上报 SETT== / WIFI_STS / MQTT_STS / <id> online);同时 App 会把 SET_C / SET_F / SET_BL/WH/BU/YE=... 额外镜像发布到该 topic 供其他手机采用 |
/CM4/<id>/apps |
App→broker→盒子 | 发布(全部下行命令,:63) |
payload 中盒子上报的协议帧仍与 BLE AE05 兼容,D=<hex>、SETT==、WIFI_STS=、MQTT_STS=、<id> online、VER 回复等会先走 Cm4DataParser.parse(...);但当前 /CM4/<id>/sett 还承载多手机设置镜像的原始 SET_C / SET_F / SET_BL/WH/BU/YE=ABCDEF[G],这些消息会在 parser 返回 null 后由 MqttService._tryAdoptPeerSettingMirror() 单独处理。
另外,当前多手机设置镜像的发布范围已经不只限于温标与 per-probe SET_<color>。源码中的 isMirrorableSetting() / _isBoosterLevelSetting() 还把 BL_LVL=N、RI_CNT=N、RI_LVL=N 以及 TEMP_INT / TEMP_EXT / TEMP_ALL 也纳入 /CM4/<id>/sett 的镜像发布范围,因此这些 booster-level 设置同样属于当前 App 已实现的跨手机同步面。
另外,源码还明确支持 VER 回复经 MQTT /sett 返回并在云端路径直接处理,用于在纯云端会话里更新 espFirmwareVersion(以及回包携带时的 wifiMac)。
另外,当前云端实现还有一个主动同步步骤:会话内收到首个 MQTT 帧后,App 会立即向 /CM4/<id>/apps 发布一次 SET_RD,再等待盒子通过 /CM4/<id>/sett 回 SETT==,用来把 per-probe 设置拉回本地并完成云端侧对齐。
鉴权 / TLS:MQTT 3.1.1 over TLS :8883,无客户端证书,keepalive 30 s。当前代码在连接时统一创建 SecurityContext(withTrustedRoots: false),并加载 assets/certs/culinatech_ca.crt 作为受信 CA;源码中没有这里所述的 SecurityContext.defaultContext + onBadCertificate = (dynamic _) => true 放行路径。另有一个连接保活兜底:mqtt_client 还设置了 disconnectOnNoResponsePeriod = 60,即 60 秒收不到 broker 的 PINGRESP 时会主动断开该 broker 会话,并走后续自动重连流程,以收敛“TLS 套接字已僵死但本地仍自认为 connected”的场景。当前认证不是固定只用共享凭据:已登录账号时优先使用账号侧 MQTT 凭据(username = auth uid,password = 账号 minted secret);guest/stub、账号凭据不可用时会直接使用 username = deviceId、password = Tbroker_8f3a91。当账号凭据返回非 connected 的连接结果时,代码会回退重试一次共享凭据;而在异常路径里,只有异常文本命中 notAuthorized / badUsernameOrPassword 这类“凭据被 broker 拒绝”的形态时,才会回退到共享凭据,TLS/Socket/超时等异常不会因异常本身而切换凭据。clientId = culinatech_app_<6位随机数>(运行时随机生成;broker 鉴权不依赖 clientId)。
String.boosterFamily 扩展(booster_family.dart:41-44)——startsWith('CM4_') ? cm4 : legacy。只有 CM4_ 是 cm4;CM1/CM2/CM3/MW3/MW4/MW5 全是 legacy。heartbeatIntervalSeconds(booster_family.dart:22-25)= 两家族都 45 s;实际定时器走 ble_service.dart:237 heartbeatIntervalFor() → 45 s(ble_service.dart:221 注释里仍留着 "CM4 was 20 s until Liang 2026-06-06..." 的历史记录,是过去式说明而非真实节奏——不要按字面读成"现在是 20 s")。0x55B1、CM4 = CNT_0(booster_family.dart:32-35)。CM4 的 CNT_0 是独占锁刷新(与 legacy 0x55B1 机制相同、字节不同):45 s 刷新 / 60 s TTL,WiFi 模式下 BLE 链路也必须持续刷锁,否则盒子主动 shed GATT(TAPD #1003176)。相关页:CM4 协议(人写版) · BLE 协议 · MQTT 与云端 · Supabase 账号与设备同步