CulinaTech App 是一款 Flutter/Dart 编写的智能温度计应用,通过 BLE 与「中继盒」(booster)通讯,中继盒再无线读取探针温度。主打场景:烹饪过程中实时监测温度、达到目标温度报警、多设备并行监控。
另外,当前代码已包含账号相关子系统:有账号区域锁定(accountRegionServiceProvider)、设备绑定(deviceBindingServiceProvider)以及登录/冷启动后的跨手机设备同步(syncAccountBoundDevices())。
另有一套登录会话名额子系统:应用在冷启动恢复会话和后续登录转为 signedIn 后,都会先执行 _ensureAccountSession(),确保当前手机占用账号最多 4 个登录 slot 之一;若服务端返回 SessionLimitExceeded,会弹出 _showSessionLimitPicker(...) 让用户选择移除哪一台旧手机后再继续当前登录。同时,代码还会启动 _startSessionRealtime(),并在 _startLivenessMonitor() 的 60 秒心跳里调用 _checkSessionPresence();如果本机 slot 被其他手机移除,会执行 _handleSessionKicked(),清掉本地账号/设备数据、随后 signOut(),并弹出“该手机已被下线”的提示。此外,当前代码还包含账号存活探测与跨手机删号后的本地全量清理:启动恢复会话后和登录成功后都会启动 _startLivenessMonitor(),每 60 秒调用 checkAccountLiveness();实时设备同步的 onSync 也会顺带触发该检查。只有当账号存活探测连续 2 次得到 AccountLiveness.deleted 时,代码才会执行 wipeAllLocalData(ref);第一次 deleted 只会在 10 秒后安排一次确认复探,任一 alive 或 unknown 都会清零计数。确认后才会随后 signOut() 并弹出账号已删除提示。因此该项目已不只是本地 BLE 控制 App,也包含账户侧的设备归属与同步能力。
此外,当前代码还包含实时跨手机设备列表同步:应用从真正后台(paused / hidden)回到前台时,还会执行 _onForegroundedAccountSync():先 stop() 再重新 _startDeviceRealtime(),随后立即 syncAccountBoundDevices(ref) 与 cookLogProvider.notifier.syncNow(),补齐后台期间 Supabase realtime 不回放而可能漏掉的设备绑定和 cook log 变更。冷启动时,main.dart 在 KnownDevicesService.init() 之后还会立即调用 connectedBoostersProvider.notifier.surfaceKnownDevices(),先把本地持久化的设备列表以“离线卡片”形式渲染到 Dashboard,再并行等待 BLE / MQTT / 账号同步把这些卡片点亮;因此即使云端或蓝牙尚未连上,首页也不会先出现空白设备列表。main.dart 会在冷启动初始化 _initKnownDevices() 时先执行 _ensureAccountSession()(≤4 台手机登录位准入;返回 false 则跳过后续账号同步),通过后再执行 reconcileAccountRegion(ref) 与 syncAccountBoundDevices(ref),随后才调用 _startDeviceRealtime();并在后续登录成功转为 signedIn 时再次启动 deviceBindingRealtimeProvider 订阅;当同账号其他手机新增绑定设备时,会通过 onSync 再次触发 syncAccountBoundDevices(ref);删除绑定设备时则走 onDelete 直接 forgetDevice(ref, name, unbind: false),同样无需等待下次重启 App。此外,当前代码还包含跨手机 cook log 云端同步:main.dart 会在冷启动恢复会话后的 _initKnownDevices() 中启动 _startCookLogRealtime(),并立即执行 cookLogProvider.notifier.syncNow();后续登录成功转为 signedIn 时也会再次启动同一套订阅并立刻同步一次。当同账号其他手机修改烹饪记录时,cookLogRealtimeProvider 的 onRemoteChange 会再次触发本机 syncNow() 拉取最新记录。
另外,当前代码还实现了退出登录时的本地清理流程 wipeLocalDeviceData(ref):会停止 deviceBindingRealtimeProvider、关闭本机所有云连接管理、清空本地 WiFi 配置和 KnownDevicesService 设备列表,并立即清空内存中的设备卡片;但不会执行云端 unbind,因此账号在 Supabase 上的设备绑定关系会保留,其他同账号手机仍可继续看到该设备。同时,底层 _teardownLocalDevices(ref) 还会遍历当前已知/内存中的所有 deviceId,逐个执行 markForgotten(id)、removeFromReconnectQueue(id) 与 disconnectDevice(id),避免活跃 BLE 链路或尾随遥测在 wipe 后把本地设备卡片重新恢复出来。
此外,代码还包含独立的 onboarding/权限引导子系统:main.dart 会在首次启动时先展示 PermissionsIntroPage,随后串行执行缺失权限检查、系统蓝牙关闭提醒、低音量提醒、Android 后台权限引导与冷启动后的重提醒,并在最后触发首页新手引导(homeOnboardingProvider.notifier.maybeShow())。此外,代码会在冷启动时通过 BackgroundKillWatchdog.instance.captureColdStartVerdict() 检查上一次是否在后台监控设备时被系统杀死;若为 Android 且设备厂商存在自启动限制、并且重提醒未处于冷却期,则会再次打开 BackgroundPermissionPage 引导用户配置后台权限。对应页面位于 lib/features/onboarding/。另有一层和设备接入直接相关的 setup 抑制机制:deviceSetupProvider 会在 CM4 刚进入连接方式选择页或 WiFi 配网流程时,把该 deviceId 标记为 in-setup,alarmStateProvider 在按设备遍历时会直接跳过该设备,因此像 ambient under-temp、probe/booster low battery 等告警不会在“请选择连接方式”阶段提前弹出;待接入流程提交完成后才恢复正常告警评估。
此外,应用根部还常驻两个与烹饪过程直接相关的同步子系统:app.dart 会持续 watch cookSessionRecorderProvider 与 cookingLiveActivitySyncProvider。另外,app.dart 还会持续 watch authControllerProvider,用于在应用启动时恢复持久化登录会话;因此已登录用户在后续 WiFi 配网链路中可以直接沿用现有会话,而不必每次重新进入登录页。前者负责在任意页面下持续记录所有活跃烹饪会话的温度曲线,后者负责按会话状态驱动 iOS Live Activities 的开始、更新与结束,因此这两类能力都不依赖用户当前是否停留在烹饪页。另有一条与此配套的退出收尾链路:main.dart 中 _AppExit.run() 会在 detached / 原生 onDestroy 触发时先调用 finalizeActiveForAppExit(),把所有已达标但仍处于尾随记录窗口的会话就地完结;随后还会调用 CookingLiveActivityService.instance.endAll(),避免应用被手动关闭后锁屏上残留过期的 Live Activity 卡片。
同一层里,MaterialApp.builder 还会把整个 Navigator 包在 ConnectionAlertHost 与 AlarmBannerHost 外层:前者让 WiFi 模式的连接丢失弹窗可以覆盖任意页面,后者让非 safety alarm 的顶部横幅在任意页面上都可见。这两层也是应用根部的常驻全局 UI 子系统。
另外,main.dart 还会在应用根部常驻激活 alarmStateProvider:既监听 connectedBoostersProvider 并在新遥测到达时立即 refresh,也通过 Timer.periodic(const Duration(seconds: 5)) 每 5 秒强制重算一次。因此像 probeDisconnected 这类依赖时间推进的告警,不依赖用户当前停留在哪个页面,也不会只在下一次 UI 重建时才补算。
CG_ 前缀)由 deviceTypeLabel 识别但当前文档未深入ProbeAddress / ProbeNumber.colorName,V5 spec 2026-05-14)boosterProvider.family 以 deviceId 为键,每台独立此外,当前启动链路还包含一套根级崩溃/异常日志采集:main() 在 runApp() 前会先初始化 DevLogService,随后挂接 FlutterError.onError 与 PlatformDispatcher.instance.onError。前者记录 Flutter 框架错误,后者记录根 zone 的未捕获异步异常;其中 supa.AuthException 与 SocketException: Reading from a closed socket(MQTT broker 半开 TLS socket 的读泵风暴,TAPD #1003457)会在写入 crash log 后作为可恢复错误被吞掉;后者还会通知 CloudConnectionRegistry.instance?.reportClosedSocketPumpError() 强制拆除僵尸 MQTT 连接。其余异常仍按 fatal 继续上抛。这意味着当前 App 已具备基础的 Dart 层闪退/异常留痕能力。
此外,当前代码还包含一条 Firebase/FCM 背景补偿链路:main() 会在 runApp() 前先调用 await PushSyncService.instance.init() 注册后台消息处理;登录后 _startDeviceRealtime() 还会设置 push.onDeviceSync 并执行 push.registerToken()。当前代码已接入这条 Firebase/FCM 链路,且 lib/core/config/firebase_push_config.dart 中 culinatechFirebaseOptions 已配置为非空 FirebaseOptions(包含 apiKey、appId、messagingSenderId、projectId);因此该链路不再是仅因 Dart 侧配置缺失而 dormant/no-op 的状态。
| 领域 | 组件 | 版本 |
|---|---|---|
| 语言/框架 | Flutter + Dart | SDK >=3.3.0 <4.0.0 |
| 状态管理 | Riverpod (flutter_riverpod) |
^2.5.1(以 family 键区分多设备) |
| BLE | flutter_blue_plus | ^1.35.3 |
| 云端 | MQTT (mqtt_client) |
^10.5.1(仅 CM4 WiFi 机型使用) |
| 账号后端 | Supabase (supabase_flutter) |
^2.8.0(Auth + Postgres + RLS,默认启用,见 Supabase 账号与设备同步) |
| UI | Material Design(深色主题)+ google_fonts ^6.1.0 + flutter_svg ^2.2.4 | — |
| 图表 | fl_chart | ^0.68.0 |
| 持久化 | shared_preferences ^2.2.2 + path_provider ^2.1.2 |
— |
| 报警 | flutter_ringtone_player ^4.0.0+3 + flutter_local_notifications ^18.0.0 |
— |
| 权限 | permission_handler |
^11.3.0 |
| 音量读取 | flutter_volume_controller ^1.3.3(Android 读 notification + ring;启动检查 <50% 弹提醒) |
— |
| iOS 实时活动 | live_activities ^2.4.3(iOS 16.1+;Android 静默 no-op) |
— |
| 设备/网络信息 | package_info_plus ^8.0.0 + network_info_plus ^5.0.0 + uuid ^4.3.3 |
— |
| 分享/导出 | share_plus ^12.0.0 + url_launcher ^6.2.5 |
— |
| 国际化 | intl + ARB |
6 种语言:en / de / es / fr / it / zh |
ios/Podfile:2);Live Activities 需要 iOS 16.1+minSdk = flutter.minSdkVersion,即当前 Flutter 默认值(API 21 / Android 5.0);MainActivity 重写了 onDestroy 通过 MethodChannel culinatech.app/lifecycle 通知 Dart 层做 BLE + MQTT 清理kIsWeb 分支禁用 BLE/通知,但 Web 不是发布目标lib/
├── main.dart # 入口:启动顺序锁定、权限预请求、Provider 容器、生命周期钩子(625 行)
├── app.dart # MaterialApp、命名路由、rootNavigatorKey、安全告警全屏推送(165 行)
├── core/ # 跨业务共享层
│ ├── models/ # Booster / Probe / CookingSession / ConnectionDisplayState
│ ├── protocol/ # 协议层:cm4_protocol / legacy_protocol / 数据解析 / LUT
│ ├── transport/ # 传输层抽象(2026-05-01 拆分落地):booster_transport + legacy/cm4 impls + booster_family
│ ├── providers/ # device_providers.dart(2096 行,所有 Riverpod 状态集中此处)
│ ├── services/ # 单例服务:BleService / BleDeviceService / MqttService / AlarmService / NotificationService …
│ ├── theme/ # 深色主题、颜色常量
│ └── utils/ # anonymous_id、log_timestamp、meat_localization、navigation_utils
├── features/thermometer/ # 业务层
│ ├── data/ # datasources/(BLE / 云端 / Mock)+ repositories/
│ ├── domain/ # entities/(DeviceType / ProbeReading / ThermometerDevice / ThermometerSnapshot)+ repositories/
│ └── presentation/ # 19 个页面 + 共享控件 + Provider + 控制器
└── l10n/ # ARB 源文件 + 自动生成的本地化代码
全应用(
thermometer/19 个 +auth/6 个)共 25 个屏幕级页面文件,app.dart共注册 22 条命名路由(12 个静态、10 个走onGenerateRoute传参,两个 feature 模块共用同一份路由表),help_detail_page.dart/cook_log_detail_page.dart/account_credential_page.dart这 3 个不走命名路由、直接 pushMaterialPageRoute。此外,onboarding 子系统里的PermissionsIntroPage与BackgroundPermissionPage也都通过MaterialPageRoute直接 push;其中BackgroundPermissionPage还有/background-permission与/background-permission-renudge两个入口。另外,帮助中心还包含一个独立的二级页面HelpTopicsPage:HelpCenterPage会先 pushHelpTopicsPage(topic list),再由它继续 pushHelpDetailPage,因此帮助中心实际是三级页面流转。。详见仓库结构、界面解析目录(每屏一页,共 24 页——WifiNetworkSelectPage/WifiSetupPage两个类合并在一页里)。
补充:当前传输层拆分仍是“命令面已拆出、连接生命周期后迁移”的状态。LegacyBoosterTransport 已承接 setProbeTarget、setUnit、muteAlarm、queryStatus、sendSetting 等命令下发,但它的 connect() / disconnect() 仍是 UnimplementedError,dataStream / connectionStateStream / rssiStream 也还是空流。因此 legacy 设备当前的连接状态与数据流,实际仍主要由 BleDeviceService 持有,并未完全下沉到 transport 实现。
补充:当前 BoosterTransport 抽象不只定义 connect / setProbeTarget / setUnit / muteAlarm / queryStatus 这类基础控制,还显式暴露 connectionStateStream、rssiStream、disconnect()、dispose()、disarmProbeAlarm(ProbeNumber) 以及 sendSetting(String)。此外,它还定义了 Stream<CM4Message> get dataStream;,用于输出设备数据流。其中 sendSetting 用于下发像 CM4 RI_CNT=、DISP=、RI_LVL= 这类暂未封装成专用 typed method 的 booster 级设置。
lib/core/transport/booster_transport.dart:抽象接口(connect / setProbeTarget / setUnit / muteAlarm / queryStatus),数据流为 Stream<CM4Message>lib/core/transport/booster_family.dart:enum BoosterFamily { legacy, cm4 } + 独占锁刷新元数据(legacy 45s 0x55B1 / CM4 45s CNT_0——per Liang 2026-06-06 / TAPD #1003176 起两家族同 cadence 同机制;c613148 之前 CM4 是 20s 且文档为"无独占锁",那是错的)+ MTU 决策(仅 CM4 协商 512)lib/core/transport/legacy_booster_transport.dart / cm4_booster_transport.dart:family-specific 实现BleDeviceService._transportFor(deviceId) 按 deviceId.boosterFamily 懒创建并缓存;调用层不感知 family 具体类型详见 04-规划与跟进/02-传输层重构 跟踪页。
独占锁机制对所有家族适用(per Liang 2026-06-06 / TAPD #1003176 更正先前 "CM4 无独占锁" 假设)。CM1/CM2/CM3 每 45 秒发送 0x55B1 刷新独占锁,60 秒 TTL 到期后其他手机可抢占(Liang 2026-04-20 锁定不变,见 legacy_protocol.dart:25-44)。CM4 也有同样的独占锁——发 ASCII CNT_0 每 45 秒一次(c613148 之前是 20s),同 60 s TTL;仅 wire 字节与 legacy 0x55B1 不同(CM4 不接受二进制锁命令)。WiFi 模式下 BLE 链路也必须持续刷锁——否则中继盒释放锁、约 20–30 s 后主动 shed GATT 链路。详见 CM4 协议 §心跳。
断线宽限期:90 秒(ble_device_service.dart:388 gracePeriod = 90s)。非入仓引发的断开,前 90 秒 UI 仍显示最后温度,后台静默重连;超时才显示"已断开"。设计动机:legacy 60s lock TTL + 30s 留给一次连接尝试。
入仓触发关机:收到 0x55AA 后 1 秒内若再收到 0x07 断开(_dockShutoffWindow=1s,ble_device_service.dart:589),分类为"入仓关机",跳过宽限期,UI 显示 10 秒 boosterShuttingDown 过渡态(_dockShutoffUiDelay=10s)再切到 boosterOff。首次重连延迟 8 秒(_dockShutoffReconnectDelay)匹配中继盒断电+复位时间。
多设备彼此独立:每台中继盒有独立的 BLE 连接、独立的心跳/锁刷新、独立的重连失败计数器(_deviceAttempts[deviceId])。UI 层 boosterProvider.family 以 deviceId 为键。锁刷新写按 deviceId.hashCode 相位错峰(ble_service.dart:1354),避免多设备同步写堵塞 BLE 命令队列。
温度查表权威 + 内温 HI 阈值 = 101 °C:App 必须从 15B 包的字节 3-4 / 5-6 自己查 tempIntArray / tempExtArray(已抽到 lib/core/protocol/temperature_lookup.dart,199 行),不要信任 12B 包的字节 7-8(中继盒预转换值,已确认不权威,见 v4.2 §Q20)。内温显示阈值经 Liang 2026-04-29 v5 §15 修订收紧到 101 °C:超过即显示"HI"、值钳制为 101 °C;alarm 触发阈值则是 100 °C(AlarmThresholds.internalOverTempF=212),HI 钳位再晚一度。HI 报警走 WarningPage 全屏推送——在 app.dart 的 ref.listen(alarmStateProvider) 里只针对 AlarmType.internalOverTemp / ambientOverTemp 这两类 safety alarm 推全屏,其它类型用顶部 banner (AlarmBannerHost)。
探针断连判断:探针 20 秒 15B 沉默 → 探针卡片置灰 + Probe.isConnected=false(_probeDropoutThreshold=20s,ble_device_service.dart:400,老 10s 阈值因 12-15s 正常 jitter 误报已上调)。60 秒 15B 沉默 → 触发 AlarmType.probeDisconnected 报警弹窗(per Liang 2026-04-28)。任意 15B 重置计时器。报警评估器在 device_providers.dart 里——此为载荷于陈旧烹饪场景下的安全报警,未经 Liang 重新确认前不要改阈值或抑制窗口。
中继盒断连判断:连续 60 秒 12B & 15B & 2B 全部沉默 → DeviceStatus.lostConnection。AND 条件由单一时间戳 _DeviceState.lastBoosterData 隐式实现——任何一种包到达都重置时间戳,60 秒无更新即三种全沉默(ble_device_service.dart:3649-3656,v4 时为 15 秒,Liang 2026-04-30 audit reply 提高到 60 秒)。判定连接是否可建立用相反的判据:扫描期间发现该设备广播任意 15B / 12B / 2B 即可发起连接。
扫描和重连节奏(v4 §6 升级后,per Liang 2026-04-29 follow-up):前台 始终 15 秒主动扫描,不衰减;后台前 4 小时同前台节奏;后台 4 小时后才进入分档退避——第 1-5 次每 15 秒、第 6-30 次每 60 秒、第 31 次以后每 5 分钟。多设备扫描集合:每次扫描构建所有断开-但-保留的 known devices 集合,任意命中即算成功,避免轮询时错过 B 设备。入仓引发的断线也进入重连循环——用户重开中继盒后下次扫描窗口即可命中。每个设备独立的失败计数器(_deviceAttempts),连接成功后清零;用户长按"忘记设备"后该设备的状态全部清除。详见 02-重连与宽限期。
| Tier | 类型(优先级 0=最高) | 触发 |
|---|---|---|
| S(连续大鸣) | ambientOverTemp(0) / internalOverTemp(1) |
外温 ≥527°F / 内温 ≥100°C |
| B(单声+通知) | probeDisconnected(2) |
15B 沉默 60s,链路仍活 |
| C(烹饪流) | targetReached(3) / earlyWarning2(4) / earlyWarning1(5) |
达标 / -5°C / -10°C |
| D(信息) | lowBattery(6) |
探针电量 ≤20% |
| A(单声+通知) | boosterDisconnected(7) / boosterPoweredOff(8) |
中继盒 60s 沉默 / 入仓关机 |
| D | probeDocked(9) |
探针入仓 |
| D | boosterLowBattery(10) |
中继盒电量 ≤20%;≤10% 时每 10 min 重复 |
| D | internalUnderTemp(11) / ambientUnderTemp(12) |
内温 <0°C / 外温 <40°C |
AlarmKey = (deviceId, ProbeNumber?, AlarmType):booster-level 报警的 probe 为 null。详见 告警与通知。
SET_RD、SET_01..04=ABCDEF、USUS=N、CNT_0 等),cloud doc 是权威,Beta 是渠道。