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、deviceConnectionManagerProviderCM4Command.setWifiSsid / setWifiPassword(lib/core/protocol/cm4_protocol.dart)、saveWifiConfig、cloud.connect / telemetryStreamdeviceId,以及可选 initialSsid(从网络选择步骤带入;手动输入路径则为 null)。/wifi-setup(WifiSetupPage.routeName)lib/app.dart 的 onGenerateRoute(line 92),需要 deviceId arguments输入态:返回箭头、标题、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)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'))本页是 ConsumerStatefulWidget,但只在 _startWifiSetup 内调 ref.read(非订阅)取服务实例。所有 UI 状态变化由 setState 驱动 _step / _statusMessage —— 不依赖 Provider 重建。
initState 里调 _detectWifiSsid(),通过 network_info_plus 取当前手机连的 WiFi 名预填到 SSID 输入框(Android 取出来带引号会被 strip)。
——3076d37 / Per Liang 2026-06-10 起整段闸已去:成功判据搬到 BLE 端的 _mqttTelemetrySub 是临时订阅 mqtt.telemetryStream,仅在等待首条遥测的 180s 窗口内活跃(6a3fdd1 / TAPD #1003212 起,前 20s)WIFI_STS 轮询(详见上方 §代码路径 + §Per spec callout),telemetryStream 监听 + _mqttTelemetrySub 字段一并删除,配对成功后由 V12 自动推 telemetry、dashboard 自然填。新增 _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 加密本身仍是开放项。