Files
super_agent_bot d6253aa974 fix(ui/wave): 钉死并修复回放页恒空根因——读侧用反 seg_path 返回契约
FR 埋点构建上板判读(本 boot 串口实录,临时诊断构建 = 上一提交):

    [UI_TASK] ui wave scan #1 done cnt=7 np=0 tt=0 ac=407
    [ERR][UI_TASK] ui wave head #1 path fail idx=6

- 目录枚举一直是好的(cnt=7 = 盘上七段全数入表,锁也拿到,tt=0):
  10-31「唯独 ui_task_wave_scan 拿不到目录条目」的定性被推翻。
- 真正坏的在 load_head / seg_win 的拼路径判错:契约
  (App/algo/wave_store.h:458)是「成功 = 写入字符数(本形态恒 16),
  失败才是负码」,读侧自建成功能起写成 != 0 判失败——每次成功都被
  判死,f_open 永远走不到,盘上有段也恒显「无录波段」。写侧
  default_task.c 同函数用 <= 0,一直是对的,所以「FTP 六段全见、
  写侧枚举正常、唯独回放恒空」的怪象全线得解。
- 模拟器走桩、不编译本文件读侧,黄金参考图与主机单测因此从未覆盖
  这条链,此病从建成存活至今(板端首张真机波形图见 PR #67)。
- 两处口径改 <= 0(与写侧一致);表列着的段 f_open 失败补带 FR 码
  的永久留痕——该失败态页面无视觉面,10-31 误定性的教训就是静默。
- 临时诊断(#N 序号 / try 闸日志 / 无条件 done 行)收敛删除,留
  最小永久集:scan opendir/readdir FR、零条目 INFO、head/win 开档
  失败 FR。try 闸恢复静默(看门狗设计说明 §4.3 口径)。
- /keil-build 0 错误 0 警告:Code 247,284 B(较临时诊断构建
  −368 B,较修复前 +236 B),RAM 不变;台账 / 同码凭证 / 数字回填
  同批刷新。

Refs #38
2026-11-02 18:07:52 +08:00

1183 lines
61 KiB
C
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 模块名称:UI Task
* 模块功能:UI 主任务,等待网络就绪后初始化显示/菜单/推流并周期刷新
* 适用平台:STM32F407 系列(LVGL + CH395F 以太网)
* 作者:王建锋
* 创建日期:2026-08-30
* 修改记录:
* v1.1 2026-09-14 王建锋 新增待机显示页初始化(ui_standby_init,空闲 60s 切入)
* v1.2 2026-10-10 王建锋 任务耗时测量埋点:主循环加常开探针
* (循环周期 / 单轮工作耗时 / ui_net_sink_poll 往返),
* 用于把"心跳缺席"拆成 饿死 / 阻塞在往返 / 自身拖长
* 三种互斥机理(判读见 docs/子系统设计/看门狗设计说明.md §11.4)
* v1.3 2026-10-10 王建锋 解耦心跳(docs/子系统设计/看门狗设计说明.md §11.3):把 task_wd_beat
* (TASK_WD_UI) 提到 ui_net_sink_poll() **之前**。原写法心跳
* 在推流往返之后 → 心跳周期 = 渲染 + 一次 netTask 往返 + 10ms,
* 实测 uiw≈uis≈50ms(心跳被 netTask 往返主导),心跳实际测的是
* "netTask 响应性"而非"UI 活着"。提到往返之前后,心跳测的是
* "UI 自己被调度到了"。注:若往返**永久**阻塞,本改仍不能救
* (心跳只发一次)→ 真要彻底去掉依赖须走事件驱动 accept
* (NET_EVENT_CONNECTED 回调),待复现快照确认阻塞机理后再定。
* v1.4 2026-10-25 DTU 项目组 F3(大窗判读):向 ui_net_sink 注册心跳回调
* (ui_task_sink_beat)——推流段内**每个可等待 net 往返**
* (accept/recv/send/close/重建)返回点即喂本任务心跳,
* 风暴期「单轮多个慢往返之和」不再串成 800ms 缺席链
* (2026-10-25 实测 bit4 8 连击、uisn=154ms 仍在段内;
* 按位连击判据 = docs/子系统设计/看门狗设计说明.md §5.3)。
* 单个往返自身仍无上界——该上界与彻底解耦(事件驱动
* accept)归登记簿「net 层跨上下文收口」(批 3),本票只做心跳续命。
* v1.5 2026-10-26 DTU 项目组 道F task-3 装置维护三页的执行侧:新增
* ui_data_maint_ops_t 六件适配(时钟=写+回读 RTC、
* 复归=prot_elm_request_reset、清除=syslog_request_clear
* /clear_state 直通)并在 ui_menu_init 之前注入;记录
* reader 的 2 条 if 链改成 7 条映射表 s_log_map,并加
* 编译期守卫把 UI_W_REC_LOG_* == syslog_id_t 枚举序钉死
* v1.6 2026-11-02 DTU 项目组 修复「回放页段表枚举恒空」第一幕(登记簿同名
* 条目 + docs/测试说明/专项验证/故障录波板级到场轮执行手册.md §3):
* ui_task_wave_scan 失败不再无条件钉 s_wave_scanned——
* opendir/readdir 失败本轮返 -1(卷忙语义),下一次进页/
* 按键自然重试;失败分支与「FR_OK 零条目」补带 FR 码留痕
* (正文「ui wave scan」前缀,tag 沿用本文件 [UI_TASK])
* 供板级远程定性根因。FR_NO_PATH(从未录过波)归 0 段
* 并钉表的设计原口径不动。
* v1.7 2026-11-02 DTU 项目组 根因修复:wave_store_seg_path 契约是「成功 =
* 写入字符数(恒 16),失败为负码」,读侧两处(load_head
* / seg_win)自建成起把返回值 != 0 当失败——每次成功拼
* 路径都被判死,回放页开档永远走不到 f_open,盘上有段
* 也恒显「无录波段」(本 boot FR 埋点钉死:scan done
* cnt=7 + head path fail;写侧同函数用 <= 0 一直是对的;
* 模拟器桩不走本文件,故黄金图从未暴露此病)。口径改
* <= 0;开档失败补带 FR 码的永久留痕(该失败态页面无
* 视觉面);第一幕临时诊断(序号/try 闸日志/无条件
* done 行)收敛删除。
*/
#include "ui_task.h"
#include <string.h>
#include "cmsis_os.h"
#include "sys_events.h"
#include "ui_port.h"
#include "ui_menu.h"
#include "ui_net_sink.h"
#include "ui_standby.h"
#include "ui_w_records.h" /* task-6 记录 reader 注入点(UI 页面不再 include sys_log.h) */
#include "ui_w_soe.h" /* task-15 SOE 记录 reader 注入点(同上:UI 不 include soe_store.h) */
#include <stdio.h> /* task-15 snprintf:CP56 → UI 视图里的 char[20] 时标 */
#include "ui_data.h" /* task-14 快照存侧(BSP):本任务只负责先复位再喂帧 */
#include "ui_data_feed.h" /* task-14 数据装配节拍入口(App/app,UI 侧唯一发布者) */
#include "sys_log.h"
#include "soe_store.h" /* task-15 SOE 只读遍历(count/read);写侧仍归 protectTask/defaultTask */
#include "ui_tokens.h" /* UI 设计令牌:循环周期 UI_TASK_TICK_MS / 待机阈值 UI_STANDBY_IDLE_MS */
#include "sys_clock.h"
#include "prot_elm.h" /* 道F task-3 装置维护:强制复归请求 + 动作态元件位图 */
#include "adc_health.h" /* 屏板状态灯 ID 4:ADC 电平故障位(adc_health_flags) */
#include "sb_led_map.h" /* 屏板状态灯位映射(批 3:业务状态 → info[0..3] 掩码) */
#include "iec104_serv.h" /* 屏板状态灯 ID 3:104 链路 RUN 态(任意上下文可读) */
#include "test_config.h" /* TEST_LATENCY_PROBE(链路时延探针编译开关) */
#include "net_latency_test.h" /* M3 探针:ui_loop(生产循环实测周期) */
#include "task_watchdog.h"
/* 故障录波回放道(UI 道):录波注入表执行侧需要的四份头。
* wave_ring.h 给触发投递(request 任意上下文可调、非阻塞);
* wave_store.h 给段格式解析与定点 mV 换算(唯一口径,页面不复述算式);
* vol_lock.h + ff.h:/wave 只读遍历按 soe_store.c 读者同款纪律——
* try 锁非阻塞(卷忙本轮按「无段」处理,uiTask 绘制节拍绝不等待)、
* 锁跨完整文件操作序列、小窗口分次读、整段 5250B 绝不驻留 RAM */
#include "ff.h"
#include "vol_lock.h"
#include "wave_ring.h"
#include "wave_store.h"
#include "task_prof.h" /* P2 常开探针:循环周期 / 工作耗时 / 推流往返 */
#include "sb_link.h" /* 屏板链路:帧 B 组帧 / 按键出队 / 统计与离线判定(批 2) */
/* 枚举留痕(v1.6,第一幕埋点):dbg_log.h 原在文件后段(s_wave_ops 之后)才
* 被包含,随本波把 DBG_TAG 定义 + 包含一并上收到包含区(原后段两处删除,
* 同标签同宏,不构成行为改动);本波只在原先静默返回的黑洞处补锚点,
* 不新增重复日志(DBG_* 每条调用点约 90B Flash + 行缓冲栈)。
* 留痕行以「ui wave scan」正文前缀区分来源。 */
#define DBG_TAG "[UI_TASK]"
#include "dbg_log.h"
/*
* 函数功能:待机屏底部时钟提供回调(平台差异:MCU = sys_clock_get_str)
* 入口参数:buf - 时间串输出缓冲 char *
* len - buf 容量 size_t
* 返回值:无
*/
static void ui_task_time_cb(char *buf, size_t len) {
if (sys_clock_get_str(buf, len) == NULL && len > 0u) {
buf[0] = '\0'; /* 墙钟无效:空串(待机屏时钟行留白) */
}
}
/*
* UI 记录源编号 → syslog_id_t 映射表(道F task-6 起 7 类,task-3 装置维护「清除记录」
* 要在 7 行上分行显示;表长 = UI_W_REC_LOG_N,两端映射表同源同长)
*
* ⚠ 顺序契约(Lead 裁定 2026-10-26):UI_W_REC_LOG_* 逐字等于 syslog_id_t 枚举序,
* 只准尾部追加——枚举序就是 App/log 两端 s_paths[]/s_caps[] 的下标,中间插值会
* 安静地把事故读成告警。下面的编译期守卫就是这条契约在本端的机械防线(记录侧
* 已有 TC-SYSLOG-130/131/131b,UI 侧对应的一条在 test/host/ui_data TC-UIDATA-024,
* 两端各钉一次,谁中间插号谁编译不过 / 单测红)。
* 即便如此,适配层**始终显式逐项映射**、从不把 UI 编号强转成 syslog_id_t:
* 等值是今天的事实,不是页面可以依赖的契约(UI_W_REC_LOG_* 对页面仍是不透明号)。
*/
static const syslog_id_t s_log_map[UI_W_REC_LOG_N] = {
SYSLOG_RUN, SYSLOG_SELFTEST, SYSLOG_ACCIDENT, SYSLOG_ALARM,
SYSLOG_START, SYSLOG_OPER, SYSLOG_YK
};
#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L)
typedef char ui_task_log_order_check_t[
((UI_W_REC_LOG_RUN == (uint8_t)SYSLOG_RUN) &&
(UI_W_REC_LOG_SELFTEST == (uint8_t)SYSLOG_SELFTEST) &&
(UI_W_REC_LOG_ACCIDENT == (uint8_t)SYSLOG_ACCIDENT) &&
(UI_W_REC_LOG_ALARM == (uint8_t)SYSLOG_ALARM) &&
(UI_W_REC_LOG_START == (uint8_t)SYSLOG_START) &&
(UI_W_REC_LOG_OPER == (uint8_t)SYSLOG_OPER) &&
(UI_W_REC_LOG_YK == (uint8_t)SYSLOG_YK) &&
(UI_W_REC_LOG_N == (uint8_t)SYSLOG_COUNT)) ? 1 : -1];
#endif
/*
* 函数功能:记录 reader 适配——UI 不透明编号 → syslog_id_t 后取条数
* 入口参数:log_id - UI 记录源编号(UI_W_REC_LOG_*) uint8_t
* 返回值:记录条数;未知编号返回 0(页面退化为空态,与合并前直调同形) uint16_t
* 限定条件:uiTask 上下文同步调用(记录页与清除记录页的绘制路径,无 I/O 之外开销:
* syslog_count 读的是文件长度/内存计数,不逐条读盘)
*/
static uint16_t ui_task_rec_count(uint8_t log_id) {
if (log_id >= (uint8_t)UI_W_REC_LOG_N) {
return 0u;
}
return (uint16_t)syslog_count(s_log_map[log_id]);
}
/*
* 函数功能:记录 reader 适配——读记录字段直通 sys_log(错误码不改写)
* 入口参数:log_id/idx/ts/ts_len/desc/desc_len - 契约见 ui_w_records.h ui_rec_reader_t
* 返回值:0 成功;-1 编号/序号越界(未知编号同 -1);-2 读失败 int
* 限定条件:同上
*/
static int ui_task_rec_get(uint8_t log_id, uint32_t idx, char *ts, uint32_t ts_len,
char *desc, uint32_t desc_len) {
if (log_id >= (uint8_t)UI_W_REC_LOG_N) {
return -1;
}
return syslog_get_record(s_log_map[log_id], idx, ts, (size_t)ts_len, desc,
(size_t)desc_len);
}
/*
* ============================ 道F task-3:装置维护写通道(UI 注入执行侧)
*
* 为什么在这一层实现而不是在页面里:页面属 Drivers/BSP/UI,反调 App 侧的
* sys_clock / prot_elm / sys_log 是分层方向的反例(layer-deps 拦),更关键的是
* **模拟器根本不链 sys_clock**(simulator/dbg_log.h:4「模拟器无 sys_clock/RTC」)——
* 页面里用一次就是"MCU 编得过、模拟器链接期爆"。写动作一律走 ui_data_maint_ops_t,
* 两端各给实现(口径 docs/UI设计/UI_数据供给设计说明.md §6.2/§7.2)。
*
* uiTask 阻塞上界(IWDG 800ms 窗、本任务在 required_mask 内):本通道里只有
* 时钟设置会碰硬件——sys_clock_set_unix 内含 1 次 SD2506 写,再叠加本适配层刻意
* 加的回读复核 1 次读,各 SD2506_I2C_TIMEOUT_MS=100ms ⇒ 最坏 ≈200ms 落在一次
* "保存"按键上(心跳在循环尾,仍远小于 800ms;对时任务同样在本机上做过这件事)。
* 复归/清除两条只写内存标志或投递给别的任务,无 I/O。
*/
/*
* 函数功能:设置墙钟并**回读复核**(真机 = 写 RAM 基准 + 写 SD2506 RTC)
* 入口参数:a_epoch - Unix 秒(本地时间域,与 sys_clock 同一口径,无时区偏移) uint32_t
* 返回值:0 - 已生效且 RTC 回读一致;-1 - 越界 / 未生效 / RTC 回读不符 int
* 限定条件:uiTask 上下文(页面"保存"按键直达,同步返回终态)
* 函数说明:⚠ **sys_clock_set() 恒返 0**(App/util/sys_clock.c:462 起:RTC 写失败
* 只打 DBG_ERROR,不改返回值),所以本函数不能拿它的返回值当结论——
* 回读是唯一真判据;而只回读 sys_clock_get() 证明的仅是"RAM 基准换了",
* 面板对时的用户意图是**掉电后还要是对的**,故必须回读 RTC 本身。
* RTC 坏了但墙钟已改时本函数返 -1(屏上"保存行=ERR + 值列跟着新时间",
* 两个事实各说各的,比笼统报 OK 诚实);秒位允许 ±3 与"分/时/日回绕"两种
* 形态,因为两次读之间墙钟与 RTC 都在走。
* 上限 UI_DATA_CLOCK_EPOCH_MAX(2038) 在 UI 层已挡,这里再挡一次:
* 执行侧不信任调用侧(0 还兼作"未建钟"哨兵,面板永远不该发 0)
*/
static int ui_task_maint_clock_set(uint32_t a_epoch) {
sd2506_time_t want;
sd2506_time_t got;
uint32_t now;
int ds;
if ((a_epoch == 0u) || (a_epoch > (uint32_t)UI_DATA_CLOCK_EPOCH_MAX)) {
return -1;
}
if (sys_clock_set_unix(a_epoch) != 0) {
return -1; /* 今天到不了这条腿(sys_clock_set 恒 0),留着是因为它是契约 */
}
now = sys_clock_get();
if ((now == UINT32_MAX) || ((now + 2u) < a_epoch) || (now > (a_epoch + 2u))) {
return -1; /* RAM 基准没跟上:连"现在就对"都没成立 */
}
if (sys_clock_get_time(&want) != 0) {
return -1;
}
if (sd2506_get_time(&got) != BSP_OK) {
return -1; /* RTC 读不动:掉电保持这件事无法证明 */
}
if ((got.year != want.year) || (got.month != want.month) || (got.day != want.day) ||
(got.hour != want.hour) || (got.minute != want.minute)) {
return -1;
}
ds = (int)got.second - (int)want.second;
if (ds < 0) {
ds = -ds;
}
if ((ds > 3) &&
!(((want.second >= 58u) && (got.second <= 3u)) ||
((got.second >= 58u) && (want.second <= 3u)))) {
return -1;
}
return 0;
}
/*
* 函数功能:请求强制复归(跨上下文:protectTask 下一拍消费)
* 入口参数:无
* 返回值:0 - 已投递(prot_elm_request_reset 无拒绝分支) int
* 限定条件:uiTask 上下文;不等待执行结果
* 函数说明:只投递不阻塞(契约:UI 页面二次确认后调,页面立刻给"已请求复归")。
* **不在这里投操作记录**:远端 104 YK11 那条已经在
* App/protocol/iec104/iec104_pts.c 投过,在这里再投一次就会给每一次
* 主站复归多记一条"面板复归",审计面直接失真。本地这条由页面投
* (只有页面知道"这是人按的"),措辞也不同串:见 Drivers/BSP/UI/ui_page_reset.c
*/
static int ui_task_maint_reset_req(void) {
prot_elm_request_reset();
return 0;
}
/*
* 函数功能:取当前处于动作态的保护元件个数(复归页观测行 "保护动作 N" 的 N)
* 入口参数:无
* 返回值:位图中置 1 的位数(纯 RAM 读,无锁) uint32_t
* 限定条件:uiTask 绘制路径每帧可直调
* 函数说明:给个数而不是位图,是为了让 160x160 上一行放得下、也让"复归前 N>0 →
* 复归后 N=0"这个台架判据肉眼可读;判据前提(产品构建处于未标定闭锁——
* App/task/protect_task.c:268 判停,判定本体 = adc_cal_prot_blocked(),
* 闭锁期 N 恒 0)已写进页面注释
*/
static uint32_t ui_task_maint_active_count(void) {
uint32_t mask = prot_elm_active_mask();
uint32_t n = 0u;
while (mask != 0u) {
n += (mask & 0x1u);
mask >>= 1;
}
return n;
}
/*
* 函数功能:投一条本地操作记录(面板动作留痕)
* 入口参数:a_desc - 描述串(ASCII,长度须 <= SYSLOG_DESC_MAX=28) const char *
* 返回值:无
* 限定条件:uiTask 上下文;只在动作真正被受理之后由页面调用
* 函数说明:用 quiet 口(与 iec104_serv.c 对时留痕同一选择):uiTask 不属于深链
* 任务,但 quiet 口的收/丢判据与 syslog_post 一致且不在调用链上摊开
* 行缓冲,留痕这件事没理由选更贵的那个口。
* 丢条不回报页面:syslog 侧有累计丢弃计数(syslog_post_quiet_dropped())
* 可查;页面上"动作做了没"与"这条审计写没写"是两件事,不该混在一个
* 态标里——把后者做成 ERR 会让人以为复归没做而再按一次
*/
static void ui_task_maint_oper_note(const char *a_desc) {
if (a_desc == NULL) {
return;
}
syslog_post_quiet(SYSLOG_OPER, a_desc);
}
/*
* 函数功能:请求清除某一类记录(只投递,落地在 defaultTask 的 syslog_drain 尾部)
* 入口参数:a_log_id - UI 记录源编号(UI_W_REC_LOG_*) uint8_t
* 返回值:0 - 已受理(MCU 端随后通常是 SYSLOG_CLEAR_WAIT);-1 - 越界编号 int
* 限定条件:uiTask 上下文
* 函数说明:本层是 UI 编号 → syslog_id_t 的**唯一**映射点之一(与 s_log_map 同表),
* 绝不在 UI 侧做 f_unlink / 也不调 MCU 专属的 syslog_clear_apply():
* 记录卷的属主是 defaultTask,uiTask 去碰 FAT 既违反唯一写者,又给
* IWDG required_mask 内的任务添一条无上界的存储路径(口径 App/log/README.md)
*/
static int ui_task_maint_clear_req(uint8_t a_log_id) {
if (a_log_id >= (uint8_t)UI_W_REC_LOG_N) {
return -1;
}
return (syslog_request_clear(s_log_map[a_log_id]) == 0) ? 0 : -1;
}
/*
* 函数功能:取上条清除请求的状态(值直通,不改写、不翻译)
* 入口参数:a_log_id - UI 记录源编号 uint8_t
* 返回值:UI_DATA_CLEAR_ST_NONE/_OK/_WAIT/_FAIL;越界编号按 NONE int
* 限定条件:uiTask 绘制路径每帧可直调(纯内存状态读)
* 函数说明:SYSLOG_CLEAR_* 与 UI_DATA_CLEAR_ST_* 逐值相等(0/1/-1/-2),由
* test/host/ui_data TC-UIDATA-024 钉住;这里保持直通,是为了让页面
* 显示的就是日志侧的事实,而不是"适配层以为的事实"
*/
static int ui_task_maint_clear_state(uint8_t a_log_id) {
if (a_log_id >= (uint8_t)UI_W_REC_LOG_N) {
return UI_DATA_CLEAR_ST_NONE;
}
return syslog_clear_state(s_log_map[a_log_id]);
}
/* 维护通道函数表(六件齐全;ui_data_maint_bind 拒绝残表,缺件就是装配错) */
static const ui_data_maint_ops_t s_maint_ops = {
ui_task_maint_clock_set,
ui_task_maint_reset_req,
ui_task_maint_active_count,
ui_task_maint_oper_note,
ui_task_maint_clear_req,
ui_task_maint_clear_state
};
static const ui_rec_reader_t s_rec_reader = { ui_task_rec_count, ui_task_rec_get };
/*
* 函数功能:SOE 记录条数适配(道E task-15 reader 注入)——读落盘文件的完整槽数
* 入口参数:无
* 返回值:条数;超过 uint16 上限时钳到 65535(组件契约是 uint16_t) uint16_t
* 限定条件:uiTask 上下文同步调用(SOE 记录页绘制路径)
*/
static uint16_t ui_task_soe_count(void) {
uint32_t n = soe_store_count();
return (n > 65535u) ? 65535u : (uint16_t)n;
}
/*
* 函数功能:读第 idx 条 SOE 记录并翻成 UI 视图(二进制 → 不透明显示字段)
* 入口参数:idx - 记录序号 uint32_t
* p_out - UI 侧记录视图(契约见 Drivers/BSP/UI/ui_w_soe.h) ui_w_soe_rec_t *
* 返回值:0 - 成功;-1 - 读失败 / 卷忙 / 校验和不过(页面这行退化显示,不猜) int
* 限定条件:uiTask 绘制路径;soe_store_read() 是 try 锁非阻塞,绝不拖住 UI 节拍
* 函数说明:时标**取记录内的 CP56**(事件自己的时间),不用"现在"换算。两条口径
* 叠起来正好与文本记录页一致,别"顺手补 8 小时":
* ① sys_clock 的 epoch 域本身就是本地时间(App/util/sys_clock.h 的
* sys_clock_set_unix 注明"Unix epoch time (秒,本地时间)");
* ② App/log/soe_rec.c 的 epoch→CP56 分解不加任何时区偏移。
* 所以这里打出来的墙上时钟 == 运行/自检记录页里 sys_clock_get_str() 的口径;
* 哪天真要引入显式时区,两侧必须一起改(跨模块陷阱,注释即防线)。
* 时钟无效(IV)不写在这里:它是显示语义,由 UI 侧按 SOE_FLG_IV 决定
* (组件把那行名字打"--"、值打"时钟无效"),App 只交代原始记录。
*/
static int ui_task_soe_get(uint32_t idx, ui_w_soe_rec_t *p_out) {
soe_rec_t rec;
uint32_t sec;
if ((p_out == NULL) || (soe_store_read(idx, &rec) != 0)) {
return -1;
}
/* CP56 字节表见 App/log/soe_rec.c 的 s_soe_epoch_to_cp56:
* [0..1]=分内毫秒(秒域时基 → 恒为秒×1000,亚秒不造假)[2]=分 [3]=时
* [4]=日 [5]=月 [6]=年-2000 */
sec = (((uint32_t)rec.cp56[1] << 8) | (uint32_t)rec.cp56[0]) / 1000u;
(void)snprintf(p_out->ts, sizeof(p_out->ts), "%04u-%02u-%02u %02u:%02u:%02u",
(unsigned)(2000u + ((uint32_t)rec.cp56[6] & 0x7Fu)),
(unsigned)((uint32_t)rec.cp56[5] & 0x0Fu),
(unsigned)((uint32_t)rec.cp56[4] & 0x1Fu),
(unsigned)((uint32_t)rec.cp56[3] & 0x1Fu),
(unsigned)((uint32_t)rec.cp56[2] & 0x3Fu),
(unsigned)sec);
p_out->type = rec.type;
p_out->elm = rec.elm_id;
p_out->flags = rec.flags;
p_out->seq = rec.seq;
p_out->value = rec.value;
return 0;
}
static const ui_soe_reader_t s_soe_reader = { ui_task_soe_count, ui_task_soe_get };
/* ═══════════════ 故障录波注入表执行侧(UI 道,2026-10-29)═══════════════
* 纪律逐条对齐 soe_store.c 读者(vol_lock try / 锁跨完整文件序列 / 小窗口):
* - 卷忙(vol_lock_wait(0) 返 0)一律按「本轮无段」处理——uiTask 是
* LVGL + 推流节拍的所有者,等锁 = 停摆,宁可空一帧也不阻塞(lead 裁定);
* - 段完整性判定只信 wave_store 自己的判据(尺寸等式 / magic+ver / 双 CRC /
* sem==index),本胶水不读 avail/flags 做二次裁断(§6.3 与 2026-10-29
* 「VALID+CLIPPED 非矛盾」裁定同源:可用性不是完整性);
* - 整段 5250B 不驻留:256B 静态窗口分次读,头区一次、数据区流式过 CRC。
* uiTask 阻塞上界(IWDG 800ms 窗、uiTask 在 required_mask 内;写法对齐上方
* 维护通道那段上界,同文件先例):
* - 第零项是「本块不等待」:vol_lock_wait(0) 拿不到锁即按「本轮无段」
* 返回——不等锁 = 零阻塞,它本身就是上界的一项,不是只保心跳判读;
* - 一次渲染实际发生的 I/O(进回放页首绘、或按键后重绘那帧)三件叠加:
* ① 表重扫 = 1 次 f_opendir + 至多 WAVE_ST_SEG_MAX(100)次 f_readdir,
* 收尾是 100 元插入排序(纯内存);
* ② 段头装载 = 仅未缓存段才发生:1 次 f_open + 头区 1 次 128B 读 +
* head_parse 内的头 CRC + 数据区 20 次 256B 流式读 + 尾 2B 一次
* = 最坏 22 次小读;5120B 逐字节 CRC 与 sem 表校验是纯计算,
* 168MHz 下微秒到毫秒量级;缓存命中零卷操作;
* ③ 窗口装载 = 1 次 f_open + 1 次 f_lseek + 1 次 80B 读(40 点一窗),
* 解码 40 点 + 逐点 code_to_milli 峰值扫描 40 次(int64 乘除,
* 微秒级)——不是全段 8×320 扫描,回放页一屏只碰一个通道一窗;
* - 纯内存不碰卷的路径(评审据此看最坏的落点):manual_req 只投一个
* 请求字节;已缓存段的上下切道、左右切段的段号表环游;空态与坏态
* 直接返回。所以最坏只落在「第一次进回放页 / 第一次切到未缓存段」
* 这类一次性动作上,同段内连续按键不重复付 ②。
* 没有实测数,上面是与落盘道同一把尺的**推算**:落盘道的告警线是
* 50 ms / 21 次写,读路径无簇分配、不写 FAT 元数据,只会更便宜,故按
* 同量级不高于它估——一次渲染最坏一窗 ≈ 段表 + 一段全验 + 一窗,仍远
* 小于 800ms。实测归台架,仪器就用本文件已经在跑的 task_prof 单轮
* 工作耗时探针,不必新加埋点。
* 段序口径(V0.1 已知限制,终报点名):目录重建后按段号数值排序环游,
* 盘上写满 100 段翻转后「数值序 = 时间序」不再严格成立——每屏顶行都带
* 段号自证身份,查事故先看号再看序;翻转窗口排序升级留给台架结论。
*/
static uint8_t s_wave_idx[WAVE_ST_SEG_MAX]; /* 盘上现存段号升序表 */
static uint8_t s_wave_cnt; /* 表内条数 */
static uint8_t s_wave_scanned; /* 表是否有效(manual_req 后作废) */
static uint8_t s_wave_cached_idx; /* 段头缓存属于哪一段 */
static uint8_t s_wave_cache_ok; /* 该段已验完整(头+数据 CRC) */
static wave_seg_meta_t s_wave_meta; /* 已验段的头(172B,唯一驻留) */
static uint8_t s_wave_buf[256u]; /* 读窗:数据区流式 256B/次(lead 裁定
* 2026-10-29 第 1 条·第一条:128B 抬 256B,
* f_read 次数对半砍,RAM 只多 128B;
* 不选 100 位「已验」位图——位图的作废纪律
* 一旦漏做,「CRC 不符必须拒收」这条台架
* 判据对旧段号会静默失效,多 128B 没这个面)*/
/*
* 函数功能:重扫 /wave 目录,把现存段号收集成升序表
* 入口参数:无
* 返回值:0 - 扫描完成(条数见 s_wave_cnt);-1 - 本轮无完整事实(卷忙,或
* f_opendir/f_readdir 失败),不钉表,下一次取数自然重试 int
* 限定条件:uiTask;一次 f_opendir + 有界 f_readdir,全程锁内
* 函数说明:目录不存在(FR_NO_PATH,从未录过波)与目录空都归「0 段」并钉表,
* 不是错误(设计原口径);其余 f_opendir/f_readdir 失败一律不钉表
* (v1.6:原实现失败也无条件置 s_wave_scanned=1,卷忙 / FR_DENIED
* 一类失败被钉成「0 段」假空态直到下一次 manual_req,登记簿
* 「回放页段表枚举恒空」处置方向 = 失败不钉表、下键重试,与
* soe_store 读者同纪律)。失败留痕带 FR 码,板级判读表(FR_DENIED
* → 锁纪律;FR_NOT_READY → 挂载;FR_OK 但零条目 → readdir 与卷状态
* 交互)见 docs/测试说明/专项验证/故障录波板级到场轮执行手册.md §3。
* 名字不合形态的文件(E_NAME)静默跳过——那是别的道写进来的
* 东西,回放页不替它背书也不报警
*/
static int ui_task_wave_scan(void) {
static DIR dir; /* static:同 FIL 理由,压栈深 */
FILINFO finfo;
FRESULT fr;
FRESULT rr = FR_OK;
uint8_t idx;
uint8_t i;
uint8_t full; /* 1 = 本轮拿到完整事实,允许钉表 */
if (vol_lock_wait(0u) == 0u) {
/* 卷忙本轮跳过 = 静默(看门狗设计说明 §4.3 口径:消费方在 try 失败
* 分支不留日志,锁层竞争看 vol_lock_get_stats 计数);下一次进页/
* 按键自然重试——本轮没拿到事实,绝不钉表 */
return -1;
}
s_wave_cnt = 0u;
full = 0u;
fr = f_opendir(&dir, WAVE_ST_DIR);
if (fr == FR_OK) {
full = 1u;
for (;;) {
rr = f_readdir(&dir, &finfo);
if (rr != FR_OK) {
full = 0u; /* 半截表不钉:本轮作废重来 */
break;
}
if (finfo.fname[0] == '\0') {
break; /* 目录正常走完 */
}
if (finfo.fattrib & AM_DIR) {
continue;
}
if (wave_store_idx_from_name(finfo.fname, &idx) == 0) {
if (s_wave_cnt < WAVE_ST_SEG_MAX) {
s_wave_idx[s_wave_cnt++] = idx;
}
}
}
(void)f_closedir(&dir);
if (full == 0u) {
DBG_ERROR("ui wave scan readdir fail fr=%d got=%u", (int)rr,
(unsigned)s_wave_cnt);
}
} else if (fr == FR_NO_PATH) {
full = 1u; /* 从未录过波 = 0 段,完整事实 */
} else {
DBG_ERROR("ui wave scan opendir fail fr=%d", (int)fr);
}
vol_lock_release();
if (full == 0u) {
s_wave_cnt = 0u; /* 半截计数不外泄:本轮按「无段」显示 */
return -1;
}
if (s_wave_cnt == 0u) {
/* 判读表第三条(FR_OK 但零条目):钉盘级证据;有段不重复刷
* (DBG_* 每条调用点约 90B Flash,开页成功态由画面自证) */
DBG_INFO("ui wave scan done: dir ok, 0 seg entries");
}
/* 插入排序:条数上限 100,不引 qsort 的 stdlib 依赖 */
for (i = 1u; i < s_wave_cnt; i++) {
uint8_t j;
uint8_t tmp;
tmp = s_wave_idx[i];
j = i;
while ((j > 0u) && (s_wave_idx[j - 1u] > tmp)) {
s_wave_idx[j] = s_wave_idx[j - 1u];
j--;
}
s_wave_idx[j] = tmp;
}
s_wave_scanned = 1u;
return 0;
}
/*
* 函数功能:确保段表可用(必要时重扫)
* 入口参数:无
* 返回值:0 - 表可用;-1 - 卷忙 int
* 限定条件:uiTask 绘制/按键链
*/
static int ui_task_wave_table(void) {
if (s_wave_scanned == 0u) {
return ui_task_wave_scan();
}
return 0;
}
/*
* 函数功能:表内查段号下标
* 入口参数:a_idx - 段号;a_pos - 出参下标
* 返回值:1 - 命中;0 - 不在表内 uint8_t
* 限定条件:表已可用(先过 ui_task_wave_table)
*/
static uint8_t ui_task_wave_find(uint8_t a_idx, uint8_t *a_pos) {
uint8_t i;
for (i = 0u; i < s_wave_cnt; i++) {
if (s_wave_idx[i] == a_idx) {
*a_pos = i;
return 1u;
}
}
return 0u;
}
/*
* 函数功能:读段并做完整性判定(头 CRC 由 head_parse 判,数据 CRC 流式判),
* 成功则把段头留在 s_wave_meta 供 seg_win 免重读
* 入口参数:a_idx - 段号
* 返回值:UI_WAVE_OK / UI_WAVE_NONE(卷忙/文件没了)/ UI_WAVE_BAD(判废) int
* 限定条件:uiTask;锁跨 open→close 完整序列;头 128B 一次、数据区 256B
* 分窗流式(共约 1 + 20 次 f_read),整段不驻留
* 耗时账(lead 裁定 2026-10-29 第 1 条·第三条并存):未缓存段的完整性
* 判定最坏约 21 次小读 + 5120B 逐位 CRC,全部在卷锁内、uiTask 上;
* IWDG 800ms 窗内余量几何**实测毫秒归台架**(终报点名待测项)。
* 缓存只留一段:左右切段 A→B→A 会把 B 的校验再来一遍——接受该
* 成本的代价换「CRC 不符必须拒收」永远现算,不做跨键存的位图
* 函数说明:缓存命中(同段且已验)直接返 OK——左右切段回到上一段、上下切
* 通道都靠它把重复的 5250B 全扫压成一次;文件读中途消失(被别的
* 写者轮转掉)按 NONE 处理,页面下轮自然重扫
*/
static int ui_task_wave_load_head(uint8_t a_idx) {
static FIL f; /* static:FIL = 528B,理由同 soe_store_read */
char path[WAVE_ST_PATH_MAX]; /* 容量 = WAVE_ST_PATH_MAX(17 字节口径,
* 自己声明路径缓冲不得写死 16) */
FRESULT fr;
FRESULT ofr; /* 开档 FR 码:开档失败留痕用 */
UINT br = 0u;
int rc;
if ((s_wave_cache_ok != 0u) && (s_wave_cached_idx == a_idx)) {
return UI_WAVE_OK;
}
if (wave_store_seg_path(path, (uint32_t)sizeof(path), a_idx) <= 0) {
/* 契约(wave_store.h:458):成功 = 写入字符数(恒 16),失败才是负码。
* 建成功能起这里写成 != 0,把每次成功都当失败 → 回放页开档永远走不到
* f_open,盘上明明有段也恒显「无录波段」(2026-11-02 板级 FR 埋点
* 钉死:scan done cnt=7 + head path fail;写侧 default_task.c 同函数
* 用 <= 0,一直是对的)。口径取和写侧一致的 <= 0。 */
return UI_WAVE_NONE;
}
if (vol_lock_wait(0u) == 0u) {
return UI_WAVE_NONE; /* 卷忙 = 本轮按无段处理(不阻塞 uiTask) */
}
rc = UI_WAVE_NONE;
ofr = f_open(&f, path, FA_READ);
if (ofr == FR_OK) {
uint32_t size;
uint32_t total;
uint32_t n;
size = (uint32_t)f_size(&f);
total = 0u;
rc = UI_WAVE_BAD;
/* 第一步:头区(首窗 128B = 当前形态整头);head_parse 自带判废 */
n = (size < (uint32_t)WAVE_ST_HEAD_SZ_CUR) ? size
: (uint32_t)WAVE_ST_HEAD_SZ_CUR;
fr = f_read(&f, s_wave_buf, n, &br);
if ((fr == FR_OK) && (br == n)) {
if (wave_store_head_parse(s_wave_buf, n, &s_wave_meta) == 0) {
total = wave_store_seg_size(&s_wave_meta);
}
}
/* 第二步:尺寸等式(零长/长度与头部字段矛盾 = 整段判废,§6.3) */
if ((total != 0u) && (size == total)) {
uint32_t off;
uint16_t crc;
uint8_t tail[2];
/* 第三步:数据区流式 CRC(128B 窗;CRC 覆盖不含尾两字节自身) */
crc = WAVE_ST_CRC_INIT;
off = wave_store_head_bytes(s_wave_meta.ch_count);
rc = UI_WAVE_BAD;
while ((off + 2u) < size) {
n = (size - 2u - off < (uint32_t)sizeof(s_wave_buf))
? (size - 2u - off) : (uint32_t)sizeof(s_wave_buf);
fr = f_read(&f, s_wave_buf, n, &br);
if ((fr != FR_OK) || (br != n)) {
rc = UI_WAVE_BAD;
break;
}
crc = wave_store_crc_update(crc, s_wave_buf, n);
off += n;
}
/* 第四步:读回存储值比对(低字节在前) */
if ((rc == UI_WAVE_BAD) && (f_lseek(&f, size - 2u) == FR_OK) &&
(f_read(&f, tail, 2u, &br) == FR_OK) && (br == 2u)) {
if (crc == (uint16_t)(tail[0] | ((uint16_t)tail[1] << 8))) {
rc = UI_WAVE_OK;
}
}
} else if (total == 0u) {
rc = UI_WAVE_BAD; /* seg_size 返 0 = 头字段自相矛盾,判废 */
} else {
rc = UI_WAVE_BAD; /* 长度与头部字段不符,判废 */
}
(void)f_close(&f);
}
vol_lock_release();
if (rc == UI_WAVE_OK) {
s_wave_cached_idx = a_idx;
s_wave_cache_ok = 1u;
} else {
s_wave_cache_ok = 0u;
if (ofr != FR_OK) {
/* 表上明明列着这个段、开档却失败:页面只会显示「无录波段」,
* 没有任何视觉面——必须留痕(10-31 半轮栽就栽在这类静默面上) */
DBG_ERROR("ui wave head idx=%u open fail fr=%d", (unsigned)a_idx,
(int)ofr);
}
}
return rc;
}
/*
* 函数功能:录波触发页唯一写出口——投一次手动触发请求
* 入口参数:无
* 返回值:0 - 已受理;非零 - 拒收(未 IDLE/冷却中/参数),页面显示 ERR int
* 限定条件:uiTask(request 本身任意上下文可调,这里只是转发不等待)
* 函数说明:受理 = ring 进冻结窗,成段最快也在约一秒后由默认任务落盘;
* 顺手作废段表缓存,页面下次取数会看到新段
*/
static int ui_task_wave_manual_req(void) {
int r = wave_ring_request((uint8_t)WAVE_TRIG_MANUAL, 0u);
if (r == 0) {
s_wave_scanned = 0u;
s_wave_cache_ok = 0u; /* lead 裁定 2026-10-29 第 3 条:段表与段头缓存
* 一起作废——同名文件会被轮转重写,旧头字段
* (pts/pre/k/b/逐路 avail)不作废就会顶着新段
* 的号用旧尺子。单槽缓存本身 = 已知限制:
* 只挡「本段验过不再重验」,多段来回切会重验 */
}
return r;
}
/*
* 函数功能:取盘上「数值最大段号」(回放页开屏落点)
* 入口参数:a_idx - 出参段号
* 返回值:UI_WAVE_OK / NONE(无段或卷忙本轮)/ BAD 本件不产(表只含现存文件)
* 函数说明:口径 = 数值最大,**不是时间最新**(lead 裁定 2026-10-29):
* 回绕后数值序不等于时间序,页顶显示的段号即自证;真做时间序要
* 逐段开文件读 frame_seq/t0,一百次开档不在回放页的账上,本波不做
*/
static int ui_task_wave_seg_first(uint8_t *a_idx) {
if (ui_task_wave_table() != 0) {
return UI_WAVE_NONE; /* 卷忙 = 本轮无段(同一条不阻塞纪律) */
}
if (s_wave_cnt == 0u) {
return UI_WAVE_NONE;
}
*a_idx = s_wave_idx[s_wave_cnt - 1u];
return UI_WAVE_OK;
}
/*
* 函数功能:环游取邻段(a_dir:-1 更早 / +1 更新;段号数值序环回)
* 入口参数:a_cur - 当前段号;a_out - 出参邻段段号
* 返回值:UI_WAVE_OK / NONE(表不可用或已无邻段可去) int
* 限定条件:uiTask;当前段已被轮转覆盖时先重扫一次再定位
*/
static int ui_task_wave_seg_around(uint8_t a_cur, int8_t a_dir, uint8_t *a_out) {
uint8_t pos = 0u;
if (ui_task_wave_table() != 0) {
return UI_WAVE_NONE;
}
if (ui_task_wave_find(a_cur, &pos) == 0u) {
if ((ui_task_wave_scan() != 0) || (s_wave_cnt == 0u) ||
(ui_task_wave_find(a_cur, &pos) == 0u)) {
/* 当前段没了(翻转覆盖):退回首段,画面由调用侧重载 */
if (s_wave_cnt == 0u) {
return UI_WAVE_NONE;
}
*a_out = s_wave_idx[0u];
return UI_WAVE_OK;
}
}
if (s_wave_cnt == 1u) {
*a_out = s_wave_idx[0u]; /* 单段:原地(用户看到的仍是这段) */
return UI_WAVE_OK;
}
if (a_dir > 0) {
*a_out = s_wave_idx[(uint8_t)((pos + 1u) % s_wave_cnt)];
} else {
*a_out = s_wave_idx[(uint8_t)((pos + s_wave_cnt - 1u) % s_wave_cnt)];
}
return UI_WAVE_OK;
}
/*
* 函数功能:切到段号表里的前一段(LR 左键的落点,数值升序表 → 「前」= 号更小)
* 入口参数:a_cur 当前段号;a_out 输出切换后的段号(越界/空表不写)
* 返 回 值:UI_WAVE_OK 已给出;UI_WAVE_NONE 盘上一段都没有 int
* 限定条件:仅 uiTask 上下文(段号表是本任务私有缓存)
* 函数说明:只是 ui_task_wave_seg_around 的方向包装,存在的理由是 ops 表要两个
* 独立函数指针,不写第二份查找逻辑。
*/
static int ui_task_wave_seg_prev(uint8_t a_cur, uint8_t *a_out) {
return ui_task_wave_seg_around(a_cur, -1, a_out);
}
/*
* 函数功能:切到段号表里的后一段(LR 右键;数值升序表 → 「后」= 号更大)
* 入口参数:a_cur 当前段号;a_out 输出切换后的段号(越界/空表不写)
* 返 回 值:UI_WAVE_OK 已给出;UI_WAVE_NONE 盘上一段都没有 int
* 限定条件:仅 uiTask 上下文(同上)
* 函数说明:与 seg_prev 共用 ui_task_wave_seg_around,切段即触发一次懒加载;
* 未缓存段的读盘 + 流式 CRC 也发生在这条调用链上(单轮上界见本
* 文件「故障录波注入表执行侧」块首注释的 uiTask 阻塞上界段)。
*/
static int ui_task_wave_seg_next(uint8_t a_cur, uint8_t *a_out) {
return ui_task_wave_seg_around(a_cur, 1, a_out);
}
/*
* 函数功能:读段头(含双 CRC 完整性判定——判定发生在 ui_task_wave_load_head)
* 入口参数:a_idx - 段号;a_eng/a_trig/a_clip - 段头三字段原值
* 返回值:UI_WAVE_OK / NONE / BAD int
* 限定条件:uiTask;命中缓存零卷操作
*/
static int ui_task_wave_seg_head(uint8_t a_idx, uint8_t *a_eng,
uint8_t *a_trig, uint8_t *a_clip) {
int rc = ui_task_wave_load_head(a_idx);
if (rc != UI_WAVE_OK) {
return rc;
}
*a_eng = s_wave_meta.eng;
*a_trig = s_wave_meta.trig_src;
*a_clip = (uint8_t)(s_wave_meta.clip_mask & 0xFFu);
return UI_WAVE_OK;
}
/*
* 函数功能:读固定显示窗(触发点前后各一周波)的码值与峰值
* 入口参数:a_idx - 段号;a_ch - 通道号;a_codes - UI_WAVE_WIN_PTS 个码值
* (已并入段头 b_codes);a_peak_milli - 窗内绝对值最大点的 mV
* 返回值:UI_WAVE_OK / NONE(段头未验过/卷忙)/ BAD(数据区读不齐) int
* 限定条件:uiTask;先段头后窗口(未过完整性判定的段不供数据,页面显示
* 「该段不可读」而不是半截波形——判废优先于展示)
* 函数说明:窗口起点用段头 pre_pts 现算(§8.2「数据不是编译常量」:
* 起点 = pre_pts - 窗长一半,与光标居中是同一个 20 的一半,
* 改窗宽只改 ui_data.h 的 UI_WAVE_WIN_PTS 一处);峰值的 mV
* 换算逐点走 wave_store_code_to_milli —— 全仓唯一口径,
* 这里不重抄公式(§6.3;PC 道同一条款)
*/
static int ui_task_wave_seg_win(uint8_t a_idx, uint8_t a_ch,
int16_t *a_codes, int32_t *a_peak_milli) {
static FIL f;
char path[WAVE_ST_PATH_MAX];
FRESULT fr; /* 开档 FR 码:开档失败留痕用 */
UINT br = 0u;
uint32_t off;
uint32_t want;
uint32_t start;
uint32_t got_pts = 0u;
int16_t raw[UI_WAVE_WIN_PTS];
int32_t milli;
int rc;
if (ui_task_wave_load_head(a_idx) != UI_WAVE_OK) {
return UI_WAVE_BAD; /* 段头坏了/读不到:窗口无从谈起 */
}
if (a_ch >= s_wave_meta.ch_count) {
return UI_WAVE_BAD;
}
if ((s_wave_meta.pts_per_ch < UI_WAVE_WIN_PTS) ||
(s_wave_meta.pre_pts < (UI_WAVE_WIN_PTS / 2u))) {
return UI_WAVE_BAD;
}
start = (uint32_t)s_wave_meta.pre_pts - (UI_WAVE_WIN_PTS / 2u);
off = wave_store_data_off(s_wave_meta.ch_count, s_wave_meta.pts_per_ch,
a_ch, start);
if (off == 0xFFFFFFFFu) {
return UI_WAVE_BAD;
}
want = wave_store_data_bytes_avail(s_wave_meta.pts_per_ch, (uint16_t)start,
UI_WAVE_WIN_PTS);
if (want != UI_WAVE_WIN_PTS * 2u) {
return UI_WAVE_BAD; /* 窗口贴不出完整 40 点:显式不可读 */
}
if (wave_store_seg_path(path, (uint32_t)sizeof(path), a_idx) <= 0) {
/* 同 ui_task_wave_load_head:契约成功 = 16,失败是负码;!= 0 的旧写法
* 把成功当失败,窗口永远贴不出(2026-11-02 根因修复,详见那里注释) */
return UI_WAVE_NONE;
}
if (vol_lock_wait(0u) == 0u) {
return UI_WAVE_NONE;
}
rc = UI_WAVE_BAD;
fr = f_open(&f, path, FA_READ);
if (fr == FR_OK) {
if ((f_lseek(&f, off) == FR_OK) &&
(f_read(&f, s_wave_buf, want, &br) == FR_OK) && (br == want)) {
if ((wave_store_le_decode(s_wave_buf, want, raw, UI_WAVE_WIN_PTS,
&got_pts) == 0) &&
(got_pts == UI_WAVE_WIN_PTS)) {
rc = UI_WAVE_OK;
}
}
(void)f_close(&f);
}
vol_lock_release();
if (rc == UI_WAVE_OK) {
uint8_t i;
int32_t peak = 0;
for (i = 0u; i < UI_WAVE_WIN_PTS; i++) {
int32_t v;
v = (int32_t)raw[i] + s_wave_meta.ch[a_ch].b_codes;
if (v > 32767L) {
v = 32767L;
} else if (v < (-32768L)) {
v = -32768L;
}
a_codes[i] = (int16_t)v;
/* 显示与度量同一把尺(「谁被显示谁被度量」,§6.3):
* a_codes 画的是 raw+b,而 wave_store_code_to_milli 的公式是
* (a_code + b_codes) × k_q16 / 65536000 取整(App/algo/
* wave_store.c:567 实测口径)——传 raw 进去,b 已由唯一口径
* 自己加过,PK 数与屏上波形天然同尺,这里不再加第二遍
* (加两遍才是错);clamp 只护 int16 画布,b 的 V0.1 恒 0 */
if (wave_store_code_to_milli(&s_wave_meta.ch[a_ch], raw[i],
&milli) == 0) {
if ((milli < 0 ? -milli : milli) >
(peak < 0 ? -peak : peak)) {
peak = milli;
}
}
}
*a_peak_milli = peak;
} else if (fr != FR_OK) {
/* 理由同 load_head 的开档留痕:表列着的段开不出,页面无视觉面 */
DBG_ERROR("ui wave win idx=%u ch=%u open fail fr=%d", (unsigned)a_idx,
(unsigned)a_ch, (int)fr);
}
return rc;
}
/* 录波通道函数表(六件齐全;ui_data_wave_bind 拒绝残表,缺件就是装配错) */
static const ui_data_wave_ops_t s_wave_ops = {
ui_task_wave_manual_req,
ui_task_wave_seg_first,
ui_task_wave_seg_prev,
ui_task_wave_seg_next,
ui_task_wave_seg_head,
ui_task_wave_seg_win,
};
/*
* 函数功能:推流往返边界的心跳续命钩(F3,注册给 ui_net_sink)
* 入口参数:无
* 返回值:无
* 限定条件:uiTask 轮询上下文(sink 在往返返回处调用),只做置位、不阻塞
* 函数说明:为何经回调注入而非 sink 直调 task_wd_beat()——分层边界(BSP 不得
* include App 头,layer-deps 门禁);心跳属主仍是本任务。
*/
static void ui_task_sink_beat(void) {
task_wd_beat(TASK_WD_UI);
}
/*
* 函数功能:等待网络就绪事件
* 入口参数:无
* 返回值:无
* 限定条件:sys_evtHandle 为 NULL 时直接返回
* 函数说明:无限期等待 SYS_EVT_NET_READY 标志
*/
static void ui_task_wait_net_ready(void) {
if (sys_evtHandle != NULL) {
/* osFlagsNoClear:就绪位是状态,不能被本任务消费掉(否则其它等待者永不唤醒)。
* 2026-10-02 台架修复:等待改 100ms 分片 + 每片喂心跳(与 timeSync/netTask/
* ftpTask 入口同模式)——IWDG 必需掩码含 UI 位,原 osWaitForever 无心跳:
* IWDG 启动(存储就绪)时网络未必就绪 → 掩码 800ms 不可达 → 停喂 → 复位循环。
* 100ms 分片 = 抽样窗,纯任务睡眠零队列开销。 */
for (;;) {
uint32_t r = osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY,
osFlagsWaitAny | osFlagsNoClear, 100u);
if ((r & osFlagsError) == 0u) {
break; /* NET_READY 已置位(状态位,未被消费) */
}
task_wd_beat(TASK_WD_UI); /* 看门狗:分片喂心跳(屏障等待态) */
}
}
}
/*
* 函数功能:把屏板键号映射为 UI 键码
* 入口参数:board_key - 屏板帧 A 的 key 字节(1~10,语义见 docs/子系统设计/屏板链路设计说明.md §5)
* 返回值:UI_KEY_* 之一;0 = 不映射(无键 / RESET / FACTORY / 屏板无物理入口的 5、6)
* 限定条件:只在 uiTask 上下文调用(ui_port_queue_key 的契约是输入子系统已初始化)
* 函数说明:1. 屏板 key_configs[] 只有 **7 项**,可达值仅 1/2/3/4/7/8/9——别按屏板 readme
* 的 8 键表实现(5/6 = ADD/DEC 在实物上没有入口,永远收不到)。
* 2. 9(RESET)/10(FACTORY)语义待产品裁定:**先丢弃并留痕**,不要擅自
* 映射成参考机的 F1 / F1+ENT——否则现场按一下"复位"会把菜单弹到未知页面,
* 而且那种误映射极难从现象反推回映射表。
*/
static uint8_t ui_task_board_key_map(uint8_t board_key) {
switch (board_key) {
case 1u: return UI_KEY_ENTER;
case 2u: return UI_KEY_UP;
case 3u: return UI_KEY_DOWN;
case 4u: return UI_KEY_ESC;
case 7u: return UI_KEY_LEFT;
case 8u: return UI_KEY_RIGHT;
default: return 0u;
}
}
/* 组帧失败只留痕一次:组帧每 10 ms 一次,每拍打一行会把控制台冲爆(120 s 就 1.2 万行),
* 而 BSP_ERR_PARAM/BSP_ERR_STATE 是配置类错误,出现一次就是一直出现,一行足够定位。 */
static uint8_t s_sb_frame_err_logged;
/*
* 函数功能:UI 主任务入口,初始化显示/菜单/推流并进入刷新主循环
* 入口参数:argument - FreeRTOS 任务参数(未使用) void *
* 返回值:无
* 限定条件:FreeRTOS 已启动
* 函数说明:1. 等待 SYS_EVT_NET_READY 后依次初始化 ui_port/ui_menu/ui_net_sink
* 2. 主循环周期调用 ui_port_step 与 ui_net_sink_poll 刷新界面
*/
void ui_task_func(void *argument) {
(void)argument;
task_wd_bind(TASK_WD_UI); /* FTL 心跳路由:登记本任务句柄(入口一次) */
/* 等待网络就绪期间不可能喂狗:本任务此时尚未进入主循环,而就绪位由 netTask 置位。
* 该段若长期不成立,看门狗会复位——这是期望行为(网络 bring-up 卡死须暴露)。
* 注意本任务在 required_mask 内,主循环跑起来后须保证每 100ms 窗口内至少喂一次。 */
DBG_INFO("UI task: started, wait net ready");
ui_task_wait_net_ready();
DBG_INFO("UI task: net ready, init port");
DBG_INFO("UI task: ui_port_init enter");
if (ui_port_init() != 0) {
DBG_ERROR("UI port init: FAIL");
for (;;) { osDelay(1000); }
}
DBG_INFO("UI port init: OK");
/* 记录 reader 注入:必须在 ui_menu_init 之前(记录页在此前不可能被打开,
* 但契约把"启动期先绑定"写死;未绑定窗口组件按空态防御——见 ui_w_records.h) */
ui_w_records_bind(&s_rec_reader);
ui_w_soe_bind(&s_soe_reader); /* task-15:SOE 记录页数据入口(未绑定按"无记录"空态) */
/* 装置维护写通道注入(道F task-3:时钟设置 / 强制复归 / 清除记录):同样必须先于
* ui_menu_init。未注入的窗口里六个入口一律安全返回(页面显 ERR 或空态),
* 既不崩也不假成功;但残表是真故障,必须能在启动日志里看见,不能静默 */
if (ui_data_maint_bind(&s_maint_ops) != 0) {
DBG_ERROR("UI maint bind: FAIL");
}
/* 录波通道注入(故障录波道 UI 侧,2026-10-29):手动录波触发页与回放页
* 的唯一数据入口,同样先于 ui_menu_init。未注入窗口里六件按「无录波段」
* 空态渲染(不画线、不崩);残表 = 装配错,启动日志必须可见 */
if (ui_data_wave_bind(&s_wave_ops) != 0) {
DBG_ERROR("UI wave bind: FAIL");
}
/* 快照层复位:必须先于 ui_menu_init(首帧之前任何页面被打开都按"无数据"
* 渲染 "--",不会读到上电残留)——UI_数据供给设计说明.md §5.3 */
ui_data_init();
/* 首帧立即发布:待机屏/菜单屏在启动早期就可能被远端推流打开,等 200 ms
* 会闪一下 "--"(同文档 §5.3);本调用在 ui_menu_init 之前,页面尚未建立,
* 发布路径不触 LVGL,安全 */
ui_data_feed_tick();
DBG_INFO("UI task: ui_menu_init enter");
if (ui_menu_init() != 0) {
DBG_ERROR("UI menu init: FAIL");
for (;;) { osDelay(1000); }
}
DBG_INFO("UI menu init: OK");
if (ui_standby_init(UI_STANDBY_IDLE_MS, ui_task_time_cb) != 0) {
DBG_ERROR("UI standby init: FAIL");
} else {
DBG_INFO("UI standby: ready (idle=60s)");
}
if (ui_net_sink_start(UI_NET_SINK_PORT) != 0) {
DBG_ERROR("UI net sink: start FAIL");
} else {
DBG_INFO("UI net sink: started (port %u)", (unsigned)UI_NET_SINK_PORT);
}
/* F3:往返边界心跳钩(契约见 ui_net_sink.h;须在首轮 poll 之前注册)。
* start 失败也要注:失败路径的 close/重建重试同样经往返 */
ui_net_sink_set_beat_cb(ui_task_sink_beat);
#if defined(TEST_LATENCY_PROBE)
/* M3 探针 ui_loop:主循环实测周期(= LVGL 渲染 + 推流轮询 + osDelay(10))。
* 每轮开头先结算"上一轮到此轮"的间隔,再重置标记 → 得到真实循环周期。
* 单写者(本任务)/单读者(testTask),样本经 lat_probe_add 落静态缓冲。 */
lat_tmark_t tm_loop;
uint8_t tm_loop_valid = 0U;
#endif
/* 常开探针:TEST_LATENCY_PROBE 只在 CH395F 套件构建下打开,
* 且样本消费者是 testTask(产品构建里不跑)→ 产品构建拿不到任何 UI 周期数据。
* 故此处独立埋点,恒定生效、只写本模块的静态计数,由 defaultTask 周期上报。 */
while (1) {
/* 循环周期用 enter/exit 测:exit 结算"上轮起点 → 本轮起点",enter 记本轮起点。
* 首轮 exit 时尚未 enter 过 → 内部直接返回,不会拿 0 当起点。
* 选 enter/exit 而非"自己存一个 prev"的额外收益:两次循环之间段内标志保持为 1
* → 报告可读出"本轮已进行多久"。**这正是'uiTask 是否被饿住'的直接证据**:
* 若该值在两次报告间持续增长而 uic 不动,就是被调度饿死/阻塞,而非渲染拖长。 */
task_prof_exit(TASK_PROF_UI_PERIOD);
task_prof_enter(TASK_PROF_UI_PERIOD);
const uint32_t prof_t0 = task_prof_cyc(); /* 单轮工作耗时基准 */
#if defined(TEST_LATENCY_PROBE)
if (tm_loop_valid != 0U) {
lat_probe_add_seg("ui_loop", lat_mark_us(&tm_loop));
}
lat_mark(&tm_loop);
tm_loop_valid = 1U;
#endif
/* 数据装配节拍(200 ms 内部自分频,未到期时只比一次 tick):
* 排在渲染之前,本轮绘制的就是本轮刚发布的那一帧
* (UI_数据供给设计说明.md §5.1 单写者同上下文的前提就在这儿) */
ui_data_feed_tick();
ui_port_step(UI_TASK_TICK_MS);
/* ---- 屏板链路(批 2 + 2026-10-30 按键延迟整治):帧 B 组帧 + 按键上行,全在任务上下文 ----
* 本段顺序是**先注键、后组帧**(原先反过来):按键召唤由 sb_link 扣住不发
* (延迟应答,判据见 sb_link.h 的 SB_KEY_RESP_MAX_MS),这一拍必须完成
* "出键→即派即绘→组帧",sb_link_ms_tick() 在 ≤1 ms 内放行的那一答带的才是
* **按键之后**的新内容;旧顺序下按键召唤答的必然是按键前的旧帧,新内容
* 要等下一次召唤才带得出(平均 ~140 ms、最坏 280 ms,账本见
* docs/子系统设计/屏板链路设计说明.md §7.1)。
* "本轮画出来的就是本轮组进去的那一帧"的承诺因此覆盖到按键。
* 排在推流往返**之前**不变:那个往返最坏能等 ≈4 s(LCDMEM 应答 3204 B),
* 排在它后面会让屏板画面跟着网络一起卡(同一份显存、两个出口,出口不能互相拖)。
* 显存直接取活帧缓冲:不留 3200 B 影子副本,sb_link 内部与帧里的点阵段 memcmp。
* LED 掩码(批 3):每拍现采四路状态源经 sb_led_map_eval 折算——四个源
* 都是"任意上下文可读"的观测口(不取锁不阻塞,见各自头文件契约),
* 折算本身纯计算;心跳灯相位跟着这一拍的 tick 走(uiTask 10 ms 节拍
* 远高于 500 ms 半周期,闪烁边沿误差 ≤1 拍)。 */
{
sb_led_in_t led_in;
uint32_t led_mask;
bsp_err_t sb_ret;
uint8_t board_key;
uint8_t key_injected = 0u;
led_in.prot_active = prot_elm_active_mask();
led_in.prot_pick = prot_elm_pick_mask();
led_in.adc_flags = adc_health_flags();
led_in.iec104_run = iec104_serv_link_run();
led_mask = sb_led_map_eval(&led_in, osKernelGetTickCount());
/* 逐键必到:一次取一个,直到队列空。9/10 不映射(决策四待定),丢弃并留痕。
* 走 ui_port_queue_key 而不是 ui_net_sink_inject_key:后者只是 7003 后端
* 对同一个函数的转发(见 ui_net_sink_backend_mcu.c),多绕一层还得让
* App 去 include 推流后端头,没必要。两条入口最终进的是同一个输入队列。 */
while ((board_key = sb_link_key_take()) != 0u) {
uint8_t ui_key = ui_task_board_key_map(board_key);
if (ui_key != 0u) {
ui_port_queue_key(ui_key);
key_injected = 1u;
} else {
DBG_INFO("board key %u unmapped (RESET/FACTORY pending decision)",
(unsigned)board_key);
}
}
/* 有键的这一拍:立刻派发 + 立刻重绘(省 indev 轮询 ≤20 ms +
* 刷新定时器 ≤20 ms 两段干等),随后组帧才能把新画面装进本拍的答案。
* 没键的拍 service_keys 是空操作,一拍不多花。 */
if (key_injected != 0u) {
ui_port_service_keys();
}
sb_ret = sb_link_frame_update(ui_port_get_framebuffer(), led_mask);
/* BSP_ERR_BUSY 是常态(上一帧还在 59.6 ms 的发送窗口里),不必留痕;
* 其余失败打一行即可(见 s_sb_frame_err_logged 的说明)。 */
if ((sb_ret != BSP_OK) && (sb_ret != BSP_ERR_BUSY) &&
(s_sb_frame_err_logged == 0u)) {
s_sb_frame_err_logged = 1u;
DBG_ERROR("screenlink frame update ret=%d", sb_ret);
}
/* 给 sb_link 交"键已画进画面、且帧内容与活显存一致"的凭据
* (延迟应答的放行钥匙):**只在组帧返回 BSP_OK 时交**——BUSY 意味着这一拍没能
* 把新画面组进帧缓冲,凭据必须留给组成功的那一拍;扣住的召唤自会
* 等下一个 OK 拍或按 140 ms 上限发旧帧保底。 */
if ((key_injected != 0u) && (sb_ret == BSP_OK)) {
sb_link_key_rendered();
}
}
/* 心跳**先于**推流往返发出(判读口径 docs/子系统设计/看门狗设计说明.md §11.3):UI 心跳测的是
* "UI 自身被调度到了",而非"netTask 回执快慢"。原写法心跳在 sink 之后,
* 致实测 uiw≈uis≈50ms(心跳被 netTask 往返主导)。提到往返之前后,
* 即使往返偶发变慢,心跳也不被其拖垮。
* ⚠ 若往返**永久**阻塞(xTaskNotifyWait portMAX_DELAY 不返回),本改只能发
* 一次心跳,救不了 → 彻底解耦须走事件驱动 accept,见文件头修改记录 v1.3。
* v1.4 补充:sink 内每个往返**返回点**已各自补喂(set_beat_cb 注入),
* 「单轮多个慢往返之和」不再构成缺席链;剩单往返 >800ms 才报——那是
* netTask 自身停摆,按位判据抓对了人(上界归「net 层跨上下文收口」)。 */
task_wd_beat(TASK_WD_UI);
/* 推流轮询段单独 enter/exit:它内部经 net_accept → net_wait_result 走到
* xTaskNotifyWait(portMAX_DELAY) 的**无限期阻塞**。若 uiTask 永久卡在这里,
* 样本永不结算,只有 task_prof 的"仍在段内已 X us"能证明它卡在何处 */
task_prof_enter(TASK_PROF_UI_SINK);
ui_net_sink_poll();
task_prof_exit(TASK_PROF_UI_SINK);
/* 屏板链路统计上报与离线判定(内部自分频,5 s 一次、只在计数有变化时打一行)。
* 放在推流段之后:它只是读一份快照 + 偶尔一行日志,不进推流往返的耗时统计。
* 组帧与注键在渲染之后就做完了(见上),这里只剩"看一眼"——离线判定要求每拍都看,
* 所以它不跟着 5 s 的上报周期走。 */
sb_link_poll(osKernelGetTickCount());
/* 单轮工作耗时(不含 osDelay):与循环周期对比即得"调度延迟" */
task_prof_sample(TASK_PROF_UI_WORK, task_prof_cyc() - prof_t0);
osDelay(UI_TASK_TICK_MS);
}
}