主动扫描附近的 CM/MW 系列中继盒,用户点击设备完成 BLE 连接;CM4 机型连接成功后跳转到「连接模式」让用户选 BLE-only 或 WiFi+云端。
补充:deviceScanProvider 并不是直接固定到真实 BLE 服务,而是先 ref.watch(activeDeviceServiceProvider),再调用 service.scanForDevices()。这意味着当开发者模式切到 MockDeviceService 时,扫描页也会随之改用 mock 扫描源;只有在真实服务分支下,底层扫描才会落到 BLE 实现。但列表行的「已连接」标记始终 ref.read(bleDeviceServiceProvider).connectedDeviceIds,不随 mock 切换;mock 模式下可能出现扫描列表来自 mock、已连状态却反映真实 BLE 的错位。
lib/features/thermometer/presentation/pages/scan_page.dart(约 880 行,含调试子页 _DebugBleScanPage)deviceScanProvider(扫描结果流)、boosterProvider(family provider,调用形式为 boosterProvider(deviceId))(连接控制)、bleDeviceServiceProvider(已连设备查询)BleService.releaseScan / acquireScan(扫描优先级仲裁)、FlutterBluePlus(底层扫描)_DeviceRow、_ConcentricRadarPainter、_DebugBleScanPage(均在同文件内)/scan(ScanPage.routeName)lib/app.dart 的 MaterialApp.routes map顶部一行图标按钮(左侧返回、右侧刷新);下方居中是状态文案(本地化键 scanInitializing / scanSearching;中文为「正在初始化蓝牙...」/「正在搜索附近设备」)和一个动画脉动同心圆雷达 + 蓝牙图标。中央是滚动设备列表(带边框容器),未连接行显示蓝牙图标 + deviceId,右侧是通用信号图标;已连接行蓝牙图标变绿并显示 scanConnected 文案;正在连接行左侧显示转圈进度、右侧显示 scanConnecting 文案。空列表态由 _showEmptyHint 这个 15 秒计时器统一门控(不看 scanResult.hasValue——底层 scan stream 每 2 秒 reemit 空列表心跳,hasValue 会在第一轮真扫描完成前就翻 true,导致 4 秒就误显示"找不到"):开扫前(loading 状态)只渲染雷达 + "Initializing...",收到首笔扫描列表后翻转为 "Searching...";15 秒仍无设备时显示蓝牙图标 + 本地化 scanNoDevices 标题 + scanNoDevicesHint 提示 + scanAgain 按钮(ElevatedButton.icon,调 _triggerRescan);任何时刻发现设备立即切到设备列表。底部有本地化 scanNoDeviceFound 蓝色链接(中文为「找不到设备?」;→ 设备指南)和橙色 "🔧 DEBUG: All BLE" 链接(→ 内部调试子页 _DebugBleScanPage,列出所有 BLE 广播)。
IconButton.onPressed(顶部 bar,第一个按钮)Navigator.canPop(context) 为 true 时才关闭扫描页,否则只释放扫描优先级IconButton.onPressed
→ BleService.releaseScan(ScanPriority.userScan)
→ Navigator.pop(context)
Navigator.canPop(context) 为 true 时执行 Navigator.pop(context),并未写死返回 Dashboard。IconButton.onPressedElevatedButton.onPressed两者都调 _triggerRescan。
_triggerRescan
→ ref.invalidate(deviceScanProvider)
→ autoDispose 销毁旧 stream
→ build 触发 ref.watch 重新订阅 → 新 scan stream 启动
FlutterBluePlus.startScan 重新发起;empty hint 计时器重置补充:CM4 跳转到「连接模式」后,后续分流并不只是“显示一个选择页”。若用户选择 WiFi+云端,代码会先检查登录态:已登录则 pushReplacementNamed('/wifi-select', arguments: deviceId),未登录则跳到 /sign-in;若用户选择 BLE-only,则直接调用 DeviceGuidePage.showAfterDeviceAdd(context) 完成添加并展示设备指南。
补充:若点击的是 CM4,代码会在真正连接前先执行 deviceSetupProvider.begin(device.deviceId)(TAPD #1003299),把该设备标记为 setup 中,避免 connectAndReport() 期间首批遥测在”连接模式”阶段就触发告警(如探针外温过低);该标记会在连接失败、连接过程抛异常、从连接模式页返回,或设备添加完成后清除。
_DeviceRow.onTap(只有当前正在连接的那一行会把 InkWell.onTap 置空;其他行仍可点击,但 _connectToDevice 会因全局 _isConnecting 直接返回,不会发起第二次连接)DeviceGuidePage.showAfterDeviceAdd(context):先 pushNamedAndRemoveUntil('/dashboard', (route) => false) 清栈,再 pushNamed('/device-guide')InkWell.onTap
→ _connectToDevice(device)
→ 若 bleService.connectedDeviceIds 已含该 deviceId → Navigator.pop(直接返回)
→ setState(_isConnecting = true)
→ BleService.releaseScan(ScanPriority.userScan)
→ FlutterBluePlus.stopScan()
→ ref.read(boosterProvider(deviceId).notifier).connectAndReport()
→ 成功且是 CM4_*:Navigator.pushReplacementNamed('/connection-mode', arguments: deviceId)
→ 成功且不是 CM4:`DeviceGuidePage.showAfterDeviceAdd(context)`(TAPD #1003087 / 88e3ad7 起统一收尾——`pushNamedAndRemoveUntil('/dashboard')` 清栈 + `pushNamed('/device-guide')` 在顶部叠 [设备指南](/zh/03-客户端实现/04-界面解析/15-设备指南),guide pop 后落回 Dashboard)
→ 失败:若 `ConnectResult` 为 unreachable / timeout / generic gattError,则显示对应 SnackBar;若连接过程直接抛异常,则显示 `scanConnectError(e.toString())`
BoosterNotifier(deviceId) 先通过 activeDeviceServiceProvider 选择当前 DeviceService,再执行连接;当服务是 BleDeviceService 时走 connectWithDetails(...) 的真实 BLE 连接分支,当服务是 mock 服务时走 connect(...) 的 mock 分支。连接时不会主动断开手机上其他已连中继盒(scan_page.dart 注释 Connect WITHOUT disconnecting other devices)。KnownDevicesService 持久化该设备,并调用 connectedBoostersProvider.seedConnectedPlaceholder 立即占位 Dashboard 卡片;connectionModeProvider 仅在后续 WiFi 配网等路径调用 DeviceConnectionManager.startManaging 时才会更新,扫描页 connectAndReport 本身不触发该流GestureDetector.onTap(底部蓝色文案)/device-guide(设备指南)RouteAware.didPopNext 恢复扫描补充:该调试页顶部还有一个状态 banner,每 500ms 轮询一次,实时显示蓝牙适配器状态(_adapterState)、底层原生扫描是否正在进行(_isScanningNow)以及当前设备数。列表本身按 RSSI 从高到低排序,并对识别为 CM4 的设备做绿色背景/边框高亮,便于现场快速区分。
GestureDetector.onTap(底部橙色文案)_DebugBleScanPage(不在 routes map,匿名 MaterialPageRoute)—— 列出按 remoteId 去重后的附近 BLE 设备最新广播摘要(包括非 CM/MW/CG 设备),并按 RSSI 从高到低排序,用于现场排查GestureDetector.onTap
→ Navigator.push(MaterialPageRoute(builder: (_) => _DebugBleScanPage()))
_DebugBleScanPage 自启动一次原始 BLE 扫描(不带 service UUID 过滤);返回时停止_stopScan,停止时显示刷新图标调 _startScan),仅供调试补充:除了 didPushNext / didPopNext 的暂停与恢复外,ScanPage.dispose() 还会执行最终清理:取消 rootRouteObserver 订阅、销毁动画控制器并取消 _emptyHintTimer,同时再次 BleService.releaseScan(ScanPriority.userScan);若底层原生扫描仍在进行,还会直接调用 FlutterBluePlus.stopScan()。这意味着扫描页被销毁时也会主动释放 BLE 扫描资源,而不只是在返回按钮或子路由覆盖时处理。
build 通过 ref.watch(deviceScanProvider) 订阅扫描结果(AsyncValue<List<Booster>>);扫描每发现一个新设备就 emit 一次新值,列表自动重建。bleDeviceServiceProvider 用 ref.read(仅读已连 ID 集合做"已连"标记,不订阅)。
RouteAware 暂停模式(_paused 标志):扫描页订阅 rootRouteObserver;当被子路由覆盖时 didPushNext 设 _paused = true,build 跳过 ref.watch → autoDispose 关闭扫描 stream,省电。此外,didPushNext 还会立刻执行 BleService.releaseScan(ScanPriority.userScan);若原生扫描仍在进行,则直接调用 FlutterBluePlus.stopScan(),而不是只等待 autoDispose 自然收尾。didPopNext 把 _paused 翻回 false 触发 rebuild → 重新订阅 → 新一轮扫描启动。这是 Scan 页和 Dashboard 等其他页交互最关键的内部机制。
_showEmptyHint 与 _emptyHintTimer 由 initState 启动一个 15 秒计时器(scan_page.dart:91):到时仍无设备就显示 hint;若 hint 已经显示且随后发现设备,则会清掉 hint 并取消计时器。_triggerRescan(:100)使用同样的 15 秒。
无显著差异。扫描的字面行为依赖 FlutterBluePlus,iOS/Android 两侧权限和后台行为差异由 BleService 屏蔽(详见 平台集成)。
_DebugBleScanPage 不应进生产构建:橙色 "DEBUG" 文案永久可见。建议根据 kReleaseMode 或 DevLogService.enabled gate。_emptyHintTimer 初始触发的延迟在 initState 里硬编码 15 秒(scan_page.dart:91);改成命名常量更清晰。