MQTT 路径 仅 CM4 WiFi-enabled 机型 使用。BLE 优先(App 默认消费 BLE 通道);MQTT 在 BLE 不可达时作为活跃消费通道。
⚠️ 双通道遥测(per Beta 2026-05-11,55d699a):CM4 中继盒在 BLE 与 MQTT 都可达时同时往两个通道推遥测——固件侧没有切换开关,所以"fallback" 是 App-side 的语义而不是 wire-side 的。App 的
DeviceConnectionManager按_mode过滤、每次只消费一边、另一边的遥测静默丢弃。两通道之间没有 dedup(同一个 frame 可能 BLE 先到 / MQTT 先到,App 永远只看_mode指定的那条),切 mode 即换通道。未来 FW 变更预期:App 需要定期发 MQTT keep-alive token 才能保持 MQTT 推送(流量优化);落地前两通道始终都活,App 的工作只是消费其中一边。
lib/core/services/mqtt_service.dart(585 行)—— MQTT 客户端、subscribe/publish、重连、no-data watchdoglib/core/services/region_service.dart(195 行)—— 区域自动检测(IP 地理定位 → MQTT broker)lib/core/services/device_connection_manager.dart—— BLE vs MQTT 优先级编排与模式切换;当前代码里云端遥测已不再经过该 manager 的统一遥测流,而是由 CloudConnectionRegistry 直接分发给 providers。lib/features/thermometer/presentation/pages/wifi_setup_page.dart(468 行)—— WiFi 凭据输入页lib/features/thermometer/presentation/pages/mqtt_test_page.dart(315 行)—— 开发工具:MQTT 连接调试CM4 云端跑自建 4 区域 broker fleet(取代早期 EMQX Serverless)。每台 box 同一套自建栈——mosquitto + iegomez/mosquitto-go-auth + postgres:16(docker compose 于 /opt/culinatech-broker/),由同一个 CA 签发,所以嵌进固件 + 打包进 App 的 assets/certs/culinatech_ca.crt 全 fleet 通用,加区域不需要重刷固件。鉴权走 go-auth 对本地 mqtt_accounts 表(pattern-auth:任意 CM4_[0-9A-F]{6} 用户名 + 共享 dev secret 经 __cm4_default__ 行认证,ACL 把每台设备限死在 /CM4/<id>/#)。权威说明见仓库 backend/fleet/FLEET.md。
| 角色 | 主机名 | 区域 | 公网 IP |
|---|---|---|---|
| Test(中国研发) | cm4-hk.culinatech.shop |
阿里云 SWAS 香港 | 8.210.93.82 |
| Americas(US + CA) | cm4-us.culinatech.shop |
阿里云 SWAS 弗吉尼亚 | 47.90.222.241 |
| EMEA(UK + EU + 非洲) | cm4-eu.culinatech.shop |
阿里云 SWAS 伦敦 | 8.208.102.14 |
| APAC(SG / HK / AU / NZ) | cm4-sg.culinatech.shop |
阿里云 SWAS 新加坡(原始 dev broker) | 47.84.133.235 |
均 SWAS 2 vCPU / 2 GiB / Ubuntu 24.04,TLS 端口 8883。CA 私钥(ca.key)只在 SG box、从不落到各 broker box;每台 host 证书 SAN = DNS 主机名 + IP,按名或按 IP 连都能验。
⚠️ App 与固件目前都仍指向 SG 原始裸 IP,fleet 尚未接入。region_service.dart:27 的 const bool kUseDevBroker = true 让 config getter 永远返回 MqttServerConfig.dev(host 47.84.133.235:8883,即 SG box 的裸 IP),所有区域的流量都打到这一台——下节的区域自动检测仍在跑、_region 仍更新,但 kUseDevBroker=true 下不影响实际 broker 选择。
MqttServerConfig 里的三个 per-region EMQX Serverless 主机(region_service.dart:41-54)是早期方案的休眠残留,kUseDevBroker=true 下完全不用:
| 区域 | 休眠 EMQX 主机 | USUS=N |
|---|---|---|
| China | bf85af91.ala.cn-hangzhou.emqxsl.cn:8883 |
0 |
| US | u7b1232f.ala.us-east-1.emqxsl.com:8883 |
1 |
| EU | k211a27b.ala.eu-central-1.emqxsl.com:8883 |
2 |
Cutover(per FLEET.md,需 App + 固件协调——一台设备和它的 App 必须连同一 broker):(1) App 把 region_service.dart 的 MqttServerConfig.dev(及各 per-region prod 主机)改成 cm4-* 主机名;(2) Liang 把固件重指到主机名(CA 不变);(3) SG box 把 IP:47.84.133.235 证书换成 cm4-sg 主机名证书(短暂重启 broker)。fleet 已 live + verified(TLS / 鉴权 / pub-sub 往返健康检查通过),只差这步切换。
USUS=N 是 CM4 命令同步给中继盒它该用哪个 broker(per cloud doc 2026-05-11 / 351430f)。App 启动时不写 USUS=N 给 booster——App 选自己的 broker、booster 用 booster 自己的,两端独立配置。
| Topic | 方向 | App 行为 |
|---|---|---|
/CM4/<deviceId>/data |
中继盒 → broker → App | App subscribe(mqtt_service.dart:218) |
/CM4/<deviceId>/sett |
中继盒 → broker → App(028087a / V14 / Per Beta 2026-06-16 起单向 box→app;先前 860de52 / TAPD #1003167 Beta 2026-06-12 起 App 也在此 publish 命令、双向) | App 仅 subscribe:中继盒物理按键改设置时的 SETT==<32hex> unsolicited push、WIFI_STS=N / MQTT_STS=N / <deviceId> online broker-login 公告(028087a 起)、SET_RD 应答都走这一通道;自 860de52 起在 connect + auto-reconnect 时 subscribe、_handleSettingsAscii adopt |
/CM4/<deviceId>/apps |
App → broker → 中继盒(028087a / V14 / Per Beta 2026-06-16 新增) | App 仅 publish:所有 downlink 命令 ASCII(SET_xx=… / SET_RD / CNT_0 等)publish 自 mqtt_service.dart:sendCommand;App 不订阅 /apps、自此再不会听见自己的命令 echo——先前两端共用 /sett、MQTT 3.1 无 no-local、靠 fall-through + 2 s _lastUserTargetWriteAt 双重 suppression 防误 adopt |
✅ 860de52 / TAPD #1003167 起接通(Beta 2026-06-12 确认 /sett MQTT 订阅尚未连通:CM4 物理按键改设置后,中继盒会往 /sett 推 SETT==<32hex> unsolicited push(per cloud doc 2026-05-11 / 6337d24)。BLE 通道已接进 parser,但 MQTT 通道下 App 还没 subscribe /sett——接进前还需加 prefix-filter 避免 App 自己 outbound 命令被 broker echo 回踩 handler。/sett bidirectional 契约):MqttService 在 connect 与 auto-reconnect 时一并 subscribe /CM4/<id>/sett,CM4 物理按键改设置时的 SETT==<32hex> unsolicited push 经 _handleSettingsAscii 进入 cloud-local gate(独立于 BLE service 的 per-device gate state,session-scoped、_initProbeSlots 时清空)——与 BLE _handleSettingsAscii 同套 Liang 闸:alarm-inactive skip / factory-reset one-shot ADOPT / 2 s own-echo suppression(sendCommand 在 SET_xx= write 时 stamp 所有 ProbeNumber 的 _lastUserTargetWriteAt、RING_OFF 单独 stamp _lastMuteSentAt 喂 3 s mute-echo guard)/ strict < 1.0 °F matched check;出口三回调 onProbeAlarmConfigUpdated / onProbeTargetReconciled / onProbeMeatTypeReconciled 在 device_providers.dart 接进与 BLE 同一 provider body(包括 probeAlarmConfigProvider.set + 独立的 prevAlarmEnabledCloud 边沿 map 跑 device-MUTE acknowledgeTargetReached(source: 'device_mute'))。App 自己往 /sett publish 的 SET_xx= 命令被 broker echo 回来——它们不解析成 CM4Message、_onMessage 静默 fall through。MqttConfig.settingsTopic(deviceId) 也承载 WIFI_STS=N push,860de52 同时把它 merge 到 Booster.wifiStatus、让 cloud 模式下 dashboard WiFi glyph 保持实时(详见 仪表盘 §header WiFi glyph 的 4f26473 描述)。详见 CM4 协议 §SETT== 设置帧。
RegionService 通过 IP 地理定位 API 判断用户所在洲,映射到最近的 broker。
流程:
mqtt_server_region),下次启动立即生效失败回退:所有 API 都失败 → 默认 China(region_service.dart:148 debug print "All APIs failed — defaulting to China")
DeviceConnectionManager 决定遥测流从哪里来:
设备通过 BLE 可见?
├─ 是 → BLE 是首选路径 (_mode = ble)
│ ├─ MQTT 被主动 disconnect(c613148 / per Liang 2026-06-06 减少流量)
│ └─ 遥测流 = BLE AE05 数据
│
└─ 否 → 且设备已配置 WiFi + MQTT 配置有效?
├─ 是 → 走 MQTT (_mode = cloud,failover)
│ └─ 遥测流 = MQTT `/data` topic payload
│
└─ 否 → 完全离线
⚠️ c613148 / TAPD #1003176 起 BLE-primary 强化(per Liang 2026-06-06 减少流量 / 降低延时):以前
DeviceConnectionManager文档里写 "cloud primary / BLE fallback"、_onMqttStateChange在 MQTT reconnect 时会把 mode 翻回 cloud 即使 BLE 健在;现修正——startManaging在 GATT 链路已起时直接_mode = ble;_onBleStateChange检到 BLE up 就_setMode(ble)+_mqtt.disconnect()主动断 MQTT(与上面 ff24f61 的 raw-BLE 帧 mode-gate 互补:那个守护 cloud-active 不被 stale BLE 帧污染,这个守护 BLE-active 不会被 unused MQTT 流量拖累);⚠️ 0ae9381 / TAPD #1003275 / Per Liang 2026-06-17 起 BLE-up 不再主动断 broker session(Option B 先把功能做全,省流推后):_onBleStateChange删掉_mqtt.disconnect()调用、新加_ensureBrokerSession()BLE-up 时幂等 (re)open broker(已连接早返);startManaging也改为 WiFi-configured 设备无论_isBleUp都 connect broker;header cloud badge 重新定义为「App↔server session」(蓝 = 连上 broker / 灰 = 没连上)由新加cloudServerConnectedProvider喂源(StreamProvider 读MqttService.isConnected/connectionStateStream),不再 keying 全局 per-device transport mode、避开「WiFi 配对成功后手机仍在 BLE → badge 灰」的 #1003275 报告缺陷。Mode / telemetry gating 不变——BLE 仍是首选 telemetry source(Liang 2026-06-06 不动),mode=ble 时 cloud 帧仍被 mode gate 丢、不双算;ESP V12 起 booster BLE-up 时停推 MQTT 所以 broker session 大多 idle、纯付一份 keepalive 心跳(即 2026-06-06 省流改动节省下来的那份,Kevin 2026-06-17 接受作 post-launch optimization)。⚠️ 1a7d68e / TAPD #1003278 / Liang + Beta 2026-06-17 反向也加 keep-alive:cloud session 时 BLE 不在就主动重连 BLE(「使用 WiFi 时保持 BLE 的锁定」):新加_ensureBleSession(reason)是_ensureBrokerSession的镜像——!_preferBleOnly && !_userForcedCloud && _wifiConfigured && !_isBleUp时调_kickBleReconnect(reason)(带 10 s cooldown、radio-gated、handoff 给BleDeviceService自身重试机器)。startManaging末尾 land 到 cloud 后调一次、_onMqttStateChange在 MQTT up activating cloud 时也调一次(reason 同为wifi-keep-ble-locked)。理由(per Liang + Beta 2026-06-17):ESP V14 同时跑 BLE + WiFi、固件 keep BLE locked,BLE 仍应 primary(#1003176 不动);先前 #1003278 报告 repro 是配网后中继盒重启 sheds BLE、suppressReconnect期间正常 reconnect 循环不跑、manager settle 到 cloud-only、BLE 信号灰持续数分钟,直到无关 background scan(如导出 log 的 share sheet 触发 PendingIntent scan)才偶然回连(即报告「export 让 icon 变绿」的真因)。BLE-only opt-out / 用户手动 force-cloud / 非 WiFi 设备 / BLE 已 up 时 no-op。⚠️ 0e3f778 / TAPD #1003278 follow-up(2026-06-18):1a7d68e 的_ensureBleSession漏掉配网时 BLE 被中继盒 WiFi join GATT_ERROR 133 杀掉的情形——suppressReconnect期间设备从未入 reconnect queue、且 90 s BLE grace 与 90 s 配对 verify 同步过期,manager 读到 stale grace_isBleUp(设备仍在BleDeviceService._devicesmap 里)就跳过 re-acquire,BLE 灰持续数分钟(CM4_3009E9 / fw V11 / ESP V14 2026-06-17 repro)。修复改在 WiFi 配置 §代码路径 provisioning success 路径自己 seed 前台 reconnect 循环:读boosterProvider(deviceId),isInGracePeriod || !isBleConnected时调bleService.retryConnect(deviceId, reason: 'post-provision BLE re-lock (#1003278)');BLE 在 join 中存活则 skip 避免 churn 已健康的 GATT。同 commitBleDeviceService.retryConnect加可选reason参数喂RECONNECT_MANUAL日志(默认'user-initiated retry from dashboard'),让导出日志能区分 dashboard Retry 按钮与 post-provision 自动 re-lock。_onMqttStateChange在 MQTT reconnect 时只在 BLE 真断或用户手动选 WiFi 时才激活 cloud。_userForcedCloud新增 flag:用户在 Connection-Mode 上点 "WiFi" 走switchToCloud()时 stamp,BLE 自动回归不会覆盖这条手动选择;switchToBle()与startManaging()清掉。同 commitBoosterFamilyExt.heartbeatIntervalSeconds对 CM4 由 20 s 改 45 s——CNT_0是 CM4 的独占锁刷新(与 legacy0x55B1同机制,详见 CM4 协议 §心跳),WiFi 模式下 BLE 链路也必须持续刷锁、否则中继盒主动 shed GATT 链路(5586d80 / TAPD #1003176 phantom Reconnecting banner 的根因)。
⚠️ 6558c22 / TAPD #1003164 ①:cloud transport 死时主动连 BLE(Beta 2026-06-12):上方 c613148 的
_userForcedCloudflag 让手动选 WiFi 后即便 BLE 健在也不被自动覆盖——但反向情形仍开口:手机在 cloud 模式下关 WiFi 时_onMqttStateChange收到CloudConnectionState.disconnected/reconnecting,原 fallback 逻辑只在_isBleUp时_setMode(ble)、CM4 自己虽继续 advertise 却永不主动 connect(Beta 2026-06-12),整设备就停在 disconnected 直到 WiFi 回来。修复在_onMqttStateChange这两条 cloud-dead 分支:(a) 清掉_userForcedCloud——dead cloud 不能再阻挡 BLE 自动回归;(b)_isBleUp时直接_setMode(ble)采纳现成 GATT 链路(同 Liang 2026-06-06 BLE 偏好),否则调新加的_kickBleReconnect(reason)主动发BleDeviceService.connect(deviceId, trigger: 'cloud-lost-fallback')与 broker reconnect loop 赛跑——哪条 transport 先恢复哪条赢、走原有_onBleStateChange/_onMqttStateChange处理。_kickBleReconnect带 10 s cooldown(_lastBleKickAt,防 flapping broker 排队重复 connect),首次后BleDeviceService自己的重试机器接手;always-on dev logCONN_MGR_BLE_FALLBACK | reason=cloud-disconnected | cloud-reconnecting。⚠️ 2cfbfdf / TAPD #1003274 起_isBleUp与_kickBleReconnect都加_adapterOn闸:新加_adapterOnflag 由FlutterBluePlus.adapterStateNowsynchronous seed +_onAdapterState续维护,_isBleUp改为_deviceId != null && _adapterOn && _ble.connectedDeviceIds.contains(_deviceId)——BleDeviceService.connect在 GATT handshake 之前_preRegisterDevice把设备塞进_devices,蓝牙关时这条 lingering 项原先让connectedDeviceIdsmid-connect 暂时 contain 该设备、_isBleUp读 true,prefer-BLE 分支把 manager 翻进blemode 之后 connect 失败 ("Bluetooth must be turned on") 但无人重评估 mode、cloud 帧被raw-BLE mode gate(ff24f61)丢掉、_lastSeen冻结、~75 s 后CLOUD_LOST_CONNECTION误报(field log:手机蓝牙关 WiFi 模式从 bg 回 fg 时 MQTT socket bounce →reconnecting触发 cloud-lost BLE fallback → 进入 strand)。_kickBleReconnect同样早返:!_adapterOn时记 always-onCONN_MGR_BLE_FALLBACK_SKIP | reason=… — radio off, staying on cloud; BLE reconnect resumes when Bluetooth returns、不发 connect 不打 cooldown stamp,等用户把蓝牙开回来后由BleDeviceService自身重试机器自然 re-engage。
⚠️ 7140ac2 / TAPD #1003245:BLE 实际不在了才 fail over 到 cloud,而不是只盯
disconnected(field logculinatech_log_20260612175603:Xiaomi WiFi 模式下 17:43:54 关蓝牙,中继盒整段一直推 broker /MQTT_RX自 17:43:56 起连续 parsed,但 dashboard 17:45:24 宣告设备 lost、直到 17:47:33 App-resume bounce MQTT socket 才恢复)。根因:原_onBleStateChange只在BleConnectionState.disconnected触发_tryMqttFallover,但设备在 reconnect 队列里时 BLE service 永远不会 emit disconnected——adapter-off 让_attemptReconnectpark(TAPD #1003211)、public state 仍是connected(grace 中),grace 过期 emitreconnecting;manager 因此停在mode=ble、raw-BLE mode gate(ff24f61)把每帧 live cloud 帧都丢掉,staleness 闸再把 booster 翻 disconnected。修复加两条 trigger:(a)_onBleStateChange把reconnecting也算 dropped——非空队列下 grace expiry 就 fire failover;短暂 GATT flap 不命中(grace 静默重试期间不 emit state change,恢复 link 走原有_isBleUpre-enter)。(b) 新加_adapterSub = FlutterBluePlus.adapterState.listen(_onAdapterState),radiooff/turningOff/unavailable时——任何 BLE retry 都不可能成功——立即翻 cloud,而不是空等不可能赢的 90 s grace;_falloverToCloudFreshre-reads 持久化wifi-configuredflag(startManaging时 cache 的可能 predate mid-session 配网完成),_falloverInFlightflag dedupeturningOff→off那对毫秒级相邻事件、避免第二次 call 把第一次 in-flight MQTT handshake 拆掉重做。Always-on dev logCONN_MGR_CLOUD_FALLOVER | reason=ble-disconnected | ble-reconnecting | adapter-off | adapter-turningOff | adapter-unavailable。MqttService.connect对已 subscribe session short-circuit,所以 fail over 到通常仍活的 broker 连接是 instant。补:ticket 里 Phone B 的 BLE connect 是 incidental——中继盒同时服务 BLE + cloud(B 改 target 经/sett也到 A),掉线纯 A 侧 state machinery;B 是否该 claim A-owned booster 是 BLE-lock 设计题(A radio off 后CNT_0TTL 过期),firmware / Liang territory、独立 track。
⚠️ ff24f61 / TAPD #1003117:
_mode == cloud时 raw-BLE 也必须丢。DeviceConnectionManager在自己的 unifiedtelemetryStream上按_mode过滤、外发到下游;但BoosterNotifier与ConnectedBoostersNotifier各自独立订阅了 raw_service.telemetryStream(BLE 通道)+connMgr.telemetryStream(unified),上一版只在 unified 那条做了 mode gate、raw-BLE 那条直接 last-frame-wins。WiFi/cloud 模式下用户关蓝牙触发 BLE link-down 快照(90 s grace → grace-expire 把每根 probe reset 到Probe.inactive+deviceStatus=lostConnection)渗入connectedBoostersProvider,于是alarmStateProvider的 over-temp CLEAR 在if (!probe.isConnected) continue闸被跳过——≥100 °C 安全报警冻结、MQTT 还在源源不断推冷却曲线却清不掉。修复让两个 notifier 自己也按 mode gate:if (!fromCloud && _connMgr.mode == DeviceConnectionMode.cloud && _connMgr.deviceId == deviceId) return;——把 connMgr 已经做的 mode 过滤补到 raw-BLE 那条订阅上。用mode而非booster.wifiStatus.firmwareValue:CM4 可以 WiFi 满格但 MQTT broker 当掉,那时候 mode 已 fallback 到 ble、wifiStatus 还是 4——mode 是 connection-manager 实时同步好的可信信号,dashboard 的isCloudConnected也用它。
用户可以在两处触发 WiFi 配置(WifiSetupPage,/wifi-setup 路由):
ConnectionModePage 问用户「仅 BLE / WiFi + 云端」→ 选后者进 /wifi-select(WiFi 网络选择页,若未登录则先跳 /sign-in)(connection_mode_page.dart:113)settings_page.dart:678)注意:当前代码 没有对活跃烹饪会话做限制——用户在烹饪中也能进入 WiFi 配置页重新输入 SSID/密码。
M_ID=<deviceId> → M_PD=<MqttConfig.devTestPassword>(88aaeaa / per Beta/Liang 2026-06-12 新增前置序言,每条 1 s 间距、两条都不重启中继盒;不写时固件 fallback 到内置 rd broker-superuser 账号、server 端 topic/auth 错被静默吞掉。9f419a8 / Beta 2026-06-15 V13 起中继盒落 NVS 后回 M_ID OK / M_PD OK BLE echo ack,CM4CredAck 进 parser 记 WIFI_PAIR_CRED_ACK 日志确认凭据真持久化;rd-account fallback 正在 server 端拆除,不落真凭据的盒子彻底连不上 broker),再 SSID=<name> → PSWD=<password>(SSID/PSWD 顺序敏感,per Beta 2026-05-11)PSWD= 后 CM4 重启到 WiFi 模式;App 必须静默重连 BLE(wifi_setup_page.dart 通过 suppressReconnect toggle 实现)MQTT_STS=N,N=1 = booster 自己 broker session 健康即 Beta 指定的「配网成功」信号,进 Booster.mqttConnected;与 WIFI_STS 的 AP join 严格区分——盒子可能 WiFi 满格却到不了 broker)MqttService 也订阅同一 /CM4/<id>/data topic → BLE 不可达时从此处取数据;9f419a8 / V13 起首条 cloud 帧到达后 publish SET_RD(V13 落 /sett、028087a / V14 / Per Beta 2026-06-16 起改落 /apps)——Beta 2026-06-15 确认中继盒 over MQTT 也回 SETT== 到 /sett(如 BLE 一向),_handleSettingsAscii adopt,纯 cloud session(手机蓝牙关)无 GATT 链路也能同步 target / alarm 状态,每 session 一次、reconnect 时重新 syncMQTT_STS=1、任一先到都算「配对成功」(前者证明端到端、后者由 box 自报 broker 可达),两条都没到才视为失败MqttService 内置 no-data watchdog(mqtt_service.dart:434-462):长时间无 frame(60 s _stalenessTimeout → _onTelemetryStale)时把连接标记为 telemetry-stale(isTelemetryStale),由 DeviceConnectionManager 据此触发 BLE fallback——watchdog 本身不重连 broker;broker socket 真掉线由 mqtt_client 的 autoReconnect 重开并 re-subscribe 现有 _subscribedDeviceId。⚠️ 6ba37e8 / TAPD #1003211 起阈值 15 s → 60 s——Android WiFi power-save 让手机 radio 进入 6–35 s 无线 nap、所有 cloud 帧排队在 wake 瞬间一次性 burst flush(log 实测 42–96 frames / 3 s),15 s 阈值每次 nap 都跨过 → 翻 disconnect → BLE fallback → burst 把 UI 翻回 connected 的可见 disconnect/reconnect 循环。同 commit 把 deliberate prefer-BLE cloud close 与 _onDisconnected 的回调竞态修了:_disconnect() 先清 bookkeeping + flag teardown 再 touch socket,所以 _onDisconnected 看到 flag 不再 log unexpected-disconnect / 不再启动 reconnect loop(原症状 09:22:17 主动断、09:22:24 自己又重连回来);provider 侧 cloud-lost 窗也由 30 s 推到 75 s(714a1e3 / TAPD #1003266 / Per Liang 2026-06-17 起再缩到 60 s——Liang 确认的 WiFi-drop 1 分钟规则,详见 重连与宽限期 §_cloudLostConnectionAfter)。Liang 待办(commit body 标的 firmware 嫌疑、未 fix):BLE central 一连上 cloud 帧就完全停推**、central 断开几秒后恢复(06-09 / 06-10 log 3 次重现),需要 MQTTX 订阅 /CM4/<id>/data 时让 App 走 BLE 复测确认。⚠️ 5940b81 / TAPD #1003274 起 staleness watchdog 加 setAppBackgrounded(bool) resume re-arm:Android 在 bg 期同时挂起 broker socket + Dart timers,_onTelemetryStale 的 timer delay 走 wall-clock、resume 时立刻 "overdue" fire 出假 disconnected(连接管理器顺势翻成 stale→BLE-fallback、BT 关时 UI 闪 "Scanning for CM4 booster…" 与 false boosterDisconnected 通知),实际中继盒整段在 stream(mqtt_client 的 autoReconnect 几秒内重开 socket)。新加 _appBackgrounded 字段守门:_onTelemetryStale / _checkStaleness 在 backgrounded 时早返、resume 时(lifecycle observer 经 DeviceConnectionManager.setAppBackgrounded fan-out,详见 01-平台集成 §_AppExitObserver)调 _resetStalenessTimer 重新 arm + 打 always-on MQTT_RESUME_RESYNC / MQTT_TELEMETRY_STALE_SUPPRESSED 日志,等真 foreground 帧再下 verdict。配套 DeviceConnectionManager.setAppBackgrounded(false) 从 FlutterBluePlus.adapterStateNow 重新 seed _adapterOn(adapter-state stream 同样 bg 期冻结、stale-true 会让 2cfbfdf 的 radio gate 失效,导致 resume 后 BLE fallback connect 撞 "Bluetooth must be turned on" 失败),打 CONN_MGR_ADAPTER_RESEED 日志;同 commit ConnectedBoostersNotifier.setAppBackgrounded 对 cloud 设备 forgive _lastSeen + shouldDeclareCloudLost predicate 加 appBackgrounded 入参,详见 重连与宽限期 §_cloudLostConnectionAfter 末尾 5940b81 callout。⚠️ 9845793 / TAPD #1003285 起 MqttService 从单例改 per-device、新加 CloudConnectionRegistry 路由:先前一个全局单例服务所有 CM4,固定 clientId、connect() 无 re-entrancy guard、_disconnect() 仅在 state == connected 时拆 client(autoReconnect-on 的 in-flight 实例被遗弃成 orphan);两台已绑 CM4 又共享同一 clientId,broker 按 MQTT 3.1.4 规则在每次重复 connect 时把上一条踢出独占——QA log 实证单设备同时持 2–3 个 broker session、单 session 触发 2181 次 autoReconnect 风暴(WiFi 模式蓝牙关时 dashboard 上 app↔box 每 ~2 s 闪连/掉、cloud icon 蓝/灰 flap、probe drop in/out 是 #1003285 报告的真因)。修复两层:L1 MqttService.connect() 串行化(in-flight gate)+ _disconnect() 无条件 autoReconnect = false + disconnect 干掉 orphan;L2 新加 CloudConnectionRegistry(lib/core/services/cloud_connection_registry.dart,117 行)—— Map<deviceId, MqttService> 工厂、每台 CM4 一份 MqttService 实例 + 各自唯一 clientId,暴露 serviceFor(deviceId) / connect / disconnect / isConnected / sendCommand / setAppBackgrounded / shutdownAll、合并 device-tagged telemetryStream + connectionEventStream 给下游。DeviceConnectionManager / device_providers / _AppExitObserver / WiFi-pair verify / dashboard 全部改走 registry(MqttTestPage 除外,见下),dashboard cloud 遥测从 merged stream 读、两台绑定 CM4 同时持独立 broker session 并发 stream 不互踢。cloudServerConnectedProvider(0ae9381 加的 header badge 源)现读 registry 跨设备 merged events、任一设备 connected 即翻蓝。MqttTestPage 直接实例化独立的 MqttService(不经过 registry,避免干扰真实设备会话;详见 MQTT 测试 §共享 MqttService 单例 的 9845793 closure)。flutter analyze clean / 177 单元测试通过 / HW regression pending Liang。
未来若中继盒固件要求 App 定期发 keep-alive token 保持 MQTT 推送(per Beta 2026-05-11 dual-channel 注解),需要在 MqttService 里加 periodic publish——目前不需要。
/sett topic 未 subscribe:CM4 物理按键改设置后 unsolicited SETT== push 在 MQTT 通道下被丢,BLE 通道下已接进 parserwifi_setup_page.dart 中的 SSID/PSWD 下发已改为 sendCommandAsyncTo(widget.deviceId, ...) 显式路由到目标 CM4,不再 fallback 到第一个连接的设备