✅ 已归档:本重构已合并到 main。
lib/core/transport/现在是 main 的一部分,CM4 SET_TARGET 已接线,大多数业务命令派发走BoosterTransport,但当前仍有部分 CM4 连接后查询(如VER、RI_CNT=?、TEMP=?)直接走_ble.sendCommandTo(...)。本页保留作为重构史料 + 下一次类似拆分(例如新增设备家族)的参考范本。
当前 main 的现状描述见 仓库结构 与 BLE 协议。
| 项 | 值 |
|---|---|
| 完成时间 | 2026-05 |
| 分支(已合并) | refactor/transport-split |
| 重构 commits | 12 / 12 ✅ |
| 拆分之后的修复 | 8 (见 §拆分之后的修复) |
| Fork 点 | 9ed3852 |
SET_TARGET cycle(b4d4ef1 → ba53f91 revert)暴露了 CM4 与 legacy 协议在 Dart 层共享 dispatch 点的脆弱性 —— 协议级修复每次都在 BleDeviceService 里加新的 if (deviceId.startsWith('CM4_')) 分支。本重构把家族判别下推到 transport 层,让 feature 代码 transport-agnostic:上层只需 transport.setProbeTarget(...),每个 transport 实现自己的 wire format(legacy 的 0x55XX vs CM4 的 ASCII)。
零行为变化,纯 additive。
9f552a7 — 新建 lib/core/transport/booster_transport.dart:abstract class BoosterTransport + BoosterFamily { legacy, cm4 } enum + String.boosterFamily 分类 extension。零调用点。8d70e0f — 拆出 lib/core/transport/booster_family.dart(避免 core/models/booster.dart 经 BoosterTransport 间接 import services 形成循环依赖);为 Booster 加 family getter;4 处 UI inline startsWith('CM4_') 替换为 booster.family.isCm4。补充(current main):CM4 在连接建立后,BleDeviceService 还会按 AE03 同设备写入节流规则,分三次直接发起读回查询:300 ms 后发 VER(读取版本与 WiFi MAC),600 ms 后发 RI_CNT=?(读取蜂鸣次数/时长设置),900 ms 后发 TEMP=?(读取 booster LCD Display mode)。这些是当前 main 里新增且重要的连接后同步流程。
补充(current main):当前代码在这三次查询之后,还会在 1200 ms 后补发一次 SET_C 或 SET_F,把 App 当前温标主动同步到 CM4。注释说明这是为了修复“booster 仍停留在出厂 °F、导致后续 SET_<probe> 目标值按错误单位落盘”的问题,因此它也是连接后同步流程的一部分,而不只是用户手动切换单位时才会发生。
补充(current main):BoosterTransport.setProbeTarget(...) 的抽象签名现在已经把两大家族的参数差异显式编码进接口:必传 probeAddressBytes(legacy 使用的 6-byte probe MAC)、tempByte(当前单位下的原始目标温度字节),并保留可空 meatType / doneness 作为 CM4 专用参数。也就是说,上层虽然统一调用同一个 setProbeTarget,但跨家族差异并没有被“隐藏掉”,而是通过同一签名显式传入,再由各 transport 决定具体 wire format。
补充(current main):setProbeTarget 现在还带有可空 workState 参数。BoosterTransport 注释明确说明它是 CM4 V19+ 才使用的 cook 生命周期信号,承载在该命令的第 7 个字符中;legacy 会忽略这个参数。若调用方不传入 workState,则表示没有显式生命周期意图,CM4 会发送不含第 7 个状态字符的六字符命令;而 Cm4WorkState.start 只应由用户的 cook-start 操作触发。因此,这个抽象当前不仅统一了 target 写入,也开始承载 CM4 的烹饪启动/停止状态。
补充(current main):BoosterTransport 现在除 setProbeTarget / setUnit / muteAlarm / queryStatus 外,还包含 disarmProbeAlarm 与 sendSetting。另一个当前 main 的重要变化是:这个抽象现在还暴露 deviceId、family、probeCount,并声明了 dataStream / connectionStateStream / rssiStream 以及 connect({required String trigger}) / disconnect() / dispose() 等接口;其中 connect 返回 Future<ConnectResult>。;但当前具体实现里,这些 stream 仍返回 const Stream.empty(),connect() / disconnect() 仍抛 UnimplementedError,连接生命周期实际还在 BleDeviceService,因此它目前主要仍是命令派发抽象而非完整连接 facade。其中 CM4 的 disarmProbeAlarm 发送 SET_{COLOR}=000000<G> 清除单探针的闹钟展示(icon + 背光闪烁);sendSetting 用于下发如 RI_CNT=、RI_LVL= 等 booster 级设置,并且 CM4 当前并不只是“BLE 断开时再 fallback”:当设备处于 cloud-primary 时,命令会优先经 MQTT 发送;仅在非 cloud-primary 时才优先走 BLE,并在 BLE 不可用时再回退到云端。legacy 的 disarmProbeAlarm 为 no-op,sendSetting 仅走 BLE。
12ca953 — LegacyBoosterTransport 实现 wire-level legacy 命令:setProbeTarget / setUnit / muteAlarm / queryStatus。BleDeviceService 的对应方法 delegate 到 transport;状态记录(lastUserTargetWriteAt、persistence、ack-with-retry)暂留 service 层。c072acf — Cm4BoosterTransport 实现 + CM4 SET_TARGET 首次接线。调用 CM4Command.configureProbe(cm4_protocol.dart 已存在的 builder)发送 SET_<probe>=0<unit><temp><meat><doneness>。cooking_page._pushTargetToDevice 不再对 CM4 早返。meatType / doneness 默认 (1, 3);bd5312e(TAPD #1003050 ②)后改为 cooking_page._pushTargetToDevice 通过 _resolveMeatType().cm4WireE + donenessForTempF(_targetTempF).cm4WireF 透传 picker 的实际选择,闭合「App→Booster 屏幕一律显示 'Custom'」的回归(999555f 的家族感知 picker 是前置——CM4 所有可选名都映射到 E 2–9,仅 Custom 槽 fall through 到 E=1),transport 层 fallback (1, 3) 保留作 null-safety 兜底。原 °F > 99 的 Q10 边界后由 a3c9cf7 + Beta 2026-05-11 spec 收口:CD 是大写十六进制 00–FF,守卫放宽到 > 0xFF。SET 前缀本身在合并后 flip-flop 了两次(2026-05-11 色名 → 2026-05-12 数字 SET_01..04 → 2026-05-19 反向恢复色名 SET_BL/WH/BU/YE per cloud doc + Liang HW 实测),详见 CM4 协议 §Q1 / §SET 前缀历史。fef011f — dispatch 点崩塌:3 处 family-branch 调用点(device_providers._broadcastUnit、ble_device_service.muteAllAlarms、cooking_page._pushTargetToDevice)改为单一 transport.x(...) 调用。删除冗余的 setLegacyTarget / setLegacyTempUnit / setCm4Target 方法。补充(current main):BleService 现在会对每个设备的首次 heartbeat / lock-refresh 周期写入做确定性的 phase staggering:用 deviceId.hashCode.abs() % (hbInterval * 1000) 计算偏移量,再延后启动该设备的周期定时器。这样多台 booster 同时在线时,不会把 AE03 周期写入都堆到同一时刻,有助于分散 GATT 写压力。
务实 scope:wire-format 选择移到 transport 层;timer 所有权暂留 _DeviceState —— 完整 lifecycle 迁移需要 HW gates between commits,对单次 delivery 风险过高。
def2978 — heartbeat bytes / interval / Android MTU 等 wire-format 决定移到 BoosterFamily extension:heartbeatIntervalSeconds、heartbeatBytes、requestsLargeMtu。BleService._sendHeartbeat 不再 inline startsWith('CM4_') 决定字节。补充(current main):heartbeatIntervalSeconds 现在对 legacy 和 cm4 都是 45 秒;heartbeatBytes 分别是 legacy 的 LegacyCommand.lockRefresh 与 CM4 的 CM4Command.heartbeat.codeUnits;requestsLargeMtu 仅对 cm4 为 true。469da3e — Audit 发现 DeviceConnectionManager.sendCommand 零生产调用点(命令一直走 BleService.sendCommandTo 直接),plan 假设的 "MQTT failover for command publish" 是 dead code。删除。autoConnect=true 决策保持现状。01bf418 — _legacyTransports + _cm4Transports 合并为单一 Map<String, BoosterTransport> _transports。_transportFor 是唯一访问点;caller 只见 BoosterTransport 抽象类型,不见具体实现。ed6df9e — 移除 LegacyCommand.isLegacyDevice。最后一处 caller(BleDeviceService:1116)改用 deviceId.boosterFamily.isLegacy。3c9441b — 移除 Probe.commandPrefix / commandPrefixLegacy(零调用点 dead code)。probe.dart 的 cm4_protocol.dart import 顺带移除,model ↔ protocol 分层恢复干净。a3304fc — sweep 剩余 inline startsWith('CM4_'):ble_service.dart:1291 改用 family.isLegacy。当前代码并非只保留 model-level 前缀检查;除 deviceTypeLabel、getProbeCount 和 canonical classifier 外,UI 层如 lib/features/thermometer/presentation/pages/settings_page.dart 仍使用 deviceId.startsWith('CM4_') 过滤 CM4 设备。39acbce — MockBoosterTransport recording mock + 8 个 smoke tests(family 分类、heartbeat 常量、mock 调用记录)。完整 service-level coverage 留作 follow-up。这一节里的 bug 大多数也存在于 main——之所以 branch-only 是因为不想在测试周期里扰动 main。merge 之后已统一进入主线。
824ec3a,2026-05-01)Symptom(HW test 视频,CM1_E4EF): UI 顶部 banner 一直显示 Reconnecting to CM1_E4EF...,cooking 界面也是断连样式,但 BLE 实际是健康的——RSSI -27 ~ -33 dBm,RX 计数稳定增长 292→334,TX 按周期发 55 b1(45s 锁刷新)和 55 ae 00 00(30s settings query)。alarmStateProvider 也因此短路(line 1636 的 isGloballyDisconnected gate),导致 full-screen WarningPage / 铃声不响——只有系统通知栏还在工作。
Root cause: ble_device_service.dart _onUnexpectedDisconnect 在 line 1363 的 _reconnectQueue.add(deviceId) 没有 gate ds != null。_forgetDevice 流程是:先 removeFromReconnectQueue 清队列,然后 disconnectDevice 把设备从 _devices 删掉,BLE 层再断开——这次 BLE callback 进来时 ds = _devices[deviceId] 已是 null,但无条件的 _reconnectQueue.add 把刚被忘掉的设备又塞回了队列。队列从此永远非空,每次成功回连都被 _setState(_reconnectQueue.isEmpty ? connected : reconnecting) 判定为 reconnecting。HW test 视频里有 2 次 FORGET_DEVICE(CM1_E452、CM4_308A),刚好踩中这条路径。
Fix: 把 line 1362 的 queue.add + _startReconnectLoop() gate 在 ds != null && deviceId.isNotEmpty。设备已不被追踪就不该重入队列。
3d7e65f,2026-05-01)Symptom(自审计发现): C5 的 dispatch 崩塌把 cooking_page._pushTargetToDevice 的 family-branch 折成单一 setProbeTarget 调用,但同时丢掉了 OLD 代码里的 early-return 守卫——legacy 设备如果探针地址还没收到 6 字节,整个 dispatch 就跳过;而 NEW 代码里 dispatch 会跑,BleDeviceService.setProbeTarget 内部先记 lastUserTargetWriteAt + 持久化目标温度,然后 LegacyBoosterTransport.setProbeTarget 才检查地址长度并 log SET_TARGET_SKIP 早返。
副作用:TAPD #5 echo-suppression 时间戳被记下来了,但 wire 写其实没发;接下来一个 12B reconciliation 看到时间戳很新就 suppress 了——App 和中继盒静默背离,没有可见的 "目标值跳回旧值" 错误反馈。窗口很窄(探针刚激活、第一个 15B 还没到的 ~3s 内用户调目标温),但比起 OLD 的可见失败,silent divergence 更糟。
Fix: cooking_page 重新引入 early-return,gate 在 booster.family.isLegacy && probeAddress.length != 6 上——CM4 不需要地址,可以放行。同时把 core/models/booster.dart 的 import 加回来(C5 折叠时被认为不必要而删掉,现在又需要 family extension)。
1aaa284,2026-05-01)触发: v5 doc review 把 v4 §15 的 ">101°C 触发 HI 并钳制到 101°C" 改成了双段:
| 范围 | 行为 |
|---|---|
| ≤ 101 °C | 显示真实数值 |
| 102 ~ 120 °C | cooking-page 显示 "HI",但 WarningPage 全屏读数显示真实温度 |
| > 120 °C / out-LUT | "HI" + 钳制到 120 °C |
老规则把 102 ~ 120 °C 的真实数据压平成 101 °C,丢失了 WarningPage 上的精度。
Fix:
AlarmThresholds 新增 internalHardClampC = 120(internalHardClampF = 248.0)internalHiClampC = 101 由"触发 + 钳制"重新定义为"仅触发 HI flag"internalMaxDisplayF 改成对齐 internalHardClampF(WarningPage 上限从 213.8 °F 提到 248 °F)cm4_data_parser line 666 的钳制逻辑:trigger flag 仍在 >101,但只有 >120 才把数值钳到 120;in-LUT 的 102 ~ 120 保留真实数值isInternalTempAboveRange flag 的下游消费者(cooking-page "HI"、internalOverTemp 报警)逻辑不变。
8ea1074,2026-05-01)两个自审计发现的小 polish 项目,一起 commit:
BleDeviceService.disconnectDevice 新增 _transports.remove(deviceId) —— transport 是 _ble + deviceId 的无状态包装,原本不删掉只是无害的 map 增长,但反复 forget / re-pair 会留垃圾。LegacyBoosterTransport / Cm4BoosterTransport 的 stream getter(dataStream / connectionStateStream / rssiStream)从 throw UnimplementedError 改成 const Stream.empty()。当前没有 caller 订阅这些流,但万一未来 refactor 接进去,silent empty stream 比 runtime crash 安全得多。connect() / disconnect() 的 UnimplementedError 保留——那两个本来就是要响的"缺口",要 loud。cfd00bb,2026-05-02)V2454A(1080-wide)上 hardware test 时出现 Flutter 黄黑警告条:"RenderFlex overflowed by 9.4 pixels",发生在中继盒卡片 status 行(status==boosterShuttingDown 时本地化字符串 "Probe docked, shutting down…" 装不下 dot+name+info+battery+signal)。
Fix: status Text widget 包进 Flexible(fit: FlexFit.loose) + maxLines: 1 + TextOverflow.ellipsis,长字符串末尾 ellipsize 而不溢出。同时把级联 if/else-if 折叠成单一三元表达式,结构更清晰。
Pre-existing 隐性 bug —— 在 iPhone(更宽)和短 status 字符串("Reconnecting" / "Booster off")下不会触发;refactor 测试时才显形。
8d62648,2026-05-02)Vivo V2454A profile-mode 测量显示 raster phase 卡在 16-18 ms(120 Hz 屏幕的 8.3 ms 预算超 2 倍)。两个针对性 fix:
A. BG-MON 通知节流(notification_service.dart):
startBackgroundMonitoring 当前并不是调用 _notifications.show();注释与实现都表明,Android 的后台监控通知现在由原生 BleMonitoringService 持有,Dart 侧通过 ForegroundServiceBridge.instance.setText(text) 更新文案,并继续用 _lastBgMonText 对未变化的文本 short-circuit。
B. Android 关闭 BackdropFilter blur(cooking_page.dart):
cooking page 一次合成 6 个 frosted-glass tile + 1 badge × BackdropFilter+ImageFilter.blur。底层 gradient bar 一直在动,RepaintBoundary 也无法 cache,每帧都 re-rasterize。新增 _adaptiveBlur helper:iOS 保留 GPU-加速的 BackdropFilter,Android 改为半透明白填充(alpha 0.18 vs iOS 的 0.10)。视觉差异:Android 上「frosted」变「translucent」——elevated card 的视觉效果保留,没有 blur shader 成本。
1d4e278,2026-05-02)navigateInstant 这个 helper 历史上用 Duration.zero,后来改成 CupertinoPageRoute 是为了恢复 iOS edge-swipe pop gesture。但 CupertinoPageRoute 的 300 ms slide 在 Android 上是 felt-jank 的真凶 ——300 ms 内 source frame + 正在构建的 destination frame 同时合成;在 cooking page 这类 widget tree 复杂的目的页上 compositor 跟不上。
Fix: 按平台分流。iOS 保留 CupertinoPageRoute(slide + edge-swipe),Android 改回 PageRouteBuilder(transitionDuration: Duration.zero)(snap-in,无 transition 阶段可 jank)。V2454A 上确认主观流畅度显著提升。
影响:app 内所有 navigateInstant 调用点 —— dashboard → cooking、cooking → preset / meat / graph、cook log → detail。
14e8d34,2026-05-02)三个 hardware test 暴露的 UX bug 一起修:
A. 信息类报警改为居中 card(alarm_banner.dart 新建 + app.dart):
informational alarm(ambient under-temp、target reached、probe disconnect 等)之前与 safety alarm 一样走 full-screen WarningPage —— 对于「环境温度低于 40°C」这种通知性事件 UI 权重过重。现在 informational 走居中 card:dim backdrop + 居中浮动 card(warning icon + 完整 title + body + Confirm pill),点击 card 或 backdrop 静音并消失。SAFETY alarm(internalOverTemp + ambientOverTemp)仍走全屏红色 WarningPage —— 那两个本来就是 Critical Alerts 候选场景,必须不可错过。WarningPage 自身的 title/body 也分了字号(之前 26pt w800 全部一样大)。
B. Goal Setting 的 meat 网格不再过窄(meat_presets_page.dart):
4 列网格之前 mainAxisSpacing: 4 + childAspectRatio: 1.05,每个 cell 只有 ~70 logical-px 高,装不下 64-px 圆 + 4-px gap + 11-px label,结果 label 被下一行图标盖住。改为 mainAxisSpacing: 14 + childAspectRatio: 0.85,cell 比宽更高,label 完整显示,行间有呼吸空间。
C. 温度图永不空白(cooking_page.dart _TempGraphPainter):
之前三处 early-return(points.isEmpty / range<1 / dur<1)使得只要历史里 < 2 个点(即温度恒定时几乎总是这样,因为 _recordTemperature 只在变化时 append),整个 canvas 就是空白。现在 painter 总是绘制 chrome(坐标轴 + 网格 + 虚线 target)。points.isEmpty 时会居中显示「Waiting for telemetry…」;points.length == 1 时则直接绘制一个单点,让图表立刻出现,不再显示「Collecting data…」hint。
lib/core/transport/ 描述BoosterFamily extension)