当前代码还支持账号绑定后的跨手机云端接入:登录后或启动恢复会话时,syncAccountBoundDevices() 会拉取账户下已绑定的 CM4;对本机此前从未配过网的设备,会调用 markCloudConfigured(deviceId) 写入本地 wifi_configured_ 标记,然后直接 startManaging(deviceId, bleOnly: false) 建立云端会话。也就是说,另一台登录同一账号的手机可以直接走 MQTT 订阅这些已绑定设备,无需先在本机重新做 BLE 配网。
MQTT 路径 仅 CM4 WiFi-enabled 机型 使用。当前代码已改为 MQTT/cloud 优先:只要设备已配置 WiFi 且 broker 会话可建立,startManaging(String deviceId, {bool bleOnly = false, bool userChoseCloud = false}) 就会把该设备切到 DeviceConnectionMode.cloud;BLE 仅在 bleOnly、设备未配置 WiFi、或 broker 当前不可用且本机已有活跃 BLE 链路时才成为活跃遥测通道;若设备已配置 WiFi、broker 当前不可用且 BLE 也未连上,startManaging(...) 会把该设备置为 DeviceConnectionMode.disconnected。switchToCloud(String deviceId) 仍会把对应 _ManagedDevice 的 userForcedCloud 置为 true,但它不再是 MQTT 成为活跃消费通道的前提。
⚠️ 双通道遥测(per Beta 2026-05-11,55d699a):CM4 中继盒在 BLE 与 MQTT 都可达时同时往两个通道推遥测——固件侧没有切换开关,所以"fallback" 是 App-side 的语义而不是 wire-side 的。当前代码里,
DeviceConnectionManager不再承接任何遥测流(无 unifiedtelemetryStream;transport-split 后命令下发也改走Cm4BoosterTransport/BleDeviceService)。云端遥测由CloudConnectionRegistry合并各 per-deviceMqttService.telemetryStream后直接分发给下游 providers;BLE 原始帧的 mode gate 在BoosterNotifier/ConnectedBoostersNotifier侧按各设备mode丢弃,而不是在 connection manager 内过滤。也就是说,DeviceConnectionManager现在主要负责 BLE↔cloud 的连接状态编排,而不是统一承接全部云端遥测数据流。两通道之间没有 dedup(同一个 frame 可能 BLE 先到 / MQTT 先到,App 永远只看_mode指定的那条),切 mode 即换通道。未来 FW 变更预期:App 需要定期发 MQTT keep-alive token 才能保持 MQTT 推送(流量优化);落地前两通道始终都活,App 的工作只是消费其中一边。
补充:CloudConnectionRegistry.disconnect(String deviceId) 只会关闭该 deviceId 自己的云端会话,不会影响其他已绑定设备的 MQTT 会话;同时 registry 会保留该设备对应的 MqttService 实例及其既有 clientId,后续重连会复用这份 per-device service。另:registry 还提供 reportClosedSocketPumpError()(TAPD #1003457)——main.dart 全局错误处理把 Reading from a closed socket 读泵风暴喂进来;5 秒窗口内连续 ≥25 次会触发所有 per-device MqttService.recoverFromSocketStorm() 强制拆 socket 并走 5 秒重连循环,且两次 bounce 至少间隔 30 秒。
补充:当前实现还有 lib/core/services/cloud_connection_registry.dart。它按 deviceId 维护 Map<String, MqttService>,为每台已绑定 CM4 持有独立的 MQTT 会话,并把多设备的遥测与连接状态合并后分发给下游;这也是当前多台已绑定 CM4 可以同时保持云端会话的关键基础设施。另:mirrorSetting(deviceId, command) 会把可镜像设置写入 /sett 但不再发给盒子——供 BLE 主通道已下发、仅需多手机 peer adopt 的路径(0713 修复;底层调用各 MqttService.mirrorSettingToPeers)。另一个当前代码里的关键点是:registry 不只是“每台设备一份 service”,还会为每台设备生成独立且带随机后缀的 clientId(culinatech_app_${safeId}_$rand)。这样同一账号下多台设备不会互相撞 clientId;同一台设备在 App 重启后也能避免与上一次残留 broker 会话互踢。
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 连接调试按当前代码,旧 EMQX 地址已退役;生产区域直接使用 cm4-sg.culinatech.shop:8883、cm4-us.culinatech.shop:8883、cm4-eu.culinatech.shop:8883,另有手动可选的香港测试区域 cm4-hk.culinatech.shop:8883。
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 连都能验。
补充:当前 MqttService.connect() 还有一层 TLS 失败兜底。若按主机名建连时抛出 HandshakeException / SocketException / TlsException 这类 transport-shaped 错误,代码会先解析该 broker 的 IPv4,并针对同一台 broker 记住这份 IP,然后立即用 IP literal(无 SNI) 重试一次;只有这条 IP fallback 也失败时,才继续按普通失败处理。
当前代码里看得到的是固定的建连方案:MqttService.connect() 始终加载 assets/certs/culinatech_ca.crt 做 CA pin,优先使用 accountCredResolver 提供的账号凭据建连;只有在拿不到账号凭据,或账号凭据建连失败后进入一次 legacy 回退时,才使用 mqttUser = deviceId、mqttPass = MqttConfig.devTestPassword 建连。当前给出的源码中没有体现一个由 kUseDevBroker 控制的认证/TLS 分支。当前 config getter 返回的是 MqttServerConfig.forRegion(_region),也就是按当前区域(含账号锁定后的区域)选择对应 broker。,当前代码里存在 MqttServerConfig.asia / us / eu / hk 四个常量;MqttServerConfig 只定义了 asia、us、eu、hk 四个常量,其中 hk 才是 cm4-hk.culinatech.shop:8883 的手动测试区域。当前代码下,并不存在 kUseDevBroker 这个开关;MqttService.connect() 直接加载 assets/certs/culinatech_ca.crt,优先使用 accountCredResolver 提供的账号凭据建连;只有在拿不到账号凭据,或账号凭据建连失败并触发一次 legacy 回退时,才使用 username = deviceId、password = MqttConfig.devTestPassword 建连;这里的失败包括 broker 握手结果不是 connected 的情况;若走异常分支,则只有形如 notAuthorized / badUsernameOrPassword 的凭据被拒绝异常才会触发这次 legacy 回退。,不存在由该开关控制的认证/TLS 分支。App 的实际 broker 仍由 RegionService.config => MqttServerConfig.forRegion(_region) 决定:asia 走 cm4-sg.culinatech.shop:8883,us 走 cm4-us.culinatech.shop:8883,eu 走 cm4-eu.culinatech.shop:8883,而 hk 只是手动可选的测试区域。
MqttServerConfig 里当前实际定义了 asia / us / eu / hk 四个 broker 常量:asia 对应 cm4-sg.culinatech.shop:8883,us 对应 cm4-us.culinatech.shop:8883,eu 对应 cm4-eu.culinatech.shop:8883,hk 对应 cm4-hk.culinatech.shop:8883。当前代码里不存在 kUseDevBroker 开关。MqttService.connect() 固定启用 TLS,加载 assets/certs/culinatech_ca.crt 作为信任 CA;认证时会优先使用 accountCredResolver 提供的账号凭据,只有拿不到账号凭据,或进入一次 legacy 重试时,才使用 mqttUser = deviceId、mqttPass = MqttConfig.devTestPassword。补充(TAPD #1003388):账号凭据路径下 MQTT 用户名是 auth uid(不是 deviceId),密码为账号侧 mint 的 secret;resolveMqttLogin() / MQTT_CRED_MODE 日志以 account cred (username=uid) 区分。Legacy 回退才回到 username=deviceId + MqttConfig.devTestPassword。
| 区域 | 当前 broker 主机 | 枚举 |
|---|---|---|
| Asia | cm4-sg.culinatech.shop:8883 |
ServerRegion.asia |
| US | cm4-us.culinatech.shop:8883 |
ServerRegion.us |
| EU | cm4-eu.culinatech.shop:8883 |
ServerRegion.eu |
| HK(手动测试) | cm4-hk.culinatech.shop:8883 |
ServerRegion.hk |
Cutover(per FLEET.md,需 App + 固件协调——一台设备和它的 App 必须连同一 broker):(1) App 把 region_service.dart 的各 per-region broker 常量(asia/us/eu/hk)主机名改成 cm4-*(当前代码已无 MqttServerConfig.dev);(2) Liang 把固件重指到主机名(CA 不变);(3) SG box 把 IP:47.84.133.235 证书换成 cm4-sg 主机名证书(短暂重启 broker)。fleet 已 live + verified(TLS / 鉴权 / pub-sub 往返健康检查通过),只差这步切换。
HOST=<mqtts://host:port> 才是当前代码里同步给中继盒 broker 地址的命令。App 在配网流程中读取 regionServiceProvider.config.url,再通过 BLE 发送 CM4Command.setHost(...) 给设备。当前代码并不是“App 选自己的 broker、booster 用 booster 自己的,两端独立配置”。MqttServerConfig.url 注释明确说明该 mqtts://host:port 会通过 BLE HOST= 命令写给 booster;也就是说,App 侧确实参与配置 booster 要连的 broker 地址。
除探针设置和单位外,当前 /sett 的多手机镜像还覆盖 booster 级设置:BL_LVL=N(背光)、RI_CNT=N(蜂鸣次数)、RI_LVL=N(铃声音量)以及 TEMP_INT / TEMP_EXT / TEMP_ALL(显示模式)。接收端会分别通过背光、蜂鸣次数和显示模式回调同步下游状态;其中 RI_LVL 已镜像发布,但当前尚无 App 侧状态目标可供 adopt。
补充:当前 /sett 上的多手机镜像设置写入已经有接收侧 adopt 逻辑。SET_C / SET_F 会经 _tryAdoptPeerSettingMirror(...) 触发 onUnitReconciled,并带 2 秒本机回声抑制;SET_BL / SET_WH / SET_BU / SET_YE 这类镜像写入除了 target / meat type 外,还会继续 reconcile doneness。也就是说,/sett 现在不只是“命令镜像通道”,还承担了多手机之间的单位与熟度收敛。
补充:当前 /sett 还承载 VER 回复。另外,/sett 现在还承载 OTA=N% 进度推送:MqttService 在 cloud 通道解析到 CM4OtaProgress 后,会触发 onOtaProgress 回调,把 OTA 进度转发给下游,因此 WiFi/cloud 模式下固件升级页的 OTA 进度条也能实时更新。MqttService 收到 CM4VersionInfo 后会更新 Booster.espFirmwareVersion,并且只有回复里实际带了 wifiMac 时才更新 Booster.wifiMac,因此 cloud 模式下固件升级页也能读取 ESP 版本。
| Topic | 方向 | App 行为 |
|---|---|---|
/CM4/<deviceId>/data |
中继盒 → broker → App | App subscribe(mqtt_service.dart 的 _connectInternal 成功路径,同时订阅 /data 与 /sett) |
/CM4/<deviceId>/sett |
中继盒 → broker → App;同时当前 App 仍会把可镜像设置写入(SET_<color>=...、SET_C、SET_F)额外 publish 到 /sett,作为多手机同步通道,因此 /sett 在当前代码里并非纯粹单向 box→app。 |
App 会 subscribe /sett,但并非只接收:中继盒的 SETT==、WIFI_STS、MQTT_STS、VER 等仍从 /sett 到达;同时,SET_C / SET_F 以及 SET_BL=...、SET_WH=...、SET_BU=...、SET_YE=... 这类可镜像设置写入,也会被 App 额外 publish 到 /sett,作为多手机同步通道:中继盒物理按键改设置时的 SETT==<32hex> unsolicited push、WIFI_STS=N / <deviceId> online broker-login 公告(028087a 起)、SET_RD 应答都走这一通道;MQTT_STS=N 当前代码注明仍是 BLE 通道 push,MQTT 侧只是预留了解析逻辑,以兼容未来固件若把它镜像到 /sett。;自 860de52 起在 connect + auto-reconnect 时 subscribe、_handleSettingsAscii adopt |
/CM4/<deviceId>/apps |
App → broker → 中继盒(028087a / V14 / Per Beta 2026-06-16 新增) | App 主要 publish 到 /apps:所有 downlink 命令都会先经 mqtt_service.dart:sendCommand 发往 /apps;但当前代码里,可镜像的设置写入(SET_BL=... / SET_WH=... / SET_BU=... / SET_YE=... 以及 SET_C / SET_F)还会额外 publish 到 /sett,作为多手机同步通道。因此 App 虽然不订阅 /apps,但这类设置命令仍可能经 /sett 收到自己的 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.init() 还处理了首次启动时的竞态。如果本地还没有缓存、_detectRegion() 正在跑,而登录流程先调用了 applyLockedRegion(...) 把 _isLocked 置为 true,那么 init() 会直接忽略这次 IP 自动检测结果,不会再用它覆盖账号锁定的区域。也就是说,账号锁区优先级高于 fresh install 场景下的自动检测。
另一个当前代码里的关键前提是:RegionService 还支持“账号锁区”。登录后可通过 applyLockedRegion(ServerRegion region) 强制把 _region 设为账号所属区域,并把 isLocked 置为 true;此时 App 实际连接的 broker 仍由 config => MqttServerConfig.forRegion(_region) 决定,只是 _region 不再来自本地 IP 自动检测或普通手动覆盖。
RegionService 通过 IP 地理定位 API 判断用户所在洲/时区后,按固定规则把自动检测结果映射到 Asia / US / EU 三个自动检测区域,并不是通用的“最近 broker”计算:AS→Asia,NA/SA→US,EU/AF→EU,而 OC 与 Australia/ / Pacific/ 时区也会落到 US。但当前代码的 region 常量并不只这三个,还额外存在一个手动选择的 hk 区域。
流程:
mqtt_server_region),下次启动立即生效;另外代码还会同时写入 mqtt_region_auto_detected(是否自动检测)和 mqtt_region_locked(是否账号锁区)两个布尔键。失败回退:所有 API 都失败 → 默认 Asia(代码中的 debug print 为 All APIs failed — defaulting to Asia,并返回 ServerRegion.asia)。
当前代码还实现了多手机场景下的 BLE 独占锁逻辑:CM4 的 BLE 同时只接受 1 台手机持锁。未持锁的次要手机会先走 MQTT/cloud,并按约 60 秒周期调用 _seekBleLock() 重试 BLE;当主手机离开、锁释放后,下一台成功抢到 BLE 的手机会被提升为 main。
补充:当前 DeviceConnectionManager 已不是单设备状态机,而是按 deviceId 维护 Map<String, _ManagedDevice>,因此多台已管理的 CM4 会各自保存自己的 mode / wifiConfigured / userForcedCloud / failover 状态。同时代码里还有一个 15 秒周期的 cloud-retry supervisor:当某台 WiFi 设备没有现成 broker session 时,都会自动重试 _cloud.connect(...),即使 BLE 当前仍活跃也会重试,以保持 broker session 可用。
DeviceConnectionManager 决定遥测流从哪里来:
设备已配置 WiFi 且 broker 会话可建立?
├─ 是 → 走 MQTT (_mode = cloud)
│ ├─ 即使 BLE 也已连上,活跃遥测通道仍是 cloud
│ └─ BLE 主要保留为独占锁 / `HOST=` 等辅助通道
│
└─ 否 → 且 BLE 可用?
├─ 是 → 走 BLE (_mode = ble)
│ └─ 遥测流 = BLE 数据
│
└─ 否 → 完全离线
⚠️ 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 也必须丢。transport-split 后DeviceConnectionManager只编排 per-devicemode与 BLE↔cloud failover,不再承接 unifiedtelemetryStream;云端帧由CloudConnectionRegistry.telemetryStream分发,BLE 原始帧的 mode gate 在 notifier 侧按各设备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 配置,当前入口页是 WifiNetworkSelectPage(/wifi-select 路由);用户在该页选择网络或走手动输入后,才会进入 WifiSetupPage(/wifi-setup)完成密码输入与配网:
ConnectionModePage 问用户「仅 BLE / WiFi + 云端」→ 选后者进 /wifi-select(WiFi 网络选择页,若未登录则先跳 /sign-in)(connection_mode_page.dart:113)settings_page.dart:678)注意:当前代码 没有对活跃烹饪会话做限制——用户在烹饪中也能进入 WiFi 配置页重新输入 SSID/密码。
补充:若中继盒已报告 WiFi 已加入(wifi_sts != 0)且 broker 在线(mqtt_sts == 1),且本次不是强制改网(forceReprovision=false),WifiSetupPage 会走 verify-only:跳过重发 M_ID/M_PD/HOST/SSID/PSWD,直接 startManaging(deviceId, bleOnly: false, userChoseCloud: true) 校验云端会话后成功(日志 WIFI_PAIR_SKIP_ALREADY_ONLINE,TAPD #1003403)。从 Booster Configurations 改网时 forceReprovision=true 才会强制重写凭据。
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 可达);若已确认 WiFi 加入(stsJoined),验证窗口为 180 s,否则为 90 s。补充:App 自己对 broker 的 cloud.connect(...) 是并发、best-effort 的辅助验证;如果手机侧 TLS/网络握手失败,代码只记录 WIFI_PAIR_APP_CLOUD_SKIP 日志,不会仅因这一步失败就判定配网失败,仍继续等待 box 侧的 MQTT_STS=1 或首条 cloud frame。
当前 MqttService.connect() 还启用了 MQTT 层的 ping 无响应保护:keepAlivePeriod = 30,并设置 disconnectOnNoResponsePeriod = 60。也就是说,除了 60 秒“无遥测 frame” watchdog 之外,如果 broker 会话连续 60 秒收不到 PINGRESP,客户端也会主动断开并走 mqtt_client 的 autoReconnect 重连路径;同时 _lastPongAt 与 pongCallback 会记录最近一次 PINGRESP,仅用于诊断日志区分“ping 静默导致的死流”与“刚刚还健康却立刻掉 socket”的两类断线。
补充:除了 mqtt_client 自带的 autoReconnect 之外,当前 MqttService 在收到非 App 主动触发的断开回调后,还会启动一层手动重连循环:每 5 秒重试一次 connect(deviceId),最多 60 次;超过上限后才把状态落到 CloudConnectionState.disconnected。
补充:除 60 秒的整条 cloud 遥测 watchdog 外,MqttService 还会启动一个 3 秒周期的 _checkStaleness();如果某根 probe 的 lastSeenAt 超过 35 秒未更新,就会把该 probe 标记为 isConnected: false 并发出新的 Booster 快照。也就是说,probe 级掉线判定与整条 broker/cloud 会话的 stale 判定是两套独立计时。另一个当前代码里的关键细节是:当 App 处于后台时,_checkStaleness() 会直接 early-return,而 60 秒整链路 stale timer 若在后台触发也会被 _onTelemetryStale() 抑制并重新 arm,不会仅因 Android 后台冻结 socket / timer 就把 probe 或 cloud 会话误判为掉线。
补充:除了 staleness watchdog 之外,当前 MqttService 还做了两层会话保护。第一,connect() 用 _connectGate 串行化同一 MqttService 上的并发建连;若前一次建连仍在进行,后续调用会等待它完成,而不会并行创建第二个 MqttServerClient。第二,_disconnect() 在拆旧 client 时会先把 _client 置空,并无条件关闭旧实例的 autoReconnect 后再 disconnect(),避免遗留 orphan socket 在后台继续重连。
补充:当前 MqttService.setAppBackgrounded(false) 在前台恢复时不只是重置 staleness timer。若底层 _client?.connectionStatus?.state 已不是 connected,或应用在后台停留达到 5 秒,代码会先把状态切到 CloudConnectionState.connecting,再直接 connect(devId) 强制重建 broker session,而不是继续等待 30 秒 keepAlive 自行发现断线。
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 到第一个连接的设备deviceId,订阅的是 /CM4/<deviceId>/data 与 /CM4/<deviceId>/sett;代码里没有任何按 SSID 切换 MQTT topic 的逻辑,因此改 SSID 本身不涉及重新选择 subscribe 的 topic。