本章讲 App 如何产生、排队、展示告警,以及 Android 前台服务通知的角色。
lib/core/services/alarm_service.dart(1194 行)—— 报警栈、优先级、触发/清除;末尾还托管 ProbeSensorFlags + probeSensorFlagsProvider(按 (deviceId, ProbeNumber) 索引的 ADC 范围标志,per Liang 2026-04-24 review 从 Probe 拆出),以及 ProbeAlarmConfig + probeAlarmConfigProvider(中继盒侧 12B / SETT== 报警目标 + 用户启用标志,5 字段,per Liang 2026-04-30 audit);两个类均带 ⚠️ FIXED (固定代码) doc-comment 标注(per Liang 2026-04-30 [确认A],alarm 状态放 alarm 模块 的 placement 已锁定)lib/core/services/notification_service.dart(493 行)—— Android 前台服务、本地通知调度、Critical Alerts 申请lib/core/providers/device_providers.dart 的 alarmStateProvider(行 1750–1971)—— 报警评估主体(2026-05 已统一搬到 provider body,不再分散在 cooking_page.dart)lib/features/thermometer/presentation/pages/warning_page.dart(562 行)—— 全屏报警页(由 app 级监听推起)lib/features/thermometer/presentation/widgets/alarm_banner.dart —— 顶部 banner host(informational alarms 用)lib/app.dart(行 63–90)—— 全局 alarm safety listener(safety alarm 全屏推送)枚举:AlarmType(定义顺序就是优先级,值越小越高,13 类)
| 值 | 枚举 | 触发条件 | 声音 |
|---|---|---|---|
| 0 | ambientOverTemp |
环境温 ≥ 275 °C / 527 °F | 连续铃声 |
| 1 | internalOverTemp |
内温 ≥ 100 °C / 212 °F(per Liang 2026-05-07 用 >=,refining the 2026-04-30 #18 ">100" 写法) |
连续铃声 |
| 2 | probeDisconnected |
12B 仍在但 15B 沉默 60 秒(探针 ↔ 中继盒断) | 单次提示音 |
| 3 | targetReached |
达到目标温度 | 连续铃声 |
| 4 | earlyWarning2 |
目标前 5 °C / 9 °F | 单次提示音 |
| 5 | earlyWarning1 |
目标前 10 °C / 18 °F(与 earlyWarning2 互斥——用户在设置里二选一,仅所选档发) |
单次提示音 |
| 6 | lowBattery |
探针 tier-aware(per Liang TAPD 2026-05-15):≤ 20% (low20) 进档一次性触发、≤ 10% (low10) 升级再触发一次;session 内不重复(与中继盒 boosterLowBattery 区别——booster 重复,probe 不重复)。UI 路径 banner-eligible(99cf247 / TAPD #1003093,Liang 2026-05-28)——之前 probe lowBattery notification-only / 无 banner,cooking 页另用一对 in-page _LowBatteryBanner + bottom snackbar 重复表达;99cf247 把这两个冗余 UI 删掉、_isBannerEligible 不再 exclude lowBattery、走与所有 non-safety 报警一致的共享居中卡片 popup(详见 烹饪 §用户能看到的内容 + §已知问题) |
单次提示音 |
| 7 | boosterDisconnected |
DeviceStatus.lostConnection(60 秒 12B/15B/2B 全沉默) |
单次提示音 |
| 8 | boosterPoweredOff |
DeviceStatus.boosterOff 或 boosterShuttingDown 进入瞬间(不是 +10s) |
单次提示音 |
| 9 | probeDocked |
Probe.isDocked == true(per Liang #18 [Liang: A] 单击声 tier) |
单次提示音 |
| 10 | boosterLowBattery |
中继盒 firmwareVersion > 0 且 batteryLevel ≤ 2(≤20%)、>3 滞回;进入 critical tier(≤1,即 ≤10%)后每 10 分钟重复一次(per Liang TAPD #1003012 2026-05-12)。固件门防 fresh-connect 假报——_DeviceState 初始化时 battery / firmware 都是 0 |
单次提示音 |
| 11 | internalUnderTemp |
内温 < 32 °F(= 0 °C,per Liang #18) | 单次提示音 |
| 12 | ambientUnderTemp |
外温 < 104 °F(= 40 °C,per Liang #18;与 UI 显 "---" 同边界) | 单次提示音 |
震动:所有触发都 heavy impact。
Per Liang 2026-04-30 #18:全局报警监听器从 7 类扩到 12 类(priorities 7–12 追加在原 7 类之下);2026-05 再加 ambientUnderTemp(12) 凑成 13 类。新增的 6 类全部走 _isSingleBeepType helper 单击声 + 通知(FlutterRingtonePlayer.playNotification,不走 playAlarm 循环);internalOverTemp 阈值经 Liang 2026-05-07 收紧到 ≥ 100 °C(C=100 触发报警,C=101 同时触发 HI 钳位)。internalUnderTemp / ambientUnderTemp 同样受 90 秒宽限期冻结;boosterDisconnected / boosterPoweredOff / boosterLowBattery 不受宽限期冻结(这些状态本身就是断连/断电,宽限期内压制反而吞掉关键报警)。
⚠️
targetReached单方向触发(per Liang TAPD 2026-05-09 re-land;首次 4c7dcf5 → revert dba519c → re-land 6eccc24 加 start/finish 守卫):alarmStateProvidereval 对所有其他 alarm 都跑if (cond) trigger else clear双向同步,但targetReached只跑 trigger 分支——alarm 一旦进_active就不再自动 clear。原因:双向模式有竞态——同一 eval cycle 里 trigger 触发铃声 + 弹窗,紧接着 auto-finalize microtask 把session.isActive翻 false,下个 eval 在if (!session.isActive) continue跳过该会话,anyTargetReached算成 false 立刻 clear,整个 fire-and-clear 在 microqueue 里几毫秒内完成,用户看到 "APP 直接结束了烹饪"——没弹窗、没铃声。修复后 alarm 持续到三件事之一发生才清:用户在 banner 上 dismiss、cooking 页改目标(reset(deviceId))、App teardown(resetAll());6eccc24 还在_toggleSession启动新会话后 +_finishSession结束会话后额外调alarm.reset(deviceId),避免上一轮残留的 targetReached / dismiss state 透过到新会话或 stop 之后还留 banner。trigger()本身幂等(if (set.contains(type)) return),eval 每 tick 重复触发不堆积状态;auto-finalize microtask 不变;earlyWarning1/2后由 619dd41 一并改成单方向触发(per Liang TAPD 2026-05-08,同根 fire-and-clear 竞态:eval 的internalF < targetFgate 在探针穿过目标的瞬间翻 false,下个 tickanyEarlyWarn→ false 立即 clear,banner 闪一下就没),改后 pre-alarm 留在_active直到用户 dismiss、alarm.reset在 session start/finish/target-change 时清、或下面的 fresh-connect reset 触发。⚠️ 6036a52 / TAPD #1003289 起 trigger 闸用 quantization-tolerant helper:CM4 探针上报整 °C,internalF只落在整 °C 网格(52 °C = 125.6 °F、53 °C = 127.4 °F,永不落在 126.0 °F),但 target 由中继盒SETT==°F byte adopt——52 °C 目标固件圆到 126 °F 且 byte 在 125/126 frame-to-frame 抖动(log 6110 行 "126°F/52°C" vs 13234 行 "125°F/52°C"),裸internalF >= targetF直接跨过 126.0 °F、targetReached与 5 °C 预警(earlyWarning2)都晚一 °C 触发——在 48 °C / 53 °C 而非 47 °C / 52 °C,且间歇性(看上一次 adopt 是哪一个 byte,QA 报告 2/6 概率)。新加AlarmThresholds.probeQuantToleranceF = 0.9(探针 1 °C 步长的一半 °F)、hasReached(internalF, targetF)与inEarlyWarnBand(internalF, targetF, level):把 threshold 缩 0.9 °F 让触发落在用户屏上看到的那一格读数;alarmStateProvidersession eval 与WarningPageprobe matcher 都走同一对 helper。8 个单元测试覆盖确切 repro(target=126 °F 时 52 °C 必触发 targetReached、51 °C 必不触发;target=126 °F 时 47 °C 必进 level-1 早警 band、46 °C 必不进;level-2 同款 42 °C 必触、41 °C 必不触)+ 无早 fire 守卫。
⚠️ fresh-connect 重置(per Liang TAPD bg-test 2026-05-08,619dd41):dismiss API 只压制 banner(写
_userDismissed),并不清_active——只有 eval 评出条件 false 时clear()才会把 type 移出_active。bg-test 213.8°F 没响的真凶是_active[MW4_EB1B] = {internalOverTemp}早于断连前已被用户连续 dismiss 4 次(15:06–15:07)、断连期内 probe-level 报警在isInGracePeriod跳过、状态横跨整段断连存活,下一次 17:04:05 over-temptrigger()在if (set.contains(type)) return早返、铃声不响。AlarmService.checkFreshConnect(deviceId, deviceStatus)在alarmStateProvidereval 顶部调用:维护_lastBoosterStatus[deviceId],从lostConnection/boosterShuttingDown/boosterOff转入connected时调reset(deviceId)抹掉残留的_active/_userDismissed,记ALARM_FRESH_CONNECT_RESET审计日志;allDocked不算 offline(拔探针不该把用户正在确认的报警吹掉),首次观察(_lastBoosterStatus没记录)也不重置(冷启动没有要清的旧状态)。
⚠️ 探针
lowBatterytier-aware +reset()保留 dismiss(per Liang TAPD 2026-05-15,6d2a35f):原if (≤2) trigger else (>3) clear双向 pair 让用户在烹饪页每点一次 Set Target / Start / Timer cancel / Adjust 都会让 popup 重弹——因为这些动作走alarm.reset(deviceId)一并清掉lowBattery的 dismiss flag,下个 eval tick 又重新 trigger(与中继盒boosterLowBattery同根 TAPD #1003012 popup-spam bug 配对)。修复:alarm.evaluateProbeLowBattery(deviceId, probeNumber, batteryLevel, firmwareReady)按(deviceId, ProbeNumber)做 tier-aware 评估——low20 (≤20%) 进档一次触发、low10 (≤10%) 升级再触发一次,session 内不重复(与boosterLowBatterylow10 的 10-min repeat 明确区别——TAPD #1003012 只对 booster 重复,探针 popup 是纯一次性 per-tier)。reset()改为保留 per-probeAlarmType.lowBattery条目(不再吃掉 dismiss),checkFreshConnect()在 offline → connected 转换时同步抹掉 probe tier map(与 booster handling 对齐);新增AlarmThresholds.probeCriticalBatteryTrigger = 1,_BatteryTierenum 共享给 booster + probe 两个 evaluator。
⚠️ 连续铃声须 LIVE 数据(1a293c0 / TAPD #1003166):三类连续铃声 alarm——
internalOverTemp/ambientOverTemp/targetReached——再加新 gate:booster 4 秒(_continuousRingStaleAfter)内没有任何 probe frame 进 → 视为屏上读数已冻结,per-probe loop 立即clear三类并continue跳过阈值评估、session loop 也跳过targetReachedre-fire。原因:QA logculinatech_log_20260604115446实证 probe 触 100 °C 时 WiFi↔BLE mode 切换 drop link、读数冻在 101 °C,紧接着无关的 SESSION_STOPalarm.reset在 stale 帧上 re-fire 了internalOverTemp;铃声从此卡死(探针isConnected仍 true 通过 grace、isInGracePeriod因 telemetry dedup 丢失,clear 路径全闸出),前台服务连同 native 循环 ringtone 跨「关 App」存活,实际探针温度已掉到 36 °C,最后 Force Stop 才停。新 gate 用 telemetry 新鲜度兜底——booster 正常 ~1 Hz 流帧、4 s 阈值远高于 cadence,不影响实时报警;live telemetry 一恢复且仍超阈值就立即重新 trigger。单击声 alarm 仍按宽限期冻结、probeDisconnected的 60 秒 FIXED 闸不动(详见下方 §探针 ↔ 中继盒断连判定)。main.dart的 5 s wall-clock re-eval 决定检出延迟上限。配套:AlarmService构造无条件FlutterRingtonePlayer().stop()——前台服务持音频跨 isolate restart 时孤儿 ring 会绕过_soundPlaying=false的_stopSoundno-op,必须用 unconditional stop 收尾;若 over-temp 仍真,evaluator 几秒内会重新 trigger。
⚠️ 连续铃声不可被单击声吞掉(4ee2ae1 / TAPD #1003226):
flutter_ringtone_player只持有一个 native ringtone 槽——每次play*调用在播新音前会先ringtone.stop()当前正在响的 ringtone(plugin Android 端源码强制顺序)。所以原trigger()在单击声 tier 无条件playNotification()、在连续 tier 走 flag-gated_ensureAlarmPlaying()的写法被并发场景刺穿:探针 AtargetReached启了循环 → 探针 BearlyWarning2单击声 native 把 A 的循环 stop 掉 →_soundPlaying仍滞留 true → 此后所有_ensureAlarmPlaying早返 no-op,B 自己后续的targetReached和 ≥100 °CinternalOverTemp全部静音直到 App 重启。QA logculinatech_log_20260610152113全链路实证(15:16:43 CM3 BlacktargetReached循环 → 15:16:51 CM4 BlueearlyWarning2把循环打掉 → 15:16:54 CM4 BluetargetReached静音 → 15:17:03 CM4 BlueinternalOverTemp静音安全报警 → 15:19:34/37 CM4 Black + WhiteinternalOverTemp全部静音)。修复两半:单击声 tier 改成if (!_soundPlaying) playNotification();——循环响时跳过单击声(循环本身就提供了更响、连续的音频,新事件的 banner + system notification 仍正常显示,循环不被打断);连续 tier 在trigger()内无条件_soundPlaying = true; FlutterRingtonePlayer().playAlarm(looping: true);——set.contains(type)早返闸保证每次激活只跑一次而不是每个 eval tick 都重启,在已响循环上重启同 tone 听感不变,且自愈任何 native sound 被 audio-focus loss / OS kill 静默杀掉而_stopSound不知道的情形,从此 stale_soundPlaying再也吞不掉新的安全报警。_updateSound()的 clear / dismiss / reset 重派路径保留原 flag-gated_ensureAlarmPlaying()——这些路径没有 NEW alarm 触发、只是别的条件清零后重新求值,若 unconditional 重启会让仍存活的循环咔的重启一下、用户听得见。回归测试test/services/alarm_service_test.dartspyflutter_ringtone_playerMethodChannel、断言 native call 序列(含上述 QA repro 链)。
probeDisconnected)Per Liang 2026-04-28:当 12B 设置/心跳持续到达但某根探针的 15B 遥测沉默 60 秒时触发——12B 来自中继盒侧、15B 起源于探针,所以 12B 流而 15B 停就唯一对应"探针 ↔ 中继盒 RF 断了"(出范围、探针没电、RF 干扰),此时 App ↔ 中继盒链路本身正常。报警在以下窗口内被抑制:宽限期内(遥测被冻住,无法判断)、中继盒重连后前 60 秒(Booster.lastConnectedAt 配合 alarm evaluator——让先前活跃的探针有时间恢复 15B)、中继盒离线 / 全部入仓时。通知按 (deviceId, probeNumber) 粒度发——CM3/4 多探针场景下需要让用户看清是哪根针沉默了。
⚠️ a6e41fb / TAPD #1003133:
probeDisconnectedper-device-session 一次性触发(Kevin 2026-06-02,与ambientUnderTemp共用_isSessionOneShot闸):一根失联 / 出范围的探针在 WiFi 在线的 CM4 上每秒重弹一次断开通知(logculinatech_log_20260601182626Blue 18:14:36 起),根因是 CM4 BLE↔MQTT 双通道每帧切换Booster.isBleConnected真假翻转(但deviceStatus始终connected),导致 per-probe disconnect alarm 在 ~1 Hz 周期里 cleared → re-fired、每次 re-fire 投新通知。上方 60 秒检测是 Liang FIXED 代码,所以修在 one-shot 闸:probeDisconnected加入_isSessionOneShot,每个 device-session 只 fire 一次,先前仅 在 booster 真正 offline→connected 转换时由checkFreshConnectre-arm(与 fresh-connect 重置同源、详见上方 callout);bleConn单帧 flap(deviceStatus不变)不再 re-arm,与协议「报告一次」语义对齐。⚠️ 3f1ab43 / TAPD #1003250 起加第二条 re-arm 路径——探针 drop → reconnect(~2 min)→ 再 drop 的循环中,第二次断只灰显 / 无 popup(log 实证:CM4 White @08:11:17 + CM3 Blue @08:37:20 全 session 各只 1 条probeDisconnected ALARM_TRIGGER、跨家族),根因是中继盒整段一直connected、checkFreshConnect不动、one-shot latch 卡住吞掉第二次 trigger。修复加AlarmService.rearmProbeDisconnected(deviceId, probe)(drop latch + 记ALARM_PROBE_DISC_REARM日志、idempotent 未 latched 时 no-op),alarmStateProvider在 probe-disconnect 的else分支调它、gated on LIVE per-probe telemetry(probe.isConnected && !isDocked && now - lastSeen < 60 s)而非仅!isDisconnected——这道闸是关键:#1003133 的 dead-probe flap 让boosterEligible摆动但死探针永不 re-stream、永不命中此闸、不重开 ~1 ×/s 的喷射;只有真正重连(新 15B / 新 cloud 帧)才 re-arm。两道 60 s gate(disconnect 检测 + lastSeen 新鲜度)联手把通知绑定到真正间隔 ≥~60 s 的 disconnect 事件。family-agnostic(CM1/2/3/4 共用 eval、lastSeenAt在每条 BLE 与 cloud 帧都 stamp)。
⚠️ 固定代码(per Liang 2026-04-30 acceptance):60 秒阈值 + 每探针
lastSeenAtsource-of-truth + 上述抑制窗口 + 间歇 15B 重置归零的检测设计是协商定的;Liang 实测重连秒。改这套之前先回到 Liang 那里重新确认——这是 stale-cook 的 load-bearing 安全报警。
Per Liang 2026-04-30 同次接受意见还要求把所有面向用户的文案从「描述链路丢失」改成「告诉用户怎么办」("move the booster closer to the probe"):WarningPage 弹窗的 warningProbeDisconnectedMsg(en/zh/de/es/fr/it 6 语并行更新——zh 是「探针未连接中继盒 / 请移动中继盒,使它靠近探针」)、烹饪页无探针分支的 hardcoded 英文(详见 烹饪 §用户能看到的内容)、以及 notification_service.dart 的 _alarmContent lockscreen 文案均同步改写。后台/锁屏通知面(声音、震动、iOS badge、Android NotificationVisibility.public、iOS timeSensitive)已满足 spec,无需调整。
AlarmService 维护两套 map:_active 与 _userDismissed 的 key 都是 (deviceId, probe) 二元组,value 是该槽位上的 AlarmType 集合;AlarmKey 才是 (deviceId, probe, type) 三元组。
_active[(deviceId, probe)] —— 条件为 true 的 AlarmType set,probe 是 ProbeNumber?(booster-level 报警的 probe 为 null)_userDismissed[(deviceId, probe)] —— 用户手动 dismiss 但条件仍 true 的 AlarmType set// alarm_service.dart:117
typedef AlarmKey = (String deviceId, ProbeNumber? probe, AlarmType type);
topAlarm 派生跨所有 (device, probe) 找优先级最高、未被 dismiss 的 AlarmType —— 这就是 Warning 页 / banner 要显示的那个。算法在 alarm_service.dart:361,三键 _compareKeys 比较器(优先级 → deviceId → probe)保证稳定排序。
alarmStateProvider(device_providers.dart:1750–1971)—— 不是 cooking_page.dart(2026-05 已统一搬到 provider body)。
评估器主体一段巨型 switch 按设备 / 探针 / session 走,伪码骨架:
// alarmStateProvider body 内
for (final booster in connectedBoosters) {
alarm.checkFreshConnect(deviceId, booster.deviceStatus); // 顶部
for (final probe in booster.connectedProbes) {
if (booster.isInGracePeriod) continue; // 宽限期冻结 probe-level
if (probe.internalTempF >= 212) {
alarm.trigger(deviceId, probe.number, AlarmType.internalOverTemp);
} else {
alarm.clear(deviceId, probe.number, AlarmType.internalOverTemp);
}
// ... internalUnderTemp / ambientOverTemp / ambientUnderTemp
// ... probeDocked / probeDisconnected
alarm.evaluateProbeLowBattery(deviceId, probe.number, ...);
}
// booster-level (不受宽限期影响)
switch (booster.deviceStatus) {
case lostConnection: alarm.trigger(.., AlarmType.boosterDisconnected); ...
}
alarm.evaluateBoosterLowBattery(deviceId, booster.batteryLevel, booster.firmwareReady);
}
// active sessions 走另一段循环:targetReached / earlyWarning1/2
评估器监听:
boosterProvider.family(设备状态)probeByNumberProvider(探针温度)cookingSessionsProvider 上每个 active CookingSession.earlyWarnLevel(per-session 前置报警距离,per Liang TAPD #1003091 / 33b4e62 — 不再读全局 earlyWarnLevelProvider,避免并发 (device, probe) 会话之间阈值串扰)重评触发(main.dart 两条路径):
container.listen(connectedBoostersProvider, ... container.refresh(alarmStateProvider)) —— 新遥测触发同步重评Timer.periodic(5s, ... container.refresh(alarmStateProvider)) —— 兜底 no-telemetry 场景(probe-disconnect 阈值不会自然 emit)为什么用 container.refresh 而非 ref.invalidateSelf() 在 provider body 内:invalidateSelf 走 Riverpod 内部 scheduler、绑定到 Flutter frame loop;AppLifecycleState.paused 时 frame 不 tick(即使有 Android 前台服务保活),dirty 标记会一直挂着等下次 wake-up frame drain。container.refresh 在 listener tick 上同步跑 body——可在 paused 下也立即响。详见 main.dart 注释。
⚠️ Session-alarm 决策诊断(TAPD #1003078 / c458c44):active sessions 那段循环每 tick 跑
if (cond) trigger / clear,但决策本身之前只走 console-onlydebugPrint、导出 DevLog 看不到——05-27 CM1 log(culinatech_log_20260527103957)显示探针越过目标但targetReached/ 提前预警都没响、却也没有 eval trace 可看。c458c44 加两条logCore诊断(always-persisted、survive dev-mode off):(a)SESSION_ALARM_EVAL——循环匹配上「active session ↔ connected probe」时记temp / target / earlyLevel / grace / result,stuck no-fire cook 会以band=true at=true result=none在导出里直接显形;(b)SESSION_ALARM_SKIP——active session 在但connectedProbes没有一根 slot 对得上(dashboard cook 卡片没有这道闸),记probeStates=[Black:on,White:off,…]。关键:两条都用 module-levelMap<(deviceId, ProbeNumber), String> _sessionAlarmDiagSig缓存上次 emit 的 signature、状态变化才 emit——正常一次烹饪只会留 ~3 行 transition(matched → in-band → at-target,target-reached 后会话 auto-end)、不 flood 导出。不动 alarm-firing 行为本身。
规则 3(v4.2):断线宽限期内(90 秒静默重连)不触发 probe-level 阈值报警。
原因:避免用缓存数据误报(例:牛排已熟了但探针其实断了几秒没数据)。
例外:
lowBattery 也会被 Booster.isInGracePeriod 跳过;它并未作为宽限期例外单独评估boosterDisconnected / boosterPoweredOff / boosterLowBattery 是 device-level 状态,不受宽限期冻结——这些状态本身就是断连/断电,宽限期内压制反而吞掉关键报警代码中的检查:评估器进入 probe-level 分支前先看 Booster.isInGracePeriod,是 true 就跳过温度阈值判断。
位置:warning_page.dart(562 行)
触发路径:仅 internalOverTemp / ambientOverTemp 两类 safety alarm 走全屏推送(app.dart:63-90);其余 11 类全部走顶部 AlarmBannerHost banner,由 MaterialApp.builder 在 builder 里包裹 child 实现,banner 直接 ref.watch(alarmStateProvider) 决定显隐,不需要 ref.listen 推页面。
cooking_page / 任意页 telemetry 进来
→ alarmStateProvider body 重评
→ topAlarm 变化
→ app.dart 的 ref.listen 检查 topAlarm
├─ if isSafety (ambient/internalOverTemp): 用 rootNavigatorKey 推 WarningPage
└─ else: AlarmBannerHost watches alarmStateProvider, 显 banner
UI(safety alarm 全屏页):
app.dart 的 alarmListener 检查:
WarningPage 在栈顶 → 替换为新页(pushReplacementNamed),不是 push 一层nav.popUntil 里扫描栈顶位置:notification_service.dart + foreground_service.dart(65 行小封装)
角色:不是业务告警,而是防止 OS 回收进程的"永久通知"。文案 "Monitoring N device(s)"(本地化,per Liang 2026-05-08)。
connectionStateProvider 变化时)BleMonitoringService.kt(Android Kotlin Service 子类)承载NotificationService 在后台用 flutter_local_notifications 调度通知:
注意:iOS 在后台不允许自动响铃(系统规定),所以高优先级报警在 iOS 后台只能通过通知中心展示,铃声由系统通知设置控制——除非 Critical Alerts entitlement 已授权,详见下方。
⚠️ Settings → Notifications toggle gate(cd18bb1 / TAPD #1003096,28c930e 二次迭代起 in-app 路径不再可达):
alarmStateProvider的currentAlarms派发循环在调notifySvc.showAlarmNotification(...)之前读一次notificationsEnabledProvider,按final isSafetyAlarm = type == ... internalOverTemp || ... ambientOverTemp; if (!notificationsEnabled && !isSafetyAlarm) continue;的语义对 11 类非 safety 报警(targetReached / earlyWarning1/2 / lowBattery / probeDisconnected / boosterDisconnected / boosterPoweredOff / boosterLowBattery / probeDocked / internalUnderTemp / ambientUnderTemp)做continue早返、跳过 background system notification dispatch;Safety alarminternalOverTemp/ambientOverTemp永远 fire——与下方 HyperOS strip callout 里「fullScreenIntent 绕过 channel-level floating」同源思路:用户可以关掉 noisy 报警,但 safety 永远直通。28c930e / 同 TAPD #1003096 二次迭代后:设置页 §Action #4 Notifications 已不再是 in-app Switch、而是跳 OS 系统通知设置的 chevron tile;NotificationsEnabledNotifier.set删除、init()强制state = true并清掉持久化notifications_enabled键,因此本 gate 实际恒走notificationsEnabled == true分支、不抑制任何 alarm,OS 单独 gate 可见性。alarmStateProvider仍 read 一次notificationsEnabledProvider+ 保留 isSafetyAlarm gate 代码——为未来若需回归 in-app mute 保留 wiring,不必重新接线。Toggle 不 gate UI 路径(banner / WarningPage / Live Activity / 前台 service 持续通知)——这些是 in-App / 系统级 ongoing 表面、不属本开关 scope。
⚠️ Android channel id 升级(TAPD #1003061 Samsung S20 + #1003062 Xiaomi 15 Pro):Android
NotificationChannel.importance在首次创建后不可变——早期 build 上以低 importance 创建过 channel 的设备,代码后续改常量也无效。两次 bump 同根:
cooking_alarms→cooking_alarms_v2(c7f1ea9):烹饪报警类别原 stuck 在 Silent / 无 heads-up(Samsung S20「Floating=off / Sound=None」、Xiaomi 15 Pro 同症状)。修复在notification_service.dart把_alarmChannelId升到'cooking_alarms_v2'、保留_alarmChannelIdLegacy = 'cooking_alarms',init()在createNotificationChannel(v2)之前先deleteNotificationChannel(_alarmChannelIdLegacy);新 channel 以Importance.max(Alert + heads-up + system sound)登记。HyperOS / MIUI 例外(Liang 2026-05-26 clean-install A/B,c078fa4):同一 APK 0.1.0+2 在 Xiaomi 15 Pro 4 次 clean install 全部默认 heads-up + sound OFF,Samsung 同 APK 2 次 clean install 全部 ON——MIUI/HyperOS 在 channel 创建时强行剥掉 floating + 提示音,与Importance.max无关、无 API 可绕。Pixel / Samsung 是协商定的 reference,bump 在那里仍是必需的。Over-temp safety alarm 不受此影响:internalOverTemp/ambientOverTemp走 AndroidfullScreenIntent路径绕过 channel-level floating 开关,所以 HyperOS strip 不影响安全报警(详见下方 iOS Critical Alerts 同条目的 AndroidfullScreenIntent分支)。HyperOS 用户的非 safety 报警(targetReached / earlyWarning / lowBattery / probeDisconnected 等)需要由用户自行进系统通知设置开「显示为弹出窗口」+「提示音」,无法在代码侧默认开启。background_monitoring→background_monitoring_v2(d0714b5):Liang 在 0525_2256 build(已含上一条修复)上复测时确认烹饪报警已 提醒,但 Background Monitoring 类别仍显示 静音 / 显示为弹出窗口=关——按同模式 bump,把_bgChannelId升到'background_monitoring_v2'、_bgChannelIdLegacy = 'background_monitoring',新 channel 以Importance.high(+enableVibration: true/playSound: true)登记,让通知设置把该类别显示为 提醒(Alert)+ 显示为弹出窗口。关键:bg-monitoring channel 由两个 poster 都会创建——DartNotificationService.init()与 KotlinBleMonitoringService.ensureChannel()(cold OS-driven revive 路径可能先于 Dart 跑),所以 const id 与 importance(Kotlin 端IMPORTANCE_HIGH/ Dart 端Importance.high)两侧必须同步修改、两侧 init-time 都要先deleteNotificationChannel(legacy)。FG-service 持续通知本身在 Kotlin 侧加setOnlyAlertOnce(true)+ 删掉setSilent(true)+PRIORITY_HIGH——保证 "Monitoring N device(s)" 在 monitoring 开始时弹一次,后续 device-count 文案更新不会 beep。后续如再需要重置 importance,按同模式 bump
_v3并把旧 id 加进 init-time delete 列表;bg-monitoring 还需同步改 KotlinBleMonitoringService.CHANNEL_ID常量。
init() 里的 DarwinInitializationSettings 申请 requestCriticalPermission: true(notification_service.dart:103),并在每条通知的 DarwinNotificationDetails 上按类型分级——ambientOverTemp / internalOverTemp 用 InterruptionLevel.critical(:280-281),其余 11 类保留 InterruptionLevel.timeSensitive,对应 Android 侧 isSafetyAlarm 走 AudioAttributesUsage.alarm + fullScreenIntent 的分支。该路径需要 Apple 颁发 com.apple.developer.usernotifications.critical-alerts 权限才会真正生效;未授权时 critical 静默降级为 timeSensitive,与现状一致,不会破坏任何功能。Apple 申请单独跟踪为 Task #11 PART B,详见 平台集成 §iOS Critical Alerts 与 待解决问题。
报警通知体(低电量 / 探针断连 / 中继盒断连 / 目标达到 / over-temp 等 13 类)+ 后台监测持久通知(Connecting… / Monitoring 1 device / Monitoring N devices)+ Dashboard Delete Device 确认弹窗(标题/正文/Cancel/Delete 四条)原本不论 App 语言一直显示英文。修复加 NotificationService._l + updateLocalizations(AppLocalizations? l) setter 走 notify* / bgMon* / dashboardDelete* 三组 ARB key(en/zh/de/es/fr/it 6 语,新增 18 个 key 并清掉 7 个 orphan key),MaterialApp.builder 在每次重建(locale 变化时 MaterialApp 自然 rebuild)调一次 updateLocalizations,_lastBgMonText 缓存被 setter 失效以保证语言切换后下一次 emit 重渲染。背景隔离边界:通知如果从 background isolate 触发(例 iOS push 唤醒,UI 还没 build 过),_l 仍是 null,回退英文——目前所有 alarm trigger 都走主 isolate 上的 alarm provider,可接受;如果未来真正出现后台 isolate 直接触发通知,需要把 locale 通过 SharedPreferences 或 native 侧 plumb 进来。
⚠️ V5 探针标签格式(per Liang 2026-05-14,33b4e62):探针 user-facing 标签由 "Probe N" 改为 canonical 颜色——
ProbeNumber.colorName给 Black / White / Blue / Yellow,labelFor(deviceId)拼出 "<deviceId>_<Color>"(如CM2_F07F_Black;QA 2026-05-18 / 5b37e69 将分隔符从空格改为下划线让 color 读作第三段、与 deviceId 自身<family>_<id>形状对齐),mapping 通过 data-packet address 字节固定(0x0A/0x0B = black、0x0D = white、0x0E = blue、0x0F = yellow),legacy 与 CM4 通用。用户面无邻接 deviceId cell 的入口(push 通知、iOS Live Activity payload、烹饪页顶栏、WarningPage banner)走labelFor(deviceId);已在 per-device scope 内的 UI(设置页 tile、Dashboard probe tile、信息 modal、cook-log 行)走短的colorName,避免 deviceId 重复;dev-logdetails:字段也走colorName(DevLogEntry自带 deviceId 列)。NotificationService._alarmContent的 per-probe body template 同步去掉$boosterName ·前缀(probeLabel 已含 deviceId),warning-page / cooking-page 丢掉多余的'$probeLabel · $deviceLabel'后缀;dev_overlay老的本地颜色映射(1=blue / 3=black / 4=red)顺手修成 canonical 1=black / 2=white / 3=blue / 4=yellow。
⚠️ TAPD 2026-05-13 follow-ups 一并随 V5(33b4e62):四个小修同 commit 落地——(1) scoped mute:
alarm_banner把dismissedProbe透到muteAlarm,让 legacy0x55ADcollateral 清掉的alarmEnabledflag 能 re-arm 其他探针;(2)_DeviceState.lastMuteSentAt+ 3 s mute-echo 窗口在BleDeviceService里压制由同一条0x55AD回响触发的伪 "factory-reset signature" ADOPT 路径;(3) 全局earlyWarnLevelProvider不再被alarmStateProvider直接读,evaluator 改取每个 activeCookingSession.earlyWarnLevel,避免并发 (device, probe) 会话之间 pre-alarm 阈值串扰(TAPD #1003091);(4) Dashboard 把单一cookingStatus拆成cookingMeat+cookingDoneness两个字段独立渲染。
⚠️ CM4 Confirm 必须 pair
RING_OFF+ 探针 DISARM(3c40aa9 / TAPD #1003214):CM4 路径下先前muteAlarm只发RING_OFF——但RING_OFF是 buzzer-only(Beta Q12:静音当前响铃、不影响后续告警),Mute 模式(RI_CNT=4)/ timed ring 已过期等中继盒不在响时本身就 no-op,每探针 闹钟 icon + backlight flash 跟着 Confirm 不熄、只能等用户跑到中继盒边按物理键才能清(logculinatech_log_20260610150138.txt实证:14:42:09 设RI_CNT=4、14:52:28 / 14:53:11 两次 targetReached confirm 都只发了RING_OFF)。修复让_muteCm4Alarm先RING_OFF、Future.delayed(250 ms)(Beta 2026-05-11 同设备 AE03 ≥200 ms 间距规则)后调transport.disarmProbeAlarm(probe)发SET_<color>=000000——即字段 A=0 的报警 disarm 形(Beta 2026-05-25 cloud doc 表给出SET_BL=000000范本),wire 上等价于物理按键、scope 在被确认的那根探针。所有 ring mode 都发(不靠RI_CNT缓存 gate),timed-ring expired 也需清且 ring 模式下 confirm 之后 cook 已 finalize(TAPD #1003197)、下次烹饪setProbeTarget自然 re-arm 不冲突。HW 验证 pending Liang:in-flightSET_<color>=000000是否真能在中继盒侧清掉 firing 状态下的 icon + backlight;若固件忽略 alarming 时的 A=0,fallback 是 FW 改让RING_OFF在 mute 模式下连同视觉一起清。Legacy 路径不变(0x55ADcollateral 清 alarmEnabled flag + re-arm pass 早已覆盖),新的BoosterTransport.disarmProbeAlarm(ProbeNumber)接口方法 legacy 实现 no-op。
alarmStateProvider 的 keep-alive两道并行的 keep-alive 都不能少:
CulinaTechApp.build() 里 ref.listen<AlarmState>(alarmStateProvider, ...) —— 确保 Provider 跨所有页面不被 Riverpod 自动释放,否则切换屏幕会丢 _active 状态。main.dart 在 ProviderContainer 上加 container.listen<AlarmState>(alarmStateProvider, (_, __) {}) 非 widget 订阅。原因:alarmStateProvider body 同时承载阈值 eval 循环(alarm.trigger)与本地通知投递(notifySvc.showAlarmNotification),但消费者只有 widget——AppLifecycleState.paused 下 widget 不重建、provider 不重评,alarm 只能在用户回前台的瞬间补射(log culinatech_log_20260513105151.txt 10:51:18.931 实证:targetReached 在内温越过 134 °F 目标 17 秒后才 fire / lifecycle=inactive,27 秒后同 pattern 才 trigger internalOverTemp)。非 widget listener callback 故意留空——唯一作用是让 provider 保持 live,每次 connectedBoostersProvider emit 触发 Riverpod 重评 provider body 内的 eval 循环;eval 逻辑本身、所有阈值、Liang-marked 固定代码区域均未触碰。AlarmType 枚举值(0 最高 → 12 最低)AlarmKey 让 multi-device + multi-probe 并行烹饪时 Probe 1 vs Probe 2 同类报警不互相吞internalOverTemp / ambientOverTemp),其它走顶部 banner——不要把 targetReached / boosterDisconnected 等改回全屏rootNavigatorKey 推 —— 不要改成从子 navigator 推,否则在某些页面栈里不可见