补充:当前代码新增了“已在线箱体 verify-only”分支。默认 add / re-add 路径下,若设备已经 wifi_sts != 0 且 mqtt_sts == 1,页面不会再次下发 M_ID/M_PD/HOST/SSID/PSWD,而是直接 saveWifiConfig(...)、切到 Success 界面并开始 cloud 模式管理;
补充(TAPD #1003433):verify-only 路径会先切到 connectingMqtt 态并调用 _startProgress(fullSeconds: 10) 显示约 10 秒的短进度环,再 startManaging(bleOnly: false, userChoseCloud: true),避免用户看到凭据表单静止无反馈。只有 forceReprovision = true 的刻意换网路径才会强制重写凭据并重走完整配网流程。此外,这条 verify-only 路径会调用 saveWifiConfig(deviceId, ssid, keepExistingSsid: true):如果本地已经存过某次真实配网的 SSID,就不会被这次“未实际下发到设备”的手填/猜测值覆盖。
CM4 专属:用户输入 WiFi SSID + 密码 → App 通过 BLE 把凭据发给 CM4 → 等中继盒重启并连上 MQTT broker → 收到首条遥测后跳回 Dashboard。中途任意步骤失败就转入失败界面让用户重试或退回 BLE-only。⚠️ 242ec8c / TAPD #1003130 起拆两步:本页(/wifi-setup)变成步骤 2-5(密码输入 → 配对进度环 → 成功/失败 + Retry / Skip→Bluetooth),步骤 1(网络选择)单独住 /wifi-select WifiNetworkSelectPage——Android 走 WifiScanService.scan() 扫附近 2.4 GHz AP 按 RSSI 排序去重列出(per Liang 2026-04-29 onboarding spec),iOS 因无公开 SSID 枚举 API 走 manual fallback(详见 lib/core/services/wifi_scan_service.dart + PLAN_ACCOUNT_SYSTEM.md §6)。从 sign-in 漏斗(auth_widgets.finishAuthFunnel,c5b09e4 / TAPD #1003149 起原 goToWifiSetup 改名——携 deviceId 时落 /wifi-select,为 null 则只 popUntil auth 路由返回 sign-in 入口页,不进 WiFi 流程)或 ConnectionMode WiFi 选项进入时落 /wifi-select、带着选中的 SSID 再 push /wifi-setup;从 BoosterConfigurations 页或 Settings 直接进入则 push /wifi-setup 走 manual SSID 输入 fallback。
lib/features/thermometer/presentation/pages/wifi_setup_page.dart(468 行)bleDeviceServiceProvider(BLE 写命令)、cloudRegistryProvider、regionServiceProvider、deviceConnectionManagerProvider、accountMqttCredServiceProvider(Supabase 模式下 mint 每设备 MQTT 密码;mint 失败且 useSupabase 时中止配网)CM4Command.setWifiSsid / setWifiPassword(lib/core/protocol/cm4_protocol.dart)、saveWifiConfig、cloud.connect / telemetryStream/wifi-setup 的路由注册只从 settings.arguments 读取一个 String deviceId;lib/app.dart 这里没有接收或透传 initialSsid。/wifi-setup(WifiSetupPage.routeName)lib/app.dart 的 onGenerateRoute(line 92),需要 deviceId arguments流程态底部还会显示一条 l.wifiPairingDoNotLeave 警示文案,提醒用户在配网验证期间不要离开当前页面。此外,当前代码还会在进度百分比下方显示一条 l.wifiPairingKeepClose 提示,提醒用户在配对完成前让手机保持靠近设备。
当前进度环不是原生无状态 loading,而是代码里的 synthetic progress:_startProgress() 每 250ms 更新一次 _progress,按 45 秒的 ease-out 曲线把圆环推进到 95%,界面同时显示 l.wifiPairingProgress(percent) 百分比文案;只有在最终成功时才把 _progress 置为 1.0。
输入态:返回箭头、标题、SSID 输入框:若 initialSsid 非空则优先填入上一步选中的网络;只有在其为空时才尝试预填当前手机连接的 WiFi 名。、密码输入框(带 👁 显隐切换 Icon)、"Connect" 按钮。流程态:占满全屏的 116px 居中转圈(或 ✅/⚠️ 终态图标)+ 一行状态文案(_statusMessage,本地化),失败态显示底部 "Retry" 按钮与右上角 "Skip" 按钮(弹 Dialog 确认)。整个流程是同一个 widget 通过 _step 枚举切换显示,不是路由跳转。
IconButton.onPressed → Navigator.pop(context)Navigator.pop(context);具体返回到哪一页取决于当前入口)。IconButton.onPressed → setState(() => _obscurePassword = !_obscurePassword)补充:当前代码在发送 M_ID= / M_PD= 前,会先调用 accountMqttCredServiceProvider.mintDeviceCred(deviceId) 尝试向后端申请该设备专属的随机 MQTT 密码;
补充(TAPD #1003470):mintDeviceCred 有 10 s 超时;各 BLE 写入(M_ID、M_PD、HOST、SSID 为 5 s,PSWD 为 6 s)亦有硬超时。单步卡住不再无限停在 95% 进度环,而是落入失败界面供 Retry。当 mintDeviceCred(deviceId) 返回 null 且 SupabaseConfig.useSupabase 为 true 时,代码会调用 _failPairing(l.wifiSetupMqttCredFailed) 并停止流程,不会下发共享密码;仅非 Supabase 的访客/stub 路径才以 MqttConfig.devTestPassword 回退。因此本流程现在并不总是把共享开发密钥写入中继盒。
当前代码在 M_ID= / M_PD= 之后、SSID= 之前,还会追加一次 HOST= 下发:读取 regionServiceProvider.config.url,通过 CM4Command.setHost(regionUrl) 把账号当前区域的 broker URL 写入中继盒,并记录 WIFI_PAIR_HOST_SENT。这一步失败只记 WIFI_PAIR_HOST_SEND_FAIL,不会中断后续 WiFi 配网流程。
onPressed: _startWifiSetup_startWifiSetup
→ 校验 SSID 非空 + 密码 ≥ 8 字符(否则 SnackBar 报错并 return)
→ setState(_step = sendingCredentials, _statusMessage = "wifiSetupSendingNetwork")
→ Step 0(配网序言,88aaeaa / per Beta/Liang 2026-06-12):
bleService.sendCommandAsyncTo(deviceId, CM4Command.setMqttId(deviceId)) // M_ID=<deviceId>
→ await Future.delayed(1s)
bleService.sendCommandAsyncTo(deviceId,
CM4Command.setMqttPassword(MqttConfig.devTestPassword)) // M_PD=<dev secret>
→ logCore('WIFI_PAIR_MQTT_CRED_SENT', details: 'M_ID=<deviceId> M_PD=(<len> chars, not logged)')
→ await Future.delayed(1s)
失败 → logCore('WIFI_PAIR_BLE_SEND_FAIL', details: 'M_ID/M_PD: <e>') + _failPairing(wifiPairingFailedBody)
两条都**不重启**中继盒;不写时固件 fallback 到内置 `rd` broker-superuser 账号、server 端 topic/auth 错被吞掉
→ logCore('WIFI_PAIR_TX_SSID', details: '"<ssid>"') // 383498c / Per Liang 2026-06-12 item 3:TX 在写**之前**记;9f419a8 / Beta 2026-06-15 撤掉 `(len=<n>)` 后缀(serial-log cross-check 按 value/prefix 即可、且不再泄露密码长度)
→ bleService.sendCommandAsync(CM4Command.setWifiSsid(ssid))
→ await Future.delayed(2s)(让中继盒处理 SSID)
→ setState(_statusMessage = "wifiSetupSendingPassword")
→ bleService.suppressReconnect = true(中继盒收完密码会重启,BLE 会断;防止自动重连循环徒劳扫描)
→ logCore('WIFI_PAIR_TX_PSWD', details: '<DevLogService.maskSecret(password)>') // 383498c:TX 也在写**之前**记,避免被 reboot 杀链路吞掉;9f419a8 同样去 `(len=<n>)` 后缀
→ bleService.sendCommandAsync(CM4Command.setWifiPassword(password))
注:写完后 BLE 会立即 GATT_ERROR 133 断开,是预期行为;383498c 起原 `catch (_)` 静默吃掉改记 `WIFI_PAIR_TX_PSWD_LINK_DROP | expected post-PSWD reboot drop — <e>`
→ setState(_step = waitingForWifi, _statusMessage = "wifiSetupRestarting")
→ await Future.delayed(4s)(3076d37 / Per Liang 2026-06-10:中继盒收到 PSWD= 即重启,等 4s 让它开始 boot,先前的 15s/6s 盲等改为「短 boot 等 + BLE re-lock + WIFI_STS 轮询」三段闸)
→ _reconnectBleToBooster():最多 5 次 `bleService.connect(deviceId, trigger: 'wifi-provision-reverify')`,每次间隔 3s(中继盒可能还在 boot),成功记 `WIFI_PAIR_BLE_RELOCK_OK`,5 次都失败记 `WIFI_PAIR_BLE_RELOCK_RETRY` 且走 `_failWifiJoin(wifiSetupWifiJoinFailed)` 失败分支(无法 verify WiFi join)
→ _pollBoosterWifiJoin():每 3s 读一次 `boosterProvider(deviceId).wifiStatus`(`WIFI_STS` BLE 每 2s auto-push),**88aaeaa / per Beta/Liang 2026-06-12 起 maxChecks 3 → 30**(即 ~9 s 改为最多 ~90 s 窗、命中 1–4 即 early-exit)。命中 `wifiStatus != disconnected`(1–4 = 已 join AP)→ 成功;30 次全 0 → `_failWifiJoin(wifiSetupWifiJoinFailed)`;每次 check 记 `WIFI_PAIR_WIFI_STS`
→ setState(_step = connectingMqtt, _statusMessage = "wifiSetupConnectingCloud")
→ mqtt.connect(deviceId, serverConfig: regionService.config)
→ suppressReconnect = false(provisioning 不再驱动 BLE,恢复正常 auto-reconnect)
失败 → _failPairing(wifiSetupCloudFailed)
成功 →
saveWifiConfig(deviceId, ssid)(持久化 WiFi 配置)
deviceConnectionManagerProvider.startManaging(deviceId, bleOnly: false)
→ **0e3f778 / TAPD #1003278 post-provision BLE re-lock**:读 `boosterProvider(deviceId)`,若 `isInGracePeriod || !isBleConnected` 则调 `bleService.retryConnect(deviceId, reason: 'post-provision BLE re-lock (#1003278)')`——配网时中继盒 WiFi join 可能 GATT_ERROR 133 干掉 BLE link(CM4_3009E9 / fw V11 / ESP V14 2026-06-17 实证),但 `suppressReconnect` 期间没入 reconnect queue、且 90 s BLE grace 与 90 s 配对 verify 同步过期、`DeviceConnectionManager._ensureBleSession`(昨日 1a7d68e)读到 stale grace `_isBleUp`(设备仍在 `_devices` map 里)就跳过 re-acquire;改在 provisioning success 路径自己 seed 前台 reconnect 循环让 BLE 回到 primary。BLE 在 join 中存活(`isBleConnected == true && !isInGracePeriod`)则 skip 避免 churn 已健康的 GATT。同 commit `BleDeviceService.retryConnect` 加可选 `reason` 参数喂 `RECONNECT_MANUAL` 日志(默认 `'user-initiated retry from dashboard'`),让导出日志能区分 dashboard Retry 按钮与 post-provision 自动 re-lock。
setState(_step = success, _statusMessage = "wifiPairingSuccessBody")
await Future.delayed(2s)
DeviceGuidePage.showAfterDeviceAdd(context)(TAPD #1003087 / 88e3ad7:清栈到 Dashboard + 叠 [设备指南](/zh/03-客户端实现/04-界面解析/15-设备指南))
失败分支 _failWifiJoin(detail):setState(_step = failed, _failureDetail = detail, _retryToInput = true);记 `WIFI_PAIR_WIFI_JOIN_FAIL` —— Retry 按钮自此回到 SSID + 密码输入态而非静默重发(多半是密码错)
bleService.suppressReconnect 临时为 true,期间 BLE 重连循环停止saveWifiConfig 实现)DeviceConnectionManager 进入 cloud 模式,订阅该设备的 MQTT topicFilledButton.onPressed 二选:_retryToInput == true 时(WIFI_STS join 失败路径)→ setState(_step = input, _failureDetail = null);否则(MQTT/凭据等 _failPairing 路径)→ _startWifiSetup(直接重试整条流程,3076d37 / Per Liang 2026-06-10)TextButton(l.wifiSelectSkip)→ _showSkipDialog 弹出确认 Dialog → 用户 Confirm 后调用 DeviceGuidePage.showAfterDeviceAdd(context)(TAPD #1003087 / 88e3ad7:与其他三条 add-completion 路径统一收尾——pushNamedAndRemoveUntil('/dashboard') + pushNamed('/device-guide'))choice == false)时会执行 Navigator.of(context).pop() 返回上一页;若点遮罩关闭对话框(choice == null),则停留在当前失败页。成功路径调用 DeviceConnectionManager.startManaging 时会传入 bleOnly: false, userChoseCloud: true。这会将该设备的应用侧连接选择固定为 Cloud;即使手机自身的 broker 连接短暂滞后或超时,连接选择器仍显示 WiFi,并由连接管理器继续监督 cloud 连接。
当前代码还新增了一个配对验证期专用的 _pairingRelockTimer:从 post-reboot BLE re-lock 成功后开始,到最终 verdict 前结束,每 5 秒执行一次 keep-alive / re-lock。
此外,MQTT 验证时间盒在 _pollBoosterWifiJoin() 确认 WiFi 已 join(stsJoined=true)时从 90 s 提升到 180 s(冷启动 broker 慢路径,TAPD 0715);WiFi 未确认 join 时仍保持 90 s 以便密码错误较快失败。MQTT_STS=1 与首条 cloud frame 任一先到即提前成功。若 BLE 仍真实连接且不在 grace,则发送 CM4Command.heartbeat(CNT_0)续锁;若链路已掉或只剩 grace 伪在线,则调用 ble.connect(deviceId, trigger: 'wifi-provision-keepalive') 重新抢回 BLE,并记录 WIFI_PAIR_KEEPALIVE_RELOCK_OK / WIFI_PAIR_KEEPALIVE_RELOCK_MISS。这一步的目的,是在配网验证阶段持续接收仅经 BLE 推送的 WIFI_STS / MQTT_STS,避免模块 join WiFi 期间再次重启或 shed GATT 造成误判失败。
本页是 ConsumerStatefulWidget。当前代码不只在 _startWifiSetup 中调用 ref.read;_startStsLatch、_startPairingRelock、_pollBoosterWifiJoin 等辅助方法也会直接读取 Provider。所有 UI 状态变化由 setState 驱动 _step / _statusMessage —— 不依赖 Provider 重建。
initState 里调 _detectWifiSsid(),通过 network_info_plus 取当前手机连的 WiFi 名预填到 SSID 输入框(Android 取出来带引号会被 strip)。
——3076d37 / Per Liang 2026-06-10 起整段闸已去:_mqttTelemetrySub 是临时订阅 mqtt.telemetryStream,仅在等待首条遥测的 180s 窗口内活跃(6a3fdd1 / TAPD #1003212 起,前 20s)_mqttTelemetrySub 字段已不存在,但当前验证流程仍创建局部 telemetrySub 并监听 cloud.telemetryStream;首条当前设备的 cloud frame 可与盒子的 MQTT_STS=1 任一信号共同完成配网 verdict。新增 _retryToInput: bool 字段决定失败 Retry 走「回输入态」还是「重跑整条流程」。
中继盒在收到 PSWD= 后会自己重启,App 等 4s 后主动重新 lock BLE(最多 5 次 / 3s 间距),然后按 3s 间距 poll 中继盒的 WIFI_STS(BLE 每 2s auto-push)up to 30 次(~90 s 窗口):值落到 1–4 即「已 join AP」算成功、随后连 MQTT;3 次都还是 0、或 BLE re-lock 完全失败,都视为 WiFi-join 失败、Retry 回到 SSID + 密码输入态而不是静默重发。先前 6a3fdd1 / TAPD #1003212 的 180s 「等首条 cloud 帧」闸一并撤掉——既因 cold-join 慢路径误报「配对失败」,也因 leading-slash topic 缺陷在每次配对都会触发;BLE 端 WIFI_STS 直接 confirm join 后,配对在固件 topic 修复前就能成功,V12 起 telemetry 在连接后自动推。
配网真正的第 0 步是写中继盒自己登录 MQTT broker 用的凭据——M_ID=<deviceId> + M_PD=<MqttConfig.devTestPassword>,每条之间 1 s 间距、两条都不重启中继盒。先前 App 从未写过这对,固件 fallback 到内置 rd broker-superuser 账号,server 端所有 topic / auth 错都被静默吞掉。两条之后才是 SSID= + PSWD= 的原 wire 流。M_ID= / M_PD= 的 wire 格式假定与 SSID= / PSWD= 一致——Beta 书面确认前,固件对未知写直接 ignore(等价 today's behavior),落地后再校正;dev log 记 WIFI_PAIR_MQTT_CRED_SENT、只带密码长度不带明文。同 commit 把 _pollBoosterWifiJoin 窗口从 3 × 3 s(~9 s)改为 30 × 3 s(~90 s)窗 + 命中 1–4 即 early-exit——TAPD #1003212 实测 AP join 热路径 ~20 s、冷路径 ~2 min,2026-06-12 FONERIC repro 在原 9 s 窗里被误判失败。代价:真正错的密码也要 90 s 才会显失败。
BleService.sendCommandTo 的无条件 TX capture 只在 GATT write Future complete 之后触发,而中继盒收到 PSWD= 后即重启、把链路在写中途打死、TX line 每次都被吞——Liang 串口实测中继盒确实收到 PSWD= 并重启,但 App 日志「完全没有密码 TX」。修复在配对向导(覆盖所有入口,包括 Settings → WiFi Setup 路由)里在写之前记 WIFI_PAIR_TX_SSID / WIFI_PAIR_TX_PSWD(密码经 DevLogService.maskSecret 取前 2 字符 + * 补长);原 catch (_) 静默吃掉 post-PSWD 链路 drop 改记 WIFI_PAIR_TX_PSWD_LINK_DROP | expected post-PSWD reboot drop — <e>。配套:BleService.sendCommandTo 的 WRITE_FAILED_GATT 分支用新私有 helper _maskCredentialCommand 把 PSWD= / M_PD= payload mask 掉——该分支是 PSWD= 写的常规结局(reboot 杀链路),先前等于用户的 WiFi 密码在每份导出的配对日志里都明文落盘。
network_info_plus.getWifiName() 取 SSID。Android 在 API 29+ 需要定位权限才能取到 SSID(已通过 permission_handler 在 permissions_service 申请)。AppPermissions.requestCM4LocalNetwork() 申请,本页不重复)。CM4 固件 V13(Beta 2026-06-15)落地 broker-security 改造,App 端 4 项配套:
MQTT_STS=N BLE push(10 s 一次):N∈{0,1}、1 = booster 自己 broker session 健康,Beta 指定为「配网成功」信号——与 WIFI_STS(只报 AP join)严格区分(盒子可能 WiFi 满格却到不了 broker)。CM4MqttStatus 进 parser、_handleMqttStatus 更新 Booster.mqttConnected,BoosterNotifier.snapshotsEqual 加 mqttConnected 字段比较防 false→true 翻转被去重吞。MQTT_STS=1 任意一个先到都算成功」——前者证明端到端链路、后者由 box 自报 broker 可达。MQTT_STS 通常更早、cloud 帧更端到端,二选其一让配对更快且对单通道滞后鲁棒。WIFI_PAIR_VERIFY_OK 详情记 paired via cloud-frame|mqtt-sts,WIFI_PAIR_VERIFY_TIMEOUT 记 no cloud frame or MQTT_STS=1 within Xs。⚠️ 85e9695 / TAPD #1003256 起两信号不再对等:MQTT_STS=1 升为 AUTHORITY。先前 Step 7 是 await mqtt.connect() hard gate(!ok 直接 _failPairing(wifiSetupCloudFailed)),0615 logs 上 CM4_3009F9 因 mqtt.connect 抛 TLS HandshakeException("terminated during handshake")App 自己 broker login 失败、即便盒子已 broker 在线也宣告 pairing 失败、根本走不到 (a)。⚠️ 3cfd261 校正:原 85e9695 commit message 错归为 "bundled culinatech_ca.crt 验不过 live broker cert / prod ca.crt infra item";2026-06-15 从 Mac 直连 dev broker 实证 broker 健康(5/5 TLS 1.3)、leaf cert(CN/SAN 47.84.133.235)从 bundled assets/certs/culinatech_ca.crt 干净 chain——CA 匹配。"terminated during handshake" 是 peer / 网络在 TLS 中途关连接(手机网络路径——App 自己 cloud session 走手机 internet、不走 booster 的 WiFi),不是 client cert reject。新代码把 App 自己 broker session 改为 unawaited(mqtt.connect(...).then(...)) 并发 best-effort:login 失败只记 WIFI_PAIR_APP_CLOUD_SKIP、不再触发 _failPairing,仍按 90 s 完成时间盒等 MQTT_STS=1 latch 落定。同时把 MQTT_STS=1 用 _sawBoxOnBroker flag 自 post-reboot BLE relock 起 1 s 轮询 latch——固件可能 push 单次 =1 后立刻回 0,且 WiFi join 后 ~30 s 主动 shed GATT 链路,原 2 s sample 会漏。Booster.mqttConnected 当前值仍参与 verdict 但只是 latch 之上的补充。未在此 commit:手机网络路径 TLS 中断(App 自身 cloud session,0615 下午根因——盒子未 join Orbi AP + 手机路径不稳)与固件 post-join GATT shed(Liang 的 "keep locking CNT_0 every 45s during pairing",盒子 16:32:11 在 verify 中途断 BLE),仍是 infra / Liang 待办。M_ID OK / M_PD OK BLE echo ack:配网时 App 写 M_ID= / M_PD= 落 NVS 后中继盒回 echo ack 一帧,CM4CredAck 进 parser、_handleCredAck 记 always-on WIFI_PAIR_CRED_ACK,导出的配对日志可见凭据真持久化(rd-account fallback 在 server 端正在拆除,不落真凭据的盒子彻底连不上 broker——ack 是确认手段)。仍是 diagnostic,配对 verdict 不依赖。MqttService._pullSettingsOverCloud() 在首条 cloud 帧(_firstFrameLogged 翻 true)触发,向 /sett publish SET_RD——Beta 2026-06-15 确认中继盒 over MQTT 也回 SETT==(如 BLE 一向),_handleSettingsAscii adopt。纯 cloud session(手机蓝牙关)从此无 GATT 链路也能同步 target / alarm 状态;每 session 一次、reconnect 时重新 sync(_firstFrameLogged 重置);App 自己 publish 的 SET_RD echo 回来不解析成 CM4Message、静默 fall through、无需 prefix-filter。bleService.sendCommandAsync 没接受 deviceId 参数,默认走「第一个连接的设备」(line 105-107 / 132-134 注释 TODO(multi-device): uses first-connected-device fallback)。当前安全因为 WiFi 配置假设单设备流,但若用户同时连两台 CM4 + 一台 CM3,可能写错对象。Future.delayed(4s) 等中继盒开始 boot、_reconnectBleToBooster 最多 5 次 / 间隔 3s、_pollBoosterWifiJoin 30 次 / 间隔 3s(~90 s 窗口)(88aaeaa 起从 3076d37 / Per Liang 2026-06-10 的 3 × 3s 扩到 30 × 3s 以兜住 ~2 min cold-join),全硬编码;真正错的密码也要 90 s 才显失败。l.wifiSetupXXX。CM4Command.setWifiSsid / setWifiPassword 的字节级格式与是否加密需要确认 🔴 需要确认(追到 cm4_protocol.dart)。⚠️ 383498c / Per Liang 2026-06-12 item 3 closes 一条具体泄露线:BleService.sendCommandTo 的 WRITE_FAILED_GATT 分支自此对 PSWD= / M_PD= payload 走 _maskCredentialCommand mask(前 2 字符 + *)——该分支是 PSWD= 写的常规结局(reboot 杀链路),先前用户 WiFi 密码会在每份导出的配对日志里明文落盘;现在与 WIFI_PAIR_TX_PSWD 走同一 mask 规则。wire 加密本身仍是开放项。