本章介绍 App 的 in-app 诊断工具——最重要的是 DevLogService,它能产出 Liang 能直接 grep 的协议日志。
补充:iOS 启动期权限流在真正请求 Permission.bluetooth 前,会先调用 _primeIosBluetoothAuthorization(),通过一次 FlutterBluePlus.adapterState 订阅(1 秒超时)主动构造 CoreBluetooth 的 CBCentralManager。这样可以避免 permission_handler 在蓝牙原生管理器尚未创建时把 notDetermined 误映射成 permanently_denied,从而出现日志里先记“拒绝”、但系统蓝牙弹窗其实稍后才触发的假象。
lib/core/services/permissions_service.dart —— 权限请求与 PERM_* 审计日志的统一入口;requestAllUpfront() 会先写 PERM_FLOW_BEGIN,逐项产出 PERM_REQUEST / PERM_RESULT,在永久拒绝时补 PERM_PERMANENTLY_DENIED,最后写 PERM_SUMMARY。排查启动期权限弹窗链路时,这个文件应与 lib/main.dart 一起看。补充:开发者模式的 5 连点入口实际在 lib/features/thermometer/presentation/pages/dashboard_page.dart。这里的 _DevModeTapTarget 会在 3 秒窗口内累计 5 次点击后调用 DevLogService.instance.toggleEnabled();开启后会写一条 USER 日志,事件名为 DEV_MODE_ON,detail 为 5-tap activation on dashboard title。关闭时由于代码先切换 _enabled 再调用 logUserAction(...),该调用会被 _enabled 早返拦下,因此当前实现不会落 DEV_MODE_OFF。
补充:main.dart 在启动期除了 APP_START / APP_VERSION / PLATFORM / LOG_INIT_DONE 外,还会打出一组可直接 grep 的提示链路诊断事件名:PERM_INTRO_SHOW / PERM_INTRO_DISMISSED、BT_CHECK_OK / BT_CHECK_OFF / BT_CHECK_INDETERMINATE、VOLUME_CHECK_OK / VOLUME_CHECK_LOW / VOLUME_CHECK_FAIL / VOLUME_CHECK_NULL、BG_PERMS_ONBOARDING_* 与 BG_PERMS_RENUDGE_*。排查“为什么启动弹了某个提示/为什么没弹”时,这些事件名比只看 UI 现象更直接。
lib/core/services/dev_log_service.dart(852 行)—— 结构化日志、ring buffer、debug flags
lib/core/services/dev_log_decoder.dart—— 日志解码(RX/TX 字节转可读文本)
lib/core/services/mock_device_service.dart(360 行)—— Mock DeviceService 实现(UI 开发/演示用)
lib/features/thermometer/presentation/widgets/dev_overlay.dart —— 开发者叠加层 UI(五 tab:ALL / APP / USER / RX / TX)
lib/main.dart —— 开发日志系统的启动接线点:这里是在 WidgetsFlutterBinding.ensureInitialized() 之后,先执行 SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),再调用 DevLogService.instance.init();随后把 FlutterError.onError 与 PlatformDispatcher.onError 接到 logCrash(...),并写入 APP_START、APP_VERSION、PLATFORM、LOG_INIT_DONE 等启动期调试日志。
另外,退出清理链路也有独立的调试事件:_AppExit.run() 一开始会写 APP_EXIT_CLEANUP,随后若会话收尾失败会写 APP_EXIT_SESSION_FINALIZE_ERR,BLE / MQTT / iOS Live Activity 清理超时则分别写 APP_EXIT_BLE_TIMEOUT、APP_EXIT_MQTT_TIMEOUT、APP_EXIT_LIVE_ACTIVITY_TIMEOUT。排查“关闭 App 后后台监控、连接或 Live Activity 没有按预期释放”时,这组事件可以直接 grep。
补充:如果用户是在 Dashboard 弹窗中明确点了 Close App,且开发者模式 _enabled 仍为开启状态,代码会在 SystemNavigator.pop() 前写一条 USER 日志 CLOSE_APP,detail 为 user confirmed close — stopped FG service + notifications + BLE + MQTT before SystemNavigator.pop()。若此前已关闭 dev mode,则 logUserAction(...) 会早返,这条日志不会生成。
另外,main.dart 在 widget tree 挂载后还会继续写入一批与排障直接相关的 APP 事件,包括 WIDGET_TREE_ATTACHED、KNOWN_DEVICES_LOADED、RECONNECT_SEED 等;同时它会监听 connectedBoostersProvider,并用 Timer.periodic(const Duration(seconds: 5)) 持续 refresh(alarmStateProvider),用于保证后台或无新遥测时告警评估仍会继续推进。
此外,冷启动时如果本地已知设备列表非空,代码还会先把这些设备以离线卡片铺到 Dashboard,再写一条 KNOWN_DEVICES_SURFACED 事件;其 detail 形如 N offline card(s) on dashboard (awaiting BLE/MQTT to go live)。排查“为什么重启后首页先空白、随后才出现设备卡片”时,这条事件也应一并 grep。
当前实现里还有一个容易漏看的 keep-alive 细节:main.dart 还会额外注册 container.listen<AlarmState>(alarmStateProvider, (_, __) {}); 这个空回调监听,用来把 alarmStateProvider 固定在 container 的 active set 中,避免它在两次 refresh(...) 之间触发 ref.onDispose 被回收。
补充:当前 main.dart 还会打出一组账户/会话类排障事件,页面尚未覆盖,包括 SESSION_LIMIT_PICKER_SHOW、SESSION_LIMIT_CANCELLED、SESSION_KICKED、ACCOUNT_LIVENESS_STREAK_RESET、ACCOUNT_LIVENESS_DELETED_VERDICT、ACCOUNT_DELETED_REMOTE。当需要排查“第 5 台手机登录被挤下线”“远端删号是否被二次确认后才触发本地清空”“为何本机被强制登出”时,应把这组事件一并 grep。
补充:此外,真实后台→前台恢复时还会写一条 FOREGROUND_ACCOUNT_SYNC,随后重启设备绑定 realtime 通道,并补跑设备绑定与 cook log 的 catch-up 同步。排查“App 在后台期间错过了别的手机新增/解绑设备或 cook log 变更,回到前台后为何没有自动补齐”时,这条事件也应一并 grep。
此外,首帧 post-frame 链路还会串行跑一组“为什么刚启动就弹提示”的真实排障流程:首次安装先走权限说明页;随后依次检查缺失权限、系统蓝牙关闭、通知/铃声音量过低、Android 后台权限引导,以及“上次后台监控时被系统杀掉”后的自启动再提醒;最后才决定是否展示 HOME_ONBOARDING。排查启动期弹窗/引导顺序时,应把这条链路一并纳入。
DevLogService 的事件类型补充:权限类日志的主要产生点不只在 main.dart。AppPermissions.requestAllUpfront() 会统一驱动 PERM_REQUEST / PERM_RESULT,并在权限永久拒绝时补一条 PERM_PERMANENTLY_DENIED,流程结束后再写 PERM_SUMMARY;因此排查权限弹窗链路时,lib/core/services/permissions_service.dart 也是关键入口文件。
另外,当前权限流在开始时还会先写一条 PERM_FLOW_BEGIN。iOS 上针对 Permission.bluetooth,代码还会额外补一条 PERM_NATIVE_STATE,直接记录 FlutterBluePlus.adapterState 反映的 CoreBluetooth 原生授权状态,用来区分 permission_handler 把 notDetermined 映射成 permanently_denied 的假象与真实系统授权状态;排查 iOS 蓝牙权限时,这条日志应与 PERM_RESULT 一起看。
补充:源码里仍保留一个通用的 logEvent(deviceId, label, {details, bytes, seq}) 入口,作为旧调用点的兼容 API。它受 _enabled 控制,写入 DevDirection.event;若未显式指定细分类别,则会按 DevLogEntry._deriveCategory(...) 归到 LogCategory.core。因此排查历史调用点时,除了 logCore / logAppEvent / logUserAction 等新接口,也要留意这条旧事件路径。
补充:导出文件与 debugPrint 镜像里的“方向列”并不统一写成 EVT。DevDirection.event 会按 category 细分输出为 CORE / PIPELINE / PARSER / TRACE,而真实 BLE 帧会输出 ← RX 与 → TX。因此排查时可以直接按这些标签 grep 分桶。
补充:代码里还存在 logPipeline(deviceId, label, {details, seq}),用于记录 handler → emit → provider 链路、_boosterEqual 跳过原因以及 scan-manager 状态迁移。这类日志走 DevDirection.event + LogCategory.pipeline,属于开发者模式下的事件类日志。
| 方法 | 事件范畴 | 举例 |
|---|---|---|
logAppEvent(deviceId, label, {details, seq}) |
App 级 | APP_START, RECONNECT_SEED, GRACE_EXPIRED, BLE_READY |
logParser(deviceId, label, {details, seq}) |
解析器 | PROBE_15B, UNSOLICITED_12B, DOCKED_8B, BOOSTER_2B_HB, PROBE_CM4, SETT_ASCII |
logCore(deviceId, label, {details, seq})(代码中无 logBle 方法;BLE 连接层事件经 logCore 记录,CONNECT_RESULT 等部分亦走 logAppEvent) |
BLE 连接层 | CONNECT_RESULT, DISCONNECT, CONNECT_GATE_ENTER |
logTrace(deviceId, label, {details, seq}) |
低层字节 | ONAE05RX, DOCK_STAMP_RX |
logPermission(phase, permissionName, {status, error}) |
权限 | PERM_REQUEST, PERM_RESULT, PERM_ERROR, PERM_SUMMARY, PERM_SETTINGS_PROMPT, PERM_PERMANENTLY_DENIED 等(事件名统一带 PERM_ 前缀;VOLUME_CHECK_* 仍由 logAppEvent 记录) |
logCore(deviceId, label, {details, seq}) |
核心生命周期 | APP_EXIT_CLEANUP(logCore);APP_VERSION、PLATFORM(logAppEvent), ALARM_FRESH_CONNECT_RESET, SESSION_ALARM_EVAL / SESSION_ALARM_SKIP(TAPD #1003078 / c458c44 起的 session-alarm 决策诊断,state-change-gated), ALARM_EVAL_CONN(TAPD #1003095 / cd18bb1:alarm eval 全局 connState gate transition-gated,揭示 disconnected 整体早返是否吞掉其它设备 alarm), ALARM_OVERTEMP_EVAL(TAPD #1003095/#1003083 / cd18bb1:per-probe >= internalOverTempF 进入 trigger 前 once-per-episode 诊断,配合 PROBE_ROUTED 切分「未跑 / 跑了未 fire / trigger() 抑制」三态) |
logCrash(source, error, stack?, fatal?) |
未捕获错误 / 崩溃捕获 | CRASH(60fa696 / TAPD #1003152;main.dart 在 runApp 前接 FlutterError.onError(framework build/layout/paint)+ PlatformDispatcher.onError(root-zone 未捕获异步);与 logCore / logPermission 同档不受 dev-mode 开关影响,但 TAPD #1003457 加了 crash-storm 限流:同一签名(source + error 首行)前 3 次仍完整写 CRASH;之后相同签名在 5 s 窗口内会被直接抑制(不写 buffer、不写盘),并周期性写 CRASH_STORM 汇总行(grep CRASH_STORM 可看到被折叠的风暴规模)。签名中断 ≥30 s 后下一条仍按全新 CRASH 全量记录。落盘走 _addEntry(skipLiveFile: true) + _emergencySyncAppend 同步写盘(init 时缓存 _docsDirPath 到字段,崩溃 handler 不能再 await),保证就算 native crash / LMK 紧接着 tear-down 也能落一行;stack 顶 30 帧用 | 拼到 detail 单行不超长。只截 Dart 层——原生 SIGSEGV / OS low-memory kill 不进这两个 handler,崩溃报告里没有 CRASH 行本身就是「native cause 或 LMK reclaim、不是 Dart 异常」的诊断信号)⚠️ 7a1f7c3 起 PlatformDispatcher.onError 把 gotrue AuthException 列为可恢复类:仍 logCrash、但记 fatal: false 并 return true 吞掉(不再传播)。TAPD #1003457 起,SocketException: Reading from a closed socket(MQTT broker 半开 TLS socket 的 read pump 风暴)同样记 fatal: false 并 return true 吞掉,同时上报 CloudConnectionRegistry.reportClosedSocketPumpError() 强制拆连接。除这两类外,其它 root-zone 异常仍 fatal: true 并 return false 传播。理由:SupabaseAuthService 内部 App-initiated 调用都已 catch + map AuthException,能溜到 root zone 的 AuthException 必是 supabase_flutter _handleIncomingLinks 那条 OAuth deep-link listener throw 的 SDK 内部异常,先前每次 Google sign-in 失败(如错误 client secret / 未授权 redirect URI)都打挂 App(field log 2026-06-15 5× 闪退)。注:这只止 crash、Google sign-in 仍依赖 Google Cloud console OAuth 配置(client secret / authorized redirect URI https://<ref>.supabase.co/auth/v1/callback)单独修复。 |
#<seq> 标签)补充:当前同一关联列不只承载 packet seq,也承载重连尝试 ID。DevLogService 还会为每次 reconnect attempt 分配 attN(如 att3),并沿 scan-manager → scan result → match → connect 链路透传,用于把一次重连尝试的相关日志串起来筛查。
问题:一个 AE05 字节到达 → 进 parser → dispatch → handler → emit telemetry → UI 更新,日志里会散落在 5 行不同事件。事后怎么串起来?
解决:每次 AE05 RX 调一次 DevLogService.nextPacketSeq() 拿递增序号,stamp 到 CM4Message 上。所有下游日志都带同一个 #<seq> 标签。
好处:grep #42 就能看完这个包的整条轨迹。
补充:导出文件和 debugPrint 镜像里的关联列是固定宽度 7 个字符;无关联 ID 时留空,有值时统一按 #${seq} 写出,所以 reconnect attempt 也会显示成 #att3 这类形式,而不只是纯数字 packet seq。这个固定列宽是为了让 #4231、#att3 这两类关联 token 在 grep 时都对齐好扫。
补充:当前 DevOverlay 除五个 tab 外,还带有 AE03 / AE05 / other 通道过滤、按 deviceId 的过滤 chip、清空日志按钮,以及一个 Send Custom Packet 面板。其中“清空日志”只会清掉内存里的 RX / TX / EVT / APP / USER buffer 与 per-device 统计,不会删除持久化的 culinatech_live.log / culinatech_live.log.1;因此清空后若立刻执行导出,导出文件的 persistent log 段仍会带上之前已落盘的历史日志。其中“清空日志”调用 DevLogService.clear():除了清空 RX / TX / EVT / APP / USER 五类内存 buffer,也会把 Stats Panel 用到的 per-device 计数器一并清零,但不会改动 _enabled、_autoSave、_devVerboseTracing 这些持久化开关。另一个当前实现细节是:overlay 可通过顶部 handle 收起为右下角的 DEV 胶囊,点击胶囊可重新展开;当用户手动滚离列表底部时会暂停自动滚动,并显示 Jump to latest 按钮一键回到最新日志。
补充:通道过滤只会作用于 RX / TX 这类真正带 AE03/AE05 characteristic 的日志;event / app / user 三类事件会显式绕过该过滤逻辑,因此即使只勾选 AE05,这些生命周期/用户操作日志也仍会继续显示。该面板支持输入十六进制字节并向所选设备的 AE03 发送原始数据,底层直接调用 sendRawBytesTo(...)。
进入:Dashboard 页标题 3 秒内连续点击 5 次。当前实现是“切换”而不是单向进入:满足条件后会直接调用 DevLogService.instance.toggleEnabled(),并记录一条 USER 日志,事件名为 DEV_MODE_ON;当这次 5 连点把开发者模式关闭时,logUserAction(...) 会因 _enabled 已切为 false 而直接早返,因此当前实现不会写出 DEV_MODE_OFF。日志 detail 为 5-tap activation on dashboard title。
可见:DevOverlay 弹出,五个 tab:
当前 overlay 在 tab 上方还有一个按设备聚合的 Stats Panel:对每个当前仍有 BLE 连接的设备显示 BLE 连接图标、deviceId、心跳间隔 HB ${...}s、固件版本 fw=V...、首个已连接探针的 RSSI、以及该设备累计 RX/TX 包数与字节数、最近一次收发距今秒数。若该设备有已连接探针,还会逐行显示探针颜色名、地址与探针固件版本。
eventLog 当前没有独立的 EVT tab;DevOverlay 只有 ALL / APP / USER / RX / TX 五个 tab,这些日志只会在 ALL 里合并显示。每条事件带:
DateTime.now(),带毫秒)deviceId(但 APP/USER 等 app-wide 日志允许为空;在 DevOverlay 中会显示为 —)decoded(解码/说明文本;DevOverlay 中 APP / USER / EVT 主行直接显示,RX / TX 主行默认显示 hexDump,展开后才显示 decoded;DevLogEntry 代码中无 details 字段)#<seq>(如果导出到文件或看 debugPrint 镜像时会显示;当前 DevOverlay 列表项本身不渲染 seq)0x55B1 → "legacy LOCK-REFRESH 0x55B1")Per Liang 2026-05-14 / 33b4e62:
dev_overlay的颜色映射已修正为 V5 canonical:1=Black / 2=White / 3=Blue / 4=Yellow(旧错误的1=Blue / 3=Black / 4=Red已替换)。DevLogEntry自带 deviceId 列;ProbeNumber.colorName短形实际用于 Stats Panel 的探针列表显示(_StatsRow中p.number.colorName),而非日志条目的decoded字段(日志只展示decoded/hexDump,不存在details字段)。
culinatech_log_{时间戳}.txt(例:culinatech_log_20260518111444.txt)
一种格式(dev_log_service.dart 的 exportSnapshot):
补充:导出的 TXT 不是单纯把当前日志行直接拼起来。文件开头会先写一行生成时间头;随后按时间顺序依次拼入 culinatech_live.log.1(旧轮转文件)和 culinatech_live.log(当前 live log);最后再追加当前五类内存 buffer 的快照段,并写出总条数以及 RX / TX / EVT / APP / USER 各自计数。代码不会对持久化段和内存段的最近重叠日志去重,而是通过分段标题明确来源。
culinatech_live.log.1、culinatech_live.log)以及当前内存快照有这个日志可以直接 grep 所有协议事件,比凭记忆还原问题高效得多。Liang 调 alarm-related TAPD(bg-test、targetReached fire-and-clear、fresh-connect reset 等)就是靠这套 log 定位时间线的——详见 告警与通知 §alarmStateProvider 的 keep-alive。
补充:当前实现不是单一全局队列,而是 RX / TX / EVT / APP / USER 五个独立 buffer,各自上限都是 2000 条,超出后按 FIFO 丢弃最旧项。持久化 live log 文件超过 5 * 1024 * 1024 字节时会轮转,把旧文件改名为 culinatech_live.log.1。
_autoSave 为 true 时,写入 _addEntry() 的日志会持续追加到 culinatech_live.log,并在导出时连同 culinatech_live.log.1 一并带出,因此跨重启保留的不只 APP_START。DevLogService 中通过以下布尔字段控制(无 debugFlags 聚合对象):
补充:当前代码里并非所有日志都受 _enabled 控制。logRx、logTx、logPermission、logCore、logCrash 都会无条件进入 _addEntry();真正受 _enabled 早返控制的是 logEvent、logAppEvent、logUserAction、logPipeline、logParser,而 logTrace 还额外受 _devVerboseTracing 控制。
| Flag | 默认 | 控制什么 |
|---|---|---|
_enabled |
SharedPreferences 未命中时回退为 true |
开发者模式全局开关,控制大部分事件记录;当前代码没有按 tester/prod 分支默认值。 |
_autoSave |
true | 是否自动写入持久化 live log 文件 |
_devVerboseTracing |
false | 低层字节 trace(极度嘈杂,对应 logTrace) |
持久化:通过 SharedPreferences 持久化,重启 App 会保留上次设置。
切换位置:DevOverlay 底部控制区(Auto-save / Verbose Tracing 开关)。
补充:当至少有一台设备已连接时,overlay 的 Stats Panel 下方还会显示一个独立的 Suppress Lock Refresh (0x55B1) 开关。打开后,App 会停止向 legacy 设备发送 0x55B1,用于实测设备锁是否会在约 60 秒后过期并重新广播;切换时会额外写一条 LOCK-REFRESH-SUPPRESS 事件日志。
补充:MockDeviceService 不只是被动输出假遥测,也会响应一部分发送命令。当前实现支持通过 handleCommand() / sendCommand() 切换华氏/摄氏、修改背光等级 BL_LVL=、铃声音量 RI_LVL=,以及根据 HOST= URL 把 broker 区域映射到 us / eu / asia。因此这几类设置项在 mock 模式下也可以联调 UI 与状态回显,而不只是看温度曲线。
补充:MockDeviceService 内建一个静态断线模拟开关 simulateDisconnect。默认值为 false;若改为 true,连接建立 30 秒后会发出 BleConnectionState.reconnecting,10 秒后发出 BleConnectionState.justReconnected,再过 2 秒回到 BleConnectionState.connected。这可用于演练断线重连的 UI 与状态流,而不只是稳定输出假遥测。
Provider:useMockServiceProvider(device_providers.dart:61,Settings 页的开发者开关)
activeDeviceServiceProvider 切到 MockDeviceService(切换逻辑在 lib/core/providers/device_providers.dart;MockDeviceService 类定义在 lib/core/services/mock_device_service.dart),不走 BLE 直接产生虚假数据流BleDeviceService(real)用途:UI 开发、演示、集成测试 —— 不需要真中继盒也能看 App 工作。Mock 会模拟 CM4(4 探针,其中 1/2 激活、3/4 固定 inactive)与 CM1(1 探针)两台设备、温度上升曲线和电量缓慢下降;当前代码没有单独生成“入仓事件”流。
不持久化:session-only,App 重启回 real。
AE05_NOTIFY_ENABLED 或 AE05_INDICATE_ENABLEDCONNECT_RESULT(timeout? permission denied? CONNECT_GATE_ENTER/EXIT 看 _connectGate 是否在串行化等待)PERM_REQUEST / PERM_RESULT / PERM_SUMMARY,以及必要时的 PERM_PERMANENTLY_DENIED / PERM_ERROR,确认权限状态PROBE_15B 或 CM4 的 PROBE_CM4)→ 看 internalTemp=、ambientTemp= 中附带的原始值与换算结果;RX tab 只显示原始 AE05 接收帧tempIntArray / tempExtArray(lib/core/protocol/temperature_lookup.dart)反查isInternalTempBelowRange / isInternalTempAboveRange 是否被设HI 对应高于 101 °C 的范围状态,且 parser 会将该值钳到 101 °C;应结合 isInternalTempAboveRange 与告警评估日志排查。DISCONNECT;当前代码会把断连原因写到该事件的 details,并在有原始 reason code 时把单字节 code 放进 bytes。_lastDockEventAt 是否被 stamped(应该在 0x07 的上一行附近,DOCK_STAMP_RX 在 RX 字节层打戳)RECONNECT_ATTEMPT 是否按节奏触发(前台 + 后台 <4h 始终 15s;后台 ≥4h 走 15/60s/5min;看 BG_CADENCE_TIERED_ENTER 确认 4h 门是否已开)GRACE_EXPIRED 是否打了——打了说明 90 秒宽限期已耗尽,进入 lostConnectionAlarmService._active[(deviceId, probe)] 是否包含预期类型(通过 alarmStateProvider 评估日志)Booster.isInGracePeriod —— 若为 true,probe-level 报警被冻结(booster-level 状态报警不冻结)_userDismissed 集合,确认没被误 dismiss——reset() 通常不清这个集合(V5 修复 6d2a35f 后 probe lowBattery 的 dismiss flag 也保留)ALARM_FRESH_CONNECT_RESET 是否触发过——offline → connected 转换会清整组 _active / _userDismissedconnectedBoostersProvider emit 时序 —— 每次新遥测应触发 container.refresh(alarmStateProvider)(main.dart 的 listener)invalidateSelf 不工作—— 评估器必须用 container.refresh,详见 告警与通知 §alarmStateProvider 的 keep-alive 与 TAPD 2026-05-13 / 099e442Timer.periodic 兜底应保证 no-telemetry 场景下 probe-disconnect 60s 阈值能 fire