本章讲 App 在连接出问题时如何恢复——分级退避重连、90 秒宽限期、入仓触发关机的特殊路径。
关键文件:lib/core/services/ble_device_service.dart(4177 行)
补充:当前服务层还维护了一个 _abandonedInFlightConnects 保护,用来处理“删除/断开动作与进行中的 BLE connect 竞态”这一真实路径。若某台设备在 connect 尚未最终完成时就被标记为放弃,_finishConnect() 不会把这条晚到的连接继续收编成 DEVICE_READY,而是记录 CONNECT_ABANDONED、把设备从 _devices 与重连状态中清掉,并立即断开底层 GATT。这样可以避免已删除设备因迟到完成的 connect 又被重新“复活”。
补充:用户在 UI 上手动触发重试时,BleDeviceService.retryConnect(deviceId) 也会把该设备当作“重新开始”处理:先把该设备从 _deviceAttempts 中移除,再把全局尝试序号 _reconnectAttempts 归零并重启重连循环。其中,真正让该设备恢复到 15 秒快速节奏的是从 _deviceAttempts 中移除;_reconnectAttempts 只用于 RECONNECT_ATTEMPT 等日志/尝试编号,不参与退避计算。
v2 时代的重连是全局的——任一设备累积失败就拖慢所有设备。现在每台中继盒有独立的 _deviceAttempts[deviceId] 计数器;但调度并非完全互不影响,下一轮扫描延迟会取 _reconnectQueue 内各设备延迟的最小值,因此新断开的设备会把整个循环拉回 15 秒档。
_finishConnect 里)ConnectedBoostersNotifier.removeDevice() 还会执行 unawaited(_cloud.disconnect(deviceId))、unawaited(clearWifiConfig(deviceId))、unawaited(_bindingSvc.unbind(deviceId)),把 cloud session、WiFi 配置残留和账号绑定一并清掉,避免已删除设备在后续启动或账号同步中被再次恢复。另外,provider 还会把该 deviceId 记入 _forgotten deny-list。BLE 服务层本身也有第二道保护:_onUnexpectedDisconnect(...) 只有在该设备仍存在于 _devices 时,才会把它重新加入 _reconnectQueue。因此,删除流程之后如果底层又迟到地补发一次意外断开回调,已经从 _devices 移除的设备不会被重新排回重连循环。这样即使删除动作之后还有迟到的 BLE 末帧、cloud 遥测或定时器重扫结果到达,ConnectedBoostersNotifier._onBooster() 也会直接丢弃,不会把仪表盘上的已删设备卡片“复活”。此外,真正的 forgetDevice(...) 流程在移除设备前还会调用 cookingSessionsProvider.notifier.endAllSessionsForDevice(deviceId) 结束该设备的所有活动烹饪会话,并把返回的 session 逐条写入 cookLogProvider 归档,避免之后用同一 deviceId 重新添加设备时把旧 session 一并“复活”。
补充:服务层还有一个与 WiFi 配网直接相关的重连抑制闸 suppressReconnect。
另外,真正执行重连的 _runReconnectAttempt() 入口也会再次检查 suppressReconnect 与 _userInitiatedDisconnect。因此即使 suppression 是在重连 timer 已经 arm 之后才打开,到点触发的 attempt 也会直接 no-op,不会再偷偷跑一轮扫描或直连。当它为 true 时,_startReconnectLoop() 与 _scheduleNextReconnectAttempt() 都会直接跳过,不启动也不重排重连;当它恢复为 false 且 _reconnectQueue 非空时,会记录 RECONNECT_RESUMED 并立即重启重连循环。也就是说,某些流程会临时接管 BLE,避免与后台重连并发抢链路。
Per Liang 2026-04-29 follow-up to E verification:节奏分三段——前台始终 15 秒不衰减,后台前 4 小时与前台一致(也是 15 秒不衰减),后台超过 4 小时改用分级退避。_reconnectDelayFor 读 _isInBackground(ble_device_service.dart:472)+ _inBgTieredCadenceMode(:491)分流:前两段直接返 _reconnectDelayFast,进入分级模式后才按 priorAttempts 走 1–5 / 6–30 / 31+。普通断连(除入仓关机首次延迟 _dockShutoffReconnectDelay = 8 秒 外)也走这同一规则。
4 小时门槛由 _bgTieredCadenceArmTimer(一次性 Timer(4h, _enterBgTieredCadence))触发;回到前台时 timer 被 cancel 并立即退出分级模式。
补充:进入后台时,服务还会 unawaited(_registerPendingIntentScan()),为 _devices 与 _reconnectQueue 中所有有已保存 remoteId`` 的设备注册 Android PendingIntent 扫描;回到前台时会 PendingIntentScanService.instance.stop()并清空_pendingIntentMacToDeviceId。一旦系统投递匹配,_onPendingIntentMatch()不会对每个 match 都无条件直连;它会先检查该 MAC 是否仍映射到当前目标、该设备是否仍在_reconnectQueue或_devices中,以及当前是否其实已经连上。只有这些条件都满足时,才会directConnect(deviceId, m.mac, trigger: 'pending-intent-match')`。因此它是后台重连的额外唤醒路径,但带有显式保护闸。
分级退避(仅后台 ≥4 小时):
| 阶段(attempts) | 延迟 | 适用场景 |
|---|---|---|
| 1–5 | 首次立即执行;失败后的下次调度为 15 秒 | 瞬时 BLE 闪断,通常很快恢复 |
| 6–30 | 60 秒 | 用户可能在处理什么问题 |
| 31 次以后 | 5 分钟 | 中继盒大概率关机,等用户开机 |
无最大重试次数限制——用户随时可能按电源键开机。服务层 BleDeviceService 持有 _gaveUpQueue,并通过 gaveUpDevices getter 暴露为只读集合;本页不应表述为 Dashboard 私有字段。当前代码里这个集合只是保留的状态位:seedReconnectQueue() 会 clear(),retryConnect()、removeFromReconnectQueue()、成功重连路径和 shutdown() 都只会对它做 remove / clear,源码中没有任何把设备 add 进 _gaveUpQueue 的路径,因此现状并不存在“重试耗尽后进入 gave-up 集合”的实际流程。后台 ≥4h 覆盖 ≈ 5×15s + 25×60s = 26.25 分钟的高频段,之后每 5 分钟一次无限重试;前台和后台 <4h 始终保持 15 秒。
// ble_device_service.dart:368-372
static const Duration _reconnectDelayFast = Duration(seconds: 15);
static const Duration _reconnectDelayMedium = Duration(seconds: 60);
static const Duration _reconnectDelaySlow = Duration(minutes: 5);
static const int _reconnectFastAttempts = 5;
static const int _reconnectMediumAttempts = 25;
// ble_device_service.dart:380
static const Duration _dockShutoffReconnectDelay = Duration(seconds: 8);
// ble_device_service.dart:388
static const Duration gracePeriod = Duration(seconds: 90);
// ble_device_service.dart:414
static const Duration _bgTieredCadenceArmDuration = Duration(hours: 4);
Per Liang 2026-04-28 之后,代码已与 v4.2 §六规定对齐:前台始终 15 秒、后台 1–5 / 6–30 / 31+ 分级。pre-spec 的「1–3 attempts / 5 秒超快段」(连同 _reconnectDelayUltraFast / _reconnectUltraFastAttempts 两个常量)已一并删除——按 commit 信息这一段除了入仓关机首次重试外没有产品依据,而入仓关机走的是独立的 _dockShutoffReconnectDelay = 8 秒(见下面「入仓关机特例」),不受影响。
Per Liang 2026-04-29 follow-up:上述「后台 1–5 / 6–30 / 31+ 分级」收紧为「仅后台超过 4 小时才进入分级;后台前 4 小时与前台一致(始终 15 秒不衰减)」,普通断连也走同一规则。详见上文 §重连退避节奏(当前代码实现)。
此外,MQTT-primary 设备为维持 BLE CNT_0 锁会走 seekConnectForLock(deviceId) 补救路径:先尝试普通 connect();若当前手机尚未扫描到该设备而失败,则以该 deviceId 作为 withNames 过滤条件进行一次 6 秒扫描(同样设置 removeIfGone = 20 秒),仅在扫描命中后再重试连接。该扫描使用 ScanPriority.reconnectScan,因此用户主动扫描仍可优先。
每轮 scan-based reconnect 开始前,代码会先调用 FlutterBluePlus.systemDevices([]) 检查系统层仍保留的设备连接;如果发现某个设备的 platformName 命中本轮目标集合,就会先记录 STALE_GATT、执行 disconnect(),再额外等待 1 秒后继续后续扫描流程。这一步属于重连前的陈旧 GATT 连接清理,用来避免 Android 缓存的旧连接句柄干扰新的连接尝试。
补充:当前重连扫描通常会先用每台设备已保存的 remoteId(MAC/UUID)构造 withRemoteIds 过滤列表,再调用 FlutterBluePlus.startScan();单轮扫描窗口是 6 秒,并设置 removeIfGone = 20 秒。但若当前没有任何已保存 remoteId,代码会以空 withRemoteIds 调用,等价于无过滤扫描。这意味着重连路径会优先按已知设备的保存标识做 OS 级过滤,而不是仅靠广播名匹配。
v2 时代的重连扫描一次只盯一个目标,导致即便广播中的 B 在 A 的扫描窗口里出现也被忽略。现在:
SCAN_START(包含 targets=[deviceA, deviceB] 等信息)与 RECONNECT_ATTEMPT_reconnectDelayFor(priorAttempts) 的最小值决定(全局调度 + 每设备失败计数);但若本轮已有设备成功重连,剩余设备不会等待完整退避,而是改用 _reconnectQueueDrainDelay = 2 秒 继续排空队列。补充:90 秒真正到期后,_graceExpire(deviceId) 会把该设备从 _devices 移除、把所有 probe 重置为 Probe.inactive(...),并发出 deviceStatus: DeviceStatus.lostConnection、isBleConnected: false 的 booster 快照。若该设备仍在 _reconnectQueue 且未被用户主动断开、也未被 suppressReconnect 抑制,之后才会把全局 BleConnectionState 提升为 reconnecting;否则在没有其它设备时设为 disconnected。
补充:若设备已经处于宽限期内又收到一次重复的底层意外断开回调,代码不会重置 graceStartedAt 或重新开启新的 90 秒 timer;它只记录 GRACE_DUPLICATE 并忽略这次重复事件。因此宽限期起点以第一次进入 grace 为准,不会被重复回调悄悄延长。
实现上,宽限期内设备虽然仍保留在 _devices,但 connectWithDetails() 与 directConnect() 都不会仅因“条目还在”就直接返回成功;只要设备处于 inGrace,代码仍会继续发起真实的 _ble.connect(...) / _ble.directConnect(...)。此外,directConnect() 还额外要求 existing.booster.isBleConnected == true 才会短路,从而避免把入仓关机 10 秒过渡态(DeviceStatus.boosterShuttingDown、isBleConnected=false)误判成“已经连上”,导致真实重连被跳过。
补充:如果设备在宽限期内重连成功,代码会走静默恢复分支,直接把全局连接状态恢复为 connected 或继续保持 reconnecting,不会发出 BleConnectionState.justReconnected 的 2 秒成功提示态。因此这条路径既不弹断连 banner,也不弹“刚恢复连接”的提示。
非入仓断开时的"静默重连"窗口。服务层会把 isInGracePeriod 置为 true,并将 rssi 清空后立刻发出新的 booster 快照;但当前 provider/UI 只在前 60 秒维持“假连接”显示。若总静默超过 device_providers.dart 中的 _lostConnectionAfter = 60s,列表与 cooking UI 会先翻成 DeviceStatus.lostConnection;BLE 服务层的 gracePeriod = 90s 仍继续在底层静默重试。
| 项 | 值 | 理由 |
|---|---|---|
| 宽限期长度 | 90 秒(gracePeriod) |
legacy 锁 TTL 60s + 30s 重连缓冲 margin |
| v4.2 建议值 | 60 秒(_previousGracePeriod,仅用于 GRACE_EXPIRED 日志中标 diff) |
Kevin 加 margin 得出 90s |
| 报警冻结 | 是 | 宽限期内不触发阈值报警;避免用缓存数据误报(例:牛排已熟但探针其实断了) |
| 低电量报警例外 | 仅 booster low battery 例外 | 宽限期内 per-probe 分支会被 if (isInGracePeriod) continue; 跳过,但 booster 仍继续执行 alarm.evaluateBoosterLowBattery(...) |
| 重连成功后 probe-disconnect 抑制 | 60 秒(Booster.lastConnectedAt 配合 alarm evaluator) |
残留 15B 静默有时间被自然刷新或确认 |
⚠️
isInGracePeriod必须在 GRACE_RECOVERED 时显式清掉(9e520cb + 4ed9630 / TAPD #1003147):set-key 重启之类的 booster reset 让 BLE 在 90 s 内 drop→reconnect,走GRACE_RECOVERED路径。_finishConnect为数据连续性复用入 grace 时的_DeviceState(preCreated即原状态,prior.inGrace在几行前已被翻 false),但旧版本只 copyWithisBleConnected: true/deviceStatus: connected,漏了ds.booster.isInGracePeriod——这个字段在 grace-start 时 stamp 为 true、整个 reconnect 路径再无人清,alarm evaluator 的if (booster.isInGracePeriod) continue阈值闸永久冻结(target-reached / pre-warn / internal/ambient over-temp 整套静音整个 cook,中继盒蜂鸣器自己照响——CM1/CM2/CM3/CM4 都中招,grace 状态机 family-agnostic)。修复在ble_device_service.dart:_finishConnect的 copyWith 显式补isInGracePeriod: false。配套的 Riverpod-snapshot 守卫在device_providers.dart:BoosterNotifier._boosterEqual(4ed9630 起也比isInGracePeriod),保证一帧只翻这个标志的 emit 不被去重吞掉。两处缺一不可。
⚠️ Cloud (WiFi-mode) 的 grace 类比 —
_cloudLostConnectionAfter(20e269f / TAPD #1003169;6ba37e8 / TAPD #1003211 起 30 s → 75 s):MQTT 路径没有上面 90 秒 BLE grace 概念——服务端不报"宽限期"事件。WiFi 模式(手机蓝牙关)下中继盒停推后旧版本 last cloud snapshot 永远挂在 dashboard、deviceStatus仍connected、boosterDisconnected报警永不触发;threshold 报警走 live cloud telemetry 不受影响,但断连报警这条没有 BLE 等价体。修复在ConnectedBoostersNotifier._checkStaleness加 cloud 静默扫——shouldDeclareCloudLost(lastFromCloud, silentFor, currentStatus)predicate(device_providers.dart内,@visibleForTesting暴露给单测)gated 在新_lastFromCloudmap 上,仅 当最近一帧来自 cloud + 静默 > 75 s + 当前不是 lostConnection 时,把 boostercopyWith(deviceStatus: lostConnection, isBleConnected: false, connectedProbes: 全 Probe.inactive)让现有boosterDisconnectedevaluator 起来;BLE 设备走自己的markDisconnected(>15 s阈值),90 s grace 仍是 BLE 唯一权威。配套MqttService把lastConnectedAt在 cloud connect 时 stamp +PENOFF/ probe-staleness 用copyWith(isConnected: false)而不是Probe.inactive(pn)—— 保留lastSeenAt让probeDisconnected60 s 门在 cloud 模式也 eligible(never-seen 槽lastSeenAt == null仍无法 false-fire)。alarmStateProvider顶部的 BLE-onlyconnState == disconnected早返也加&& !cloudActive闸(connectionModeProvider读mode == cloud),纯 cloud 会话不再被 BLE 死状态 short-circuit。75 s 曾是 cloud 版的 90 s——精确值 pending Liang 确认(注释里写明);6ba37e8 同次把MqttService的 no-frame staleness 阈值 15 s → 60 s(详见 MQTT 与云端 §no-data watchdog),cloud-lost 窗刻意比它再宽 15 s 落在 75 s——原 30 s 在 Android WiFi power-save 的 6–35 s 无线 nap(log 实测 42–96 frames / 3 s burst flush)下被误判 disconnect/reconnect 循环。⚠️ 714a1e3 / TAPD #1003266 / Per Liang 2026-06-17 起_cloudLostConnectionAfter75 s → 60 s(即 Liang 确认的 WiFi-drop 1 分钟规则:cloud / WiFi 断连 < 1 min 在 list / cooking UI 上不显 断连、≥ 1 min 才 surface)。同 commitconnectionDisplayStateFor/boosterConnectionDisplayState两个 helper 都新增cloudLive入参,WiFi 模式下中继盒一直 BLE-locked 但 stream over cloud,cloud-reachable 设备恒视为online、瞬时 BLE drop/reconnect(含 0617 log 里的 WiFi-join GATT churn)不在 list / cooking 上闪 断连——仅docked胜过cloudLive,cloud 帧本身是 fresh、不是 stale-datarecentlyLost。dashboard 把原isCloudConnected = connMode == DeviceConnectionMode.cloud全局 fragile 闸换成 per-devicecloudLiveDeviceIds ∪ DeviceConnectionManager.cloudActiveDeviceId(详见 仪表盘 ce8eb0d callout 同源集合);cooking page 把现有的cloudReachable透到 probe helper。CNT_0BLE 锁刷新照旧(perble_service.dart:212任何 connected 链路都跑),BLE 作 backup 保持 lock 不动(FEATURE_PLAN_MULTIPHONE.md §5b / 0315691 记录)。⚠️ 5940b81 / TAPD #1003274 起shouldDeclareCloudLost加appBackgrounded入参 + resume 路径 forgive_lastSeen:Android 在 bg 期冻结 Dart 事件循环 + 挂起 MQTT socket,cloud 帧停推与 booster 是否健康无关;resume 瞬间 overdue 的 staleness timer 立刻 fire、用 pre-bg 时间戳量出 silence(0616 log 877 s「沉默」其实就是 suspend 窗口),误把 booster 翻lostConnection+ 触发boosterDisconnected+probeDisconnected假报警 + 通知,紧接着MqttService误 emitdisconnected触发 BLE fallback(BT 关时再翻 "Scanning for CM4 booster" + "Bluetooth must be turned on"),约 3 s 后 cloud 自己恢复才 settle。修复加setAppBackgrounded(bool)lifecycle fan-out(_AppExitObserver从main.dart喂BleDeviceService.notifyAppBackgrounded/DeviceConnectionManager.setAppBackgrounded/ConnectedBoostersNotifier.setAppBackgrounded三家,详见 01-平台集成 §_AppExitObserver):(a)shouldDeclareCloudLost顶端!appBackgrounded早返;(b) resumesetAppBackgrounded(false)在ConnectedBoostersNotifier内把每台_lastFromCloud == true的_lastSeen[id]重写为DateTime.now()——overdue sweep 起跑窗回到 fresh foreground 60 s,真断的 booster 一个新窗后再 surface lost。BLE 设备走自己的 90 s grace 不受影响。同 5940b81 配套:MqttService._onTelemetryStale与_checkStaleness在_appBackgrounded == true时早返(详见 MQTT 与云端 §no-data watchdog 末尾 5940b81 sub-clause);DeviceConnectionManager.setAppBackgrounded(false)从FlutterBluePlus.adapterStateNow重新 seed_adapterOn(adapter stream 在 bg 期冻结导致 stale-true,2cfbfdf 的 radio gate 在 5940b81 之前因此失效)。
⚠️ 6ba37e8 / TAPD #1003211:BLE adapter-off 时 park 重连循环。原
_attemptReconnect在蓝牙适配器关闭时每 15–20 s 仍跑一次,burn 5 s adapter-wait →SCAN_SKIP→ 失败 +1 → reschedule 的 endless churn(bug log 单 session climb 到 18 次失败 + per-device 计数器被推进 slow-backoff 段,prefer-BLE 一开蓝牙就立刻惩罚 30 + s)。改后入循环顶端读FlutterBluePlus.adapterStateNow,若off/turningOff/unavailable则_parkReconnectLoopForAdapterOff装一个 one-shot_adapterOnWatch等BluetoothAdapterState.on,不计失败 / 不重排 timer;adapter-on 时清 watch + 立即_scheduleReconnect,保留队列与 per-device 计数器原样,per Liang 2026-06-06 prefer-BLE 规则瞬时 re-engage。
⚠️ fe0287d / TAPD #1003230:FG 入场补射 + success 后队列以 2 s 排空。两段都是「cadence tier 仅 pace 失败 重试,不该 pace 其它状态」的同一观察。前者:bg 期 #N 失败把下次 timer arm 到 +300 s(slow tier),用户在 timer 远未到期前把 App 拉回前台——
notifyAppBackgrounded(false)清掉_inBgTieredCadenceMode但 timer 已 armed、它的 appointment 不变,下次扫描仍要等剩余几分钟才跑(field logculinatech_log_20260611071155:07:04:35 armed for +300 s,07:06:25 foregrounded,07:09:35 才扫到、208 ms 命中 CM4_2FC0D9 / 580 ms 连上,CM3_44B2 又等 15 s 才连——boosters 自 07:07 起就在广播)。修复:notifyAppBackgrounded(false)末尾若_reconnectQueue.isNotEmpty && !_reconnectParkedForAdapterOff则 cancel pending timer + 重 arm 1 s(不是 0——让 lifecycle transition settle;_runReconnectAttempt入口自查所有 gate,与 in-flight attempt 撞车降级为SCAN_MGR_DENIEDskip),打RECONNECT_FG_REARMalways-on log。adapter-off park路径不动——TAPD #1003211 的_adapterOnWatchowns resumption。后者:原 matched-success 后剩余设备等下一个完整 cadence gap 才进下次扫描,N 个 booster 一起恢复要 N × (15 s + scan);改用新常量_reconnectQueueDrainDelay = 2 s排空队列——radio 刚 demonstrate 健康、剩下的 booster 大概率也在广播(多设备同时丢通常一个动作:几只探针一起拔了),_runReconnectAttempt入口自查 gate 所以 2 s 内的 suppression flip 安全 no-op,one-connect-per-pass 规则仍然成立(这条规则保护 GATT 连接稳定性,不是 pace 队列的)。RECONNECT_CONCURRENCYlog 同步从「siblings advertising now still wait a full cadence gap」改写为「siblings follow {n}s after this connect lands」。Worst case:fg 中 booster 广播 ≤ 15 s cadence + ~6 s 扫描即命中;N-booster 恢复 ≈ 首个 connect 时间 + ~2 s × (N-1)。
代码入口:意外断开走 BleDeviceService._onUnexpectedDisconnect(deviceId);用户主动断开走 disconnectDevice(deviceId),不经过这个分类函数。
收到 0x07 断开
├─ _lastDockEventAt[deviceId] 在 1 秒内?
│ ├─ 是 → DOCK_TRIGGERED_SHUTOFF
│ │ ├─ 跳过宽限期
│ │ ├─ 发 DeviceStatus.boosterShuttingDown
│ │ ├─ 10 秒 UI 过渡后发 DeviceStatus.boosterOff
│ │ └─ 重连队列以 8 秒延迟排队
│ │
│ └─ 否 → UNEXPECTED_DISCONNECT
│ ├─ BLE 服务层进入 90 秒宽限期(底层持续静默重试)
│ ├─ UI 保持最后已知温度,不显示 banner
│ ├─ 按退避节奏重试(前台与后台 <4h 始终 15s;后台 ≥4h 走 15/60s/5min 分级)
│ └─ provider 层 60 秒静默(`_lostConnectionAfter`,TAPD #1003347 起与 cloud 分支统一)未重连 →
│ DeviceStatus.lostConnection + Reconnecting banner + AlarmType.boosterDisconnected 单声+通知
│ ⚠️ 早于 90 秒宽限期结束;60–90s 内重连成功仍会无声翻回在线(详见下方 §90 秒宽限期)
└─ 用户主动断开 → 跳过宽限期并移除该设备;仅当已无其它设备时,全局状态立即变为 disconnected
| 规则 | 内容 |
|---|---|
| 规则 1 | 入仓状态不显示"重连中",直接显示灰色 + "已入仓" |
| 规则 2 | 入仓引发的断开跳过宽限期;非入仓断开走 90s 宽限期 |
| 规则 3 | 报警在宽限期内冻结(低电量除外) |
| 规则 4 | 手动断开立即显示"已断开",跳过 60s 计时 |
| 规则 5 | 入仓后 10 秒过渡状态:"正在关机..." |
| 规则 6 | 15B 包沉默阈值 = 20 秒(v2 时代是 10 秒;常量 _probeDropoutThreshold,ble_device_service.dart:400) |
| 规则 7 | 开发者模式日志导出(标题 5 连击) |
| 规则 8 | Android 返回键提示(Liang 指定文案) |
详见 v4.2 §五、UX 连接状态显示要求。UI 投影枚举与派生函数见 状态模型 §UI 投影——连接状态展示大量依赖 ConnectionDisplayState / boosterConnectionDisplayState,但并非所有页面都只读该投影;例如当前 cooking_page.dart 仍直接读取 booster.isBleConnected 与 booster.deviceStatus 来派生 isConnectionLost / isReconnecting。
补充:启动链路里,dashboard 还会先调用 ConnectedBoostersNotifier.surfaceKnownDevices(),把已知设备立即 surface 成离线卡片;因此用户不需要等到 BLE 直连、扫描重连或 MQTT 真正连上后才看到这些设备。补充(TAPD #1003442):这些「从未见过遥测」的占位卡片虽 honest 地以 lostConnection 展示在列表,但 ConnectedBoostersNotifier._armSeedGrace / isInSeedGrace 会在 seed 后 _lostConnectionAfter(60 s)内抑制 boosterDisconnected 弹窗/通知,避免冷启动或账号同步后 transport 尚未连上就被误报断连;列表样式不受影响。因此用户不需要等到 BLE 直连、扫描重连或 MQTT 真正连上后才看到这些设备。后续 seedReconnectQueue() 与 DeviceConnectionManager.startManaging(..., bleOnly: false) 是在这些已展示的卡片上继续推进连通性恢复。
补充:启动时并不是无条件立刻对所有已知设备发起 BLE 直连。_directConnectAll() 会先等待 FlutterBluePlus.adapterState 进入 BluetoothAdapterState.on,最长 5 秒;如果超时,则记录 BLE_ADAPTER_TIMEOUT,并把这些设备加入 _reconnectQueue,改走 scan-based reconnect loop。也就是说,启动直连受蓝牙适配器就绪状态 gating,适配器未就绪时会直接回落到扫描重连。另一个关键细节是:对“有已保存 remoteId”的设备,代码会逐台独立发起 direct connect;某一台一旦直连失败,就会立刻把该设备加入 _reconnectQueue 并启动 scan-based reconnect,不会等待其它设备的 direct connect 全部结束后再统一回落。
启动时只会为持久化 connectionMode == 'ble' 的已知设备发起 BLE 直连/重连种子流程;connectionMode == 'cloud' 或仅靠 isWifiConfigured(deviceId) 命中的设备走 DeviceConnectionManager.startManaging(..., bleOnly: false),不是“所有已知设备”都进入 BLE 直连路径。实际 BLE connect 仍由 BleService._connectGate 串行化,一次只连一台。
// ble_device_service.dart: seedReconnectQueue
void seedReconnectQueue(List<String> deviceIds) {
// For devices with saved MAC addresses, attempts direct connect in
// parallel (no scanning, no startup delay). Devices without saved MACs
// fall back to the scan-based reconnect loop.
_directConnectAll(toAdd);
}
语义:
直连本身经 BleService._connectGate(ble_service.dart:351)chained-Future 串行化,避免 N 台已知设备启动时并发抢 CCCD 写 / MTU 协商 / indicate 订阅。
⚠️ 44874f7 / TAPD #1003262:cloud-side launch reconnect 同步扩到 WiFi-configured 设备。上面 BLE seed 之外,
_AppInitializerlaunch 时还按持久化记录把每台 (a)connectionMode == 'cloud'或 (b)isWifiConfigured(deviceId) == true的设备过DeviceConnectionManager.startManaging(bleOnly: false)——startManaging对 cloud-capable 设备的当前策略不是 BLE-primary,而是 MQTT-primary:broker 在线时 cloud 是主 telemetry 通道,BLE 只保留为 CNT_0 lock /HOST=通道;仅当 cloud 未连接且用户未强制 cloud 时,才回到DeviceConnectionMode.ble。同时它仍会 arm adapter watcher,让运行时 BT-off 时可 fail over 到 cloud。先前只命中connectionMode == 'cloud'一档:WiFi-provisioned booster 的持久化记录是connectionMode='ble'(即便它在 cloud 模式跑),launch 时只入 BLE seed、关蓝牙后 reconnect loop 走BLE_ADAPTER_TIMEOUT → RECONNECT_PARKED永挂、manager 永不管理该设备、MQTT session 不起、adapter watcher 不 arm(field log 142745:BT off 重启 App 后只剩KNOWN_DEVICES_LOADED 1 BLE → RECONNECT_SEED → BLE_ADAPTER_TIMEOUT → RECONNECT_PARKED、之后无任何 cloud 尝试,只能开蓝牙才恢复且再关又掉)。新加的isWifiConfigured()分支(SharedPreferences 持久化 flag,配网成功saveWifiConfig时写)兜底了「BLE seed + cloudstartManaging」并存的语义:BLE 在时仍走 BLE,BLE 不在时 cloud 接管。
BleDeviceService.gracePeriod)是 legacy TTL 60s + 30s margin 的和,动其中任一项要同步动另一项。这不是 UI 翻 lostConnection 的判据——那是 device_providers.dart 的 _lostConnectionAfter = 60s(TAPD #1003347 起 BLE 与 cloud 分支统一用同一常量,故意比 90s 宽限期短),90 秒宽限期只管 BLE 服务层底下静默重试到几时;改其中一个前搞清楚改的是哪一个_reconnectDelayFor 读 _isInBackground + _inBgTieredCadenceMode 分流——动节奏要同步动 dashboard_page._slowReconnectThreshold(值 = _reconnectFastAttempts + _reconnectMediumAttempts = 30)_lastDockEventAt 主要在 RX 字节层记录(_onAe05Rx),以保证 0x07 断开可及时分类;parser 层的 _handleProbeStatus 也会再次刷新该时间戳作为后备——改动 parser 时不要动 RX 钩子顺序_deviceAttempts、_reconnectQueue、_gaveUpQueue、_lastDockEventAt、_boosterOffDevices、_shutoffTimers、_devices、_transports、_pendingIntentMacToDeviceId(c2d00db / TAPD #1003272:先前漏了 PI scan snapshot——OS 投递的 scan match 仍解析回 deviceId、_onPendingIntentMatch 无条件 directConnect 把已删设备又连回去;同 commit _onPendingIntentMatch 顶端加 !_reconnectQueue.contains(id) && !_devices.containsKey(id) → drop + log PI_SCAN_MATCH_DROPPED 闸做第二道防线,stale snapshot 内 OS-level filter rebuild 之前的任何 match 都会被 UNMAPPED 短路)+ DeviceConnectionManager 整套 managed state(6c0feb0 / TAPD #1003272 recurrence:c2d00db 闭合了 PI scan 路径但 connection manager 一侧还开口——forgetDevice 拆 BLE 链路 / reconnect queue / known-devices DB / server binding,没告诉 manager 停管,manager 看到 BLE drop 触发 CONN_MGR_CLOUD_FALLOVER 保住 cloud session,~60 s 后 cloud-stale _ensureBleSession 反向 BLE-fallback 主动重连并重新 lock CNT_0 心跳已删设备(0618 log 实证:DEVICE_FORGOTTEN 15:57:39 → CLOUD_FALLOVER → MQTT_TELEMETRY_STALE 15:58:55 → CONNECT_CALL trigger=cloud-lost-fallback → telemetry + CNT_0 resume,factory reset 后再被 lock 回来)。修复加 DeviceConnectionManager.stopManaging(deviceId):不会逐设备取消订阅(subs are global);它当前只移除该设备的 _managed 条目、调用 _cloud.disconnect(deviceId)、把该条目的 mode 设为 DeviceConnectionMode.disconnected、记录 CONN_MGR_STOPPED,并在最后一台设备移除后才由 _teardownSubsIfIdle() 拆全局订阅。当前 DeviceConnectionManager 不是单设备 manager,而是按 deviceId 保存在 _managed map 里;stopManaging(deviceId) 只移除该设备的 managed entry、调用 _cloud.disconnect(deviceId)、把该条目的 mode 设为 DeviceConnectionMode.disconnected、记录 CONN_MGR_STOPPED 并刷新聚合 mode。代码注释明确写明不会逐设备取消订阅(subs are global)。。forgetDevice 在 bleService.disconnectDevice(deviceId) 之前调,manager 永远观察不到这次 drop 也就没 fail over 起点)全套