本章讲 App 在连接出问题时如何恢复——分级退避重连、90 秒宽限期、入仓触发关机的特殊路径。
关键文件:lib/core/services/ble_device_service.dart(4177 行)
v2 时代的重连是全局的——任一设备累积失败就拖慢所有设备。现在每台中继盒有独立的 _deviceAttempts[deviceId] 计数器,互不影响。
_finishConnect 里)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 并立即退出分级模式。
分级退避(仅后台 ≥4 小时):
| 阶段(attempts) | 延迟 | 适用场景 |
|---|---|---|
| 1–5 | 15 秒 | 瞬时 BLE 闪断,通常很快恢复 |
| 6–30 | 60 秒 | 用户可能在处理什么问题 |
| 31 次以后 | 5 分钟 | 中继盒大概率关机,等用户开机 |
无最大重试次数限制——用户随时可能按电源键开机。Dashboard 有 _gaveUpQueue 追踪"彻底放弃"的设备,供 UI 做不同提示。后台 ≥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 秒不衰减)」,普通断连也走同一规则。详见上文 §重连退避节奏(当前代码实现)。
v2 时代的重连扫描一次只盯一个目标,导致即便广播中的 B 在 A 的扫描窗口里出现也被忽略。现在:
SCAN_START(包含 targets=[deviceA, deviceB] 等信息)与 RECONNECT_ATTEMPT_reconnectDelayFor(priorAttempts) 的最小值决定(全局调度 + 每设备失败计数)非入仓断开时的"静默重连"窗口。UI 看不见任何异样——最后温度仍然显示,Reconnecting banner 不弹。
| 项 | 值 | 理由 |
|---|---|---|
| 宽限期长度 | 90 秒(gracePeriod) |
legacy 锁 TTL 60s + 30s 重连缓冲 margin |
| v4.2 建议值 | 60 秒(_previousGracePeriod,仅用于 GRACE_EXPIRED 日志中标 diff) |
Kevin 加 margin 得出 90s |
| 报警冻结 | 是 | 宽限期内不触发阈值报警;避免用缓存数据误报(例:牛排已熟但探针其实断了) |
| 低电量报警例外 | 否 | 低电量是持久状态,不受宽限期影响 |
| 重连成功后 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)
收到 0x07 断开
├─ _lastDockEventAt[deviceId] 在 1 秒内?
│ ├─ 是 → DOCK_TRIGGERED_SHUTOFF
│ │ ├─ 跳过宽限期
│ │ ├─ 发 DeviceStatus.boosterShuttingDown
│ │ ├─ 10 秒 UI 过渡后发 DeviceStatus.boosterOff
│ │ └─ 重连队列以 8 秒延迟排队
│ │
│ └─ 否 → UNEXPECTED_DISCONNECT
│ ├─ 进入 90 秒宽限期
│ ├─ UI 保持最后已知温度,不显示 banner
│ ├─ 按退避节奏重试(前台与后台 <4h 始终 15s;后台 ≥4h 走 15/60s/5min 分级)
│ └─ 宽限期满未重连 → DeviceStatus.lostConnection + Reconnecting banner + AlarmType.boosterDisconnected 单声+通知
└─ 用户主动断开 → 跳过一切,立即 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 投影——UI 只读 ConnectionDisplayState,不直接看 Booster.isBleConnected。
启动时会为所有已知设备分别发起直连流程;但实际 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)——startManagingGATT 链路活时优先 BLE(_mode = ble,详见 MQTT 与云端 §BLE-primary 强化),同时 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 接管。
_reconnectDelayFor 读 _isInBackground + _inBgTieredCadenceMode 分流——动节奏要同步动 dashboard_page._slowReconnectThreshold(值 = _reconnectFastAttempts + _reconnectMediumAttempts = 30)_lastDockEventAt 在 RX 字节层记录(_onAe05Rx),不是 parser 层——改动 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):取消每条 transport sub(_bleTelemetrySub / _bleStateSub / _mqttStateSub / _adapterSub)、drop 该 deviceId 的 cloud session、清 _deviceId / _preferBleOnly / _userForcedCloud / _wifiConfigured、_setMode(disconnected)、打 always-on CONN_MGR_STOPPED 日志;serving 另一台设备时 no-op(manager 单设备)。forgetDevice 在 bleService.disconnectDevice(deviceId) 之前调,manager 永远观察不到这次 drop 也就没 fail over 起点)全套