diff --git a/App/log/README.md b/App/log/README.md index a456f949..80ea9e78 100644 --- a/App/log/README.md +++ b/App/log/README.md @@ -10,6 +10,8 @@ | `sys_log.h` | **日志接口(平台无关)**:定长 64B 记录写入 `/log/run.log`·`selftest.log` | | `sys_log_mcu.c` | `sys_log` 的 MCU 端后端实现(NAND + FatFs) | | `console_log.c/h` | **控制台单写者日志**:多任务日志经队列汇入唯一写者,避免串口输出交错 | +| `soe_rec.c/h` | **SOE 记录纯编解码**(B9):64B 定长记录(CP56Time2a 时标 + 类型 + 元件号 + 单调序号 + 值 + sum8 撕裂校验),零 I/O 零 OS 依赖,主机单测覆盖 | +| `soe_store.c/h` | **SOE 落盘胶水**(B9):`/log/soe.log`,protectTask 投递(零文件系统)→ defaultTask 排空落盘 | ## 关键机制 @@ -27,6 +29,27 @@ - 多任务的生产者 → **单写者消费者**,消除任务间日志行交错 - 设计详见 `docs/子系统设计/控制台单写者日志设计说明.md` +### soe(SOE 事件顺序记录,B9) + +- **记录 = 64 B 定长二进制**(`soe_rec.h` 布局),落 `/log/soe.log`; + 与 run.log 分文件——run.log 是文本记录、有 UI/主机读取方,二进制 SOE + 混入会破坏读取器;分文件保持"定长 64B + 条数=fsize/64 + 满删重建 + + FTP 下载 + 主机解码"同一套先例 +- **生产者 = protectTask**(元件动作/返回、软压板投退、采样闭锁/恢复六类 + 边沿):`soe_store_post()` 纯 RAM(取时标 + 组记录 + 队列/待发环), + **零 NAND I/O**;⚠️ **post 内禁 `DBG_*`**——会把 protectTask 静态最坏链 + 垫高一帧(实证 872→1192 B FAIL),丢弃留痕由 drain(defaultTask)差值上报 +- **唯一写者 = defaultTask**:挂 `syslog_drain()` 尾部(继承其卷就绪门), + 50 ms 节拍 → SOE 落盘延迟 ≤50 ms;写盘纪律全套照 syslog(卷锁 try / + `FA_WRITE|FA_OPEN_APPEND` / 校验字节数 / 满删重建 / 失败留痕去重), + **绝不把名字/参数类错误码归入卷损坏触发集** +- **时标口径**:sys_clock 秒域时基 → CP56 ms 域 = 秒×1000(亚秒恒 0, + 不造假);墙钟未建 → IV 位如实置位;seq 仅上电生命周期内单调 + (容量重建不重置,仅复位重置——主机解码按 boot 段分段断言) +- 主机侧:`test/host/soe/decode_soe.py` 解码 + 断言(校验和/seq 单调/ + CP56↔epoch UTC 交叉);⚠️ 交叉校验用 `calendar.timegm`,不是本地时区的 + `dt.timestamp()`(+08 机差 28800 s,首跑实证坑) + ## 相关文档 - `docs/子系统设计/控制台单写者日志设计说明.md` diff --git a/App/log/soe_rec.c b/App/log/soe_rec.c new file mode 100644 index 00000000..687bfa1d --- /dev/null +++ b/App/log/soe_rec.c @@ -0,0 +1,160 @@ +/* + * 模块名称:SOE Record — 事件顺序记录编解码(实现) + * 模块功能:实现 soe_rec.h——64B 定长记录的构建(epoch→CP56Time2a,Hinnant + * civil 日历算法,与 iec104_asdu.c 同一算法族但本模块零依赖)与 + * 解析(版本 + sum8 校验和硬门槛)。 + * 线程约定:全部纯函数,无状态 → 任意上下文可调(生产者 = protectTask, + * 落盘/读取 = defaultTask / 主机工具)。 + * 创建日期:2026-10-12(B9) + */ +#include "soe_rec.h" + +#include + +/* 2000-01-01 00:00:00 UTC 的 epoch 秒(CP56 年字段下限,钳位用) */ +#define EPOCH_2000_S 946684800uL +/* 2127-12-31 23:59:59 UTC(年字段 7bit 上限 2000+127,钳位用; + * = 2128-01-01(4986057600) - 1,含 2000..2124 共 32 个闰日) */ +#define EPOCH_2127_END_S 4986057599uLL + +/* + * 函数功能:epoch 秒 → 小端 32 位写入 + */ +static void s_soe_put_u32(uint8_t *buf, uint32_t v) { + buf[0] = (uint8_t)(v & 0xFFu); + buf[1] = (uint8_t)((v >> 8) & 0xFFu); + buf[2] = (uint8_t)((v >> 16) & 0xFFu); + buf[3] = (uint8_t)((v >> 24) & 0xFFu); +} + +/* + * 函数功能:小端 32 位读取 + */ +static uint32_t s_soe_get_u32(const uint8_t *buf) { + return (uint32_t)buf[0] | ((uint32_t)buf[1] << 8) + | ((uint32_t)buf[2] << 16) | ((uint32_t)buf[3] << 24); +} + +/* + * 函数功能:距 1970-01-01 天数 → 公历(Hinnant civil_from_days,与 + * iec104_asdu.c 同一算法;纯整数,ARMCC/MSVC 双端可移植) + * 入口参数:z_in = 天数;y/m/d = 输出 + */ +static void s_soe_civil_from_days(uint32_t z_in, uint32_t *y, uint32_t *m, uint32_t *d) { + uint32_t z = z_in + 719468u; + uint32_t era = z / 146097u; + uint32_t doe = z - (era * 146097u); + uint32_t yoe = (doe - (doe / 1460u) + (doe / 36524u) - (doe / 146096u)) / 365u; + uint32_t yy = yoe + (era * 400u); + uint32_t doy = doe - ((365u * yoe) + (yoe / 4u) - (yoe / 100u)); + uint32_t mp = ((5u * doy) + 2u) / 153u; + uint32_t mon = mp + ((mp < 10u) ? 3u : 0u); + + *d = doy - (((153u * mp) + 2u) / 5u) + 1u; + if (mp >= 10u) { + mon = mp - 9u; + } + *m = mon; + *y = yy + ((mon <= 2u) ? 1u : 0u); +} + +/* + * 函数功能:epoch 秒 → CP56Time2a(7B,UTC 分解) + * 入口参数:epoch_s - Unix 秒;buf - 目标 7B;iv - 1 = 置 IV 位(时间无效) + * 返回值:无 + * 函数说明:钳位 [2000-01-01, 2127-12-31](CP56 年字段 7bit 域;epoch_s 为 + * uint32 → 上界钳位是防御性死分支,2106 后域扩展时生效); + * ms 域 = 秒×1000(秒域时基如实编码,亚秒恒 0,不造假)。 + */ +static void s_soe_epoch_to_cp56(uint32_t epoch_s, uint8_t *buf, uint8_t iv) { + uint64_t clamped; + uint32_t days, rem, y, m, d, ms_min; + + clamped = epoch_s; + if (clamped < (uint64_t)EPOCH_2000_S) { + clamped = (uint64_t)EPOCH_2000_S; + } + if (clamped > EPOCH_2127_END_S) { + clamped = EPOCH_2127_END_S; + } + days = (uint32_t)(clamped / 86400uLL); + rem = (uint32_t)(clamped % 86400uLL); + s_soe_civil_from_days(days, &y, &m, &d); + if (y > 2127u) { /* 钳位上界的日历化(防 epoch 上界跨日) */ + y = 2127u; + m = 12u; + d = 31u; + rem = 86399u; + } + ms_min = (rem % 60u) * 1000u; /* 秒域时基:秒如实入 ms 域(×1000), + * 亚秒部分恒 0——不造假(B9 口径) */ + buf[0] = (uint8_t)(ms_min & 0xFFu); + buf[1] = (uint8_t)(ms_min >> 8); + buf[2] = (uint8_t)(((rem / 60u) % 60u) & 0x3Fu); /* min;bit7 IV 下方置 */ + buf[3] = (uint8_t)((rem / 3600u) % 24u); /* hour */ + buf[4] = (uint8_t)(d & 0x1Fu); /* day */ + buf[5] = (uint8_t)(m & 0x0Fu); /* month */ + buf[6] = (uint8_t)((y - 2000u) & 0x7Fu); /* year */ + if (iv != 0u) { + buf[2] |= 0x80u; /* IV:时间无效 */ + } +} + +/* + * 函数功能:sum8([0..62]) 校验和 + */ +static uint8_t s_soe_sum8(const uint8_t *rec) { + uint8_t s = 0u; + uint32_t i; + + for (i = 0u; i < SOE_OFF_SUM; i++) { + s = (uint8_t)(s + rec[i]); + } + return s; +} + +/* + * 函数功能:构建一条 64B SOE 记录(见 soe_rec.h 声明注释) + */ +void soe_rec_build(uint8_t *rec, uint8_t type, uint8_t elm_id, uint8_t flags, + uint32_t seq, uint32_t epoch_s, uint32_t value) { + if (rec == NULL) { + return; + } + (void)memset(rec, 0, SOE_REC_SIZE); + rec[SOE_OFF_VER] = (uint8_t)SOE_REC_VER; + rec[SOE_OFF_TYPE] = type; + rec[SOE_OFF_ELM] = elm_id; + rec[SOE_OFF_FLAGS] = flags; + s_soe_put_u32(rec + SOE_OFF_SEQ, seq); + s_soe_epoch_to_cp56(epoch_s, rec + SOE_OFF_CP56, + ((flags & SOE_FLG_IV) != 0u) ? 1u : 0u); + s_soe_put_u32(rec + SOE_OFF_EPOCH, + ((flags & SOE_FLG_IV) != 0u) ? 0u : epoch_s); + s_soe_put_u32(rec + SOE_OFF_VALUE, value); + rec[SOE_OFF_SUM] = s_soe_sum8(rec); +} + +/* + * 函数功能:解析一条 64B SOE 记录(见 soe_rec.h 声明注释) + */ +int soe_rec_parse(const uint8_t *rec, soe_rec_t *out) { + if ((rec == NULL) || (out == NULL)) { + return -1; + } + if (rec[SOE_OFF_VER] != (uint8_t)SOE_REC_VER) { + return -2; + } + if (rec[SOE_OFF_SUM] != s_soe_sum8(rec)) { + return -3; + } + out->ver = rec[SOE_OFF_VER]; + out->type = rec[SOE_OFF_TYPE]; + out->elm_id = rec[SOE_OFF_ELM]; + out->flags = rec[SOE_OFF_FLAGS]; + out->seq = s_soe_get_u32(rec + SOE_OFF_SEQ); + (void)memcpy(out->cp56, rec + SOE_OFF_CP56, sizeof(out->cp56)); + out->epoch_s = s_soe_get_u32(rec + SOE_OFF_EPOCH); + out->value = s_soe_get_u32(rec + SOE_OFF_VALUE); + return 0; +} diff --git a/App/log/soe_rec.h b/App/log/soe_rec.h new file mode 100644 index 00000000..430de0c8 --- /dev/null +++ b/App/log/soe_rec.h @@ -0,0 +1,119 @@ +/* + * 模块名称:SOE Record — 事件顺序记录编解码(纯逻辑,双端可测) + * 模块功能:定长 64B SOE(Sequence-of-Events)记录的构建与解析: + * CP56Time2a 时标 + 记录类型 + 元件号 + 单调序号 + 值 + 校验和。 + * **零 I/O、零 OS 依赖**(仅 stdint/string)→ 主机侧 L0.5 离线单测 + * (落实登记簿 A-4 约束 1"协议/记录与硬件 I/O 分离")。 + * 记录口径(docs/算法/继电保护功能复现规划.md §3.4 L4-4/L4-5,B9): + * 落盘文件 = /log/soe.log(8.3 合规;FF_USE_LFN=0 约束),定长 64B 一条 + * (照 run.log 先例:count = filesize/64 O(1)、容量满删除重建)。 + * 独立于 run.log 的理由:run.log 是文本记录(时间戳+epoch+UTF-8 描述, + * syslog_get_record/UI 按文本读),SOE 是二进制结构化记录(时标/类型/ + * 序号/值),混入同一文件会破坏现有文本读者。 + * 记录布局(64B,多字节字段一律小端): + * [0] 版本 SOE_REC_VER + * [1] 类型 soe_type_t + * [2] 元件号(prot_elm_id_t;SOE_ELM_NONE=0xFF = 非元件事件) + * [3] 标志位(bit7 IV = 记录时时钟无效,CP56 IV 位同步置位) + * [4..7] 序号 seq(uint32 小端;上电内单调,跨复位重启计数——见 soe_store.h) + * [8..14] CP56Time2a(7B,IEC 60870-5-4 §5.1.6;秒域时基 → ms 字段恒 0) + * [15..18] epoch 秒(uint32 小端;IV 时置 0,主机解码交叉校验用) + * [19..22] 值 value(uint32 小端;语义随类型,见 soe_type_t 注释) + * [23..62] 保留(恒 0) + * [63] 校验和 = sum8([0..62])(撕裂/半写检测,解析侧拒绝不匹配) + * 创建日期:2026-10-12(B9) + */ +#ifndef SOE_REC_H +#define SOE_REC_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* 记录尺寸与版本(定长;改布局必须升 SOE_REC_VER) */ +#define SOE_REC_SIZE 64u +#define SOE_REC_VER 1u + +/* 字段偏移(解析/主机解码工具共用口径) */ +#define SOE_OFF_VER 0u +#define SOE_OFF_TYPE 1u +#define SOE_OFF_ELM 2u +#define SOE_OFF_FLAGS 3u +#define SOE_OFF_SEQ 4u /* 4B */ +#define SOE_OFF_CP56 8u /* 7B */ +#define SOE_OFF_EPOCH 15u /* 4B */ +#define SOE_OFF_VALUE 19u /* 4B */ +#define SOE_OFF_SUM 63u + +/* 标志位 */ +#define SOE_FLG_IV 0x80u /* bit7:记录时时钟无效(CP56 IV 位同步置位) */ + +/* 非元件事件的元件号 */ +#define SOE_ELM_NONE 0xFFu + +/* + * 记录类型(值语义随类型): + * 1 元件动作 value = 元件状态打包(bit0 asserted / bit1 picked / bit2 enabled) + * 2 元件返回 value = 同上(返回瞬间的旧状态快照) + * 3 软压板退出 value = 0 + * 4 软压板投入 value = 1 + * 5 采样闭锁 value = adc_health 闭锁位图(触发时的 hf & mask) + * 6 采样恢复 value = 0 + */ +typedef enum { + SOE_T_ELM_ASSERT = 1u, + SOE_T_ELM_RETURN, + SOE_T_PLATE_OUT, + SOE_T_PLATE_IN, + SOE_T_SAMPLE_BLOCK, + SOE_T_SAMPLE_RESUME, + SOE_T_COUNT /* 哨兵值,非有效类型 */ +} soe_type_t; + +/* 解析产物(字段原样拷贝;CP56 不在此层换算,主机工具解码) */ +typedef struct { + uint8_t ver; + uint8_t type; + uint8_t elm_id; + uint8_t flags; + uint32_t seq; + uint8_t cp56[7]; + uint32_t epoch_s; + uint32_t value; +} soe_rec_t; + +/* + * 函数功能:构建一条 64B SOE 记录(纯计算,无 I/O;任意上下文可调) + * 入口参数:rec - 输出缓冲(SOE_REC_SIZE 字节,不可为 NULL) uint8_t * + * type - 记录类型 soe_type_t(越界按 SOE_T_COUNT 处理为保留 0) + * elm_id - 元件号(非元件事件传 SOE_ELM_NONE) uint8_t + * flags - 标志位(SOE_FLG_IV = 时钟无效) uint8_t + * seq - 单调序号 uint32_t + * epoch_s - 墙钟 epoch 秒(IV 时传 0;同时 flags 须带 SOE_FLG_IV) + * value - 类型相关值 uint32_t + * 返回值:无 + * 函数说明:epoch_s → CP56Time2a(UTC 分解,Hinnant civil 日历算法,与 + * iec104_asdu.c 同一算法族;钳位 [2000-01-01, 2127-12-31], + * ms 字段恒 0——sys_clock 秒域时基,如实登记)。 + * flags 带 IV 时 CP56 byte2 bit7 置位(标准"时间无效"语义)。 + */ +void soe_rec_build(uint8_t *rec, uint8_t type, uint8_t elm_id, uint8_t flags, + uint32_t seq, uint32_t epoch_s, uint32_t value); + +/* + * 函数功能:解析一条 64B SOE 记录(校验版本 + 校验和) + * 入口参数:rec - 原始记录(SOE_REC_SIZE 字节,不可为 NULL) const uint8_t * + * out - 解析输出(不可为 NULL) soe_rec_t * + * 返回值:0 成功 / -1 参数错 / -2 版本不符 / -3 校验和不符(撕裂/半写) int + * 函数说明:只读,不改动 rec。类型/标志不做强校验(前向兼容:新版本类型 + * 旧解析器可读),版本与校验和是硬门槛。 + */ +int soe_rec_parse(const uint8_t *rec, soe_rec_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* SOE_REC_H */ diff --git a/App/log/soe_store.c b/App/log/soe_store.c new file mode 100644 index 00000000..719b0b64 --- /dev/null +++ b/App/log/soe_store.c @@ -0,0 +1,210 @@ +/* + * 模块名称:SOE Store — 事件顺序记录落盘(实现) + * 模块功能:实现 soe_store.h——protectTask 投递(取时标 + soe_rec_build + + * SPSC 待发环 / 消息队列,零 FS);defaultTask(syslog_drain 尾部) + * 排空落 /log/soe.log(64B 定长,容量满删除重建)。 + * 创建日期:2026-10-12(B9) + */ + +/* 头文件包含区 */ +#include + +#define DBG_TAG "[SOE]" +#include "dbg_log.h" + +#include "soe_store.h" +#include "soe_rec.h" +#include "ff.h" +#include "cmsis_os.h" +#include "sys_clock.h" +#include "vol_lock.h" + +/* 静态变量定义区 */ + +#define SOE_PATH "/log/soe.log" +#define SOE_QUEUE_MAX 8u +#define SOE_RING_MAX 8u /* 待发环容量(须为 2 的幂) */ +#define SOE_RING_MASK (SOE_RING_MAX - 1u) + +/* 待发环(SPSC 无锁):队列惰性创建(defaultTask 首次 drain)之前的窗口 + * 兜底。生产者 = protectTask(只写 head 侧),消费者 = defaultTask(只写 + * tail 侧)——单核 Cortex-M + volatile 下按 console_log 环形缓冲同一模型, + * 免锁安全。条目 = 完整 64B 记录(post 侧已组好,drain 侧原样落盘)。 */ +static uint8_t s_ring[SOE_RING_MAX][SOE_REC_SIZE]; +static volatile uint8_t s_ring_head; /* 生产者写位 */ +static volatile uint8_t s_ring_tail; /* 消费者读位 */ + +static osMessageQueueId_t s_queue; /* 运行期队列(drain 首次调用惰性创建) */ +static const osMessageQueueAttr_t s_queue_attr = { .name = "soe" }; + +static volatile uint32_t s_seq; /* 单调序号(RAM 生命周期内;仅复位重置) */ +static volatile uint32_t s_drop_cnt; /* 丢弃计数(队列满/环满) */ +static uint32_t s_drop_logged; /* 丢弃留痕水位(drain 侧差值上报) */ +static uint8_t s_fault_reported; /* 写故障留痕去重(成功写复位) */ + +/* 函数定义区 */ + +/* + * 函数功能:投递一条 SOE 记录(见 soe_store.h 声明注释) + * 函数说明:时标 = sys_clock_get()(纯 RAM,无 I/O);UINT32_MAX(墙钟未建) + * → flags 置 IV、epoch 字段 0、CP56 IV 位如实置位(B9 口径: + * 不造假时标)。 + * ⚠ 本函数**不得调用 DBG_***:protectTask 静态最坏链 = 经 + * s_log_active_edge 的 console 尾链(872 B,B5 实测),post 内任何 + * 日志调用点都会把该尾链再垫高一帧(stack_report 实证 +320 B → + * FAIL)。丢弃只累加计数,留痕由 drain(defaultTask)差值上报。 + */ +void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value) { + uint8_t rec[SOE_REC_SIZE]; /* 64B:protectTask 栈(链对比见 B9) */ + uint32_t epoch = sys_clock_get(); + uint8_t flags = 0u; + uint32_t seq; + + if (epoch == UINT32_MAX) { + epoch = 0u; + flags = SOE_FLG_IV; + } + seq = s_seq; + s_seq = seq + 1u; + soe_rec_build(rec, type, elm_id, flags, seq, epoch, value); + + if ((s_queue != NULL) && (osKernelGetState() == osKernelRunning)) { + if (osMessageQueuePut(s_queue, rec, 0u, 0u) == osOK) { + return; + } + } else { + uint8_t head; /* 队列未建窗口(内核启动 → defaultTask + * 首次 drain):SPSC 待发环 */ + head = s_ring_head; + if ((uint8_t)(head - s_ring_tail) < SOE_RING_MAX) { + (void)memcpy(s_ring[head & SOE_RING_MASK], rec, SOE_REC_SIZE); + s_ring_head = (uint8_t)(head + 1u); + return; + } + } + /* 队列满 / 环满:丢弃 + 计数(留痕在 drain 侧,见函数头警告) */ + if (s_drop_cnt < UINT32_MAX) { + s_drop_cnt++; + } +} + +/* + * 函数功能:追加一条 64B SOE 记录到 /log/soe.log + * 入口参数:rec - 完整记录(SOE_REC_SIZE 字节) const uint8_t * + * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障) int + * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载、/log 已建 + * 函数说明:照 sys_log_mcu.c s_append 的台架实证纪律—— + * ① vol_lock try(本任务 = 看门狗喂狗点,绝不阻塞等锁); + * ② FA_WRITE|FA_OPEN_APPEND(本移植 OPEN_APPEND 不含 WRITE, + * 缺写位 f_write 恒 FR_DENIED,2026-09-20 实证); + * ③ 判失败必须校验写回字节数(FR_OK+短写 = 失败,簇分配无错误码); + * ④ 容量满 unlink 重建;打开/写入结构性错误 → unlink 重试一次 + * (L1 文件级自愈,同 run.log)。 + * ⚠ 本模块**没有**任何"卷损坏"归类/重格式化路径:unlink 失败、 + * 名字/参数类错误码一律只留痕(存储架构 §10.1 教训),条目丢弃 + * 计数,绝不升级成卷操作。 + */ +static int s_soe_append(const uint8_t *rec) { + static FIL f; /* static:FF_FS_TINY=0 → FIL=528B 栈; + * 全程在 vol_lock 区间内,跨任务由卷锁串行化 */ + FRESULT fr; + UINT bw = 0u; + FILINFO finfo; + uint32_t cnt; + int rc = 0; + + if (vol_lock_wait(0u) == 0u) { + return -2; + } + /* 容量护栏:满 → 删除重建(seq 不重置:RAM 计数继续,主机按 boot 段看) */ + cnt = (f_stat(SOE_PATH, &finfo) == FR_OK) + ? ((uint32_t)finfo.fsize / SOE_REC_SIZE) : 0u; + if (cnt >= SOE_STORE_MAX) { + (void)f_unlink(SOE_PATH); + DBG_INFO("cap full, recreate " SOE_PATH); + } + + fr = f_open(&f, SOE_PATH, FA_WRITE | FA_OPEN_APPEND); + if (fr == FR_OK) { + fr = f_write(&f, rec, SOE_REC_SIZE, &bw); + (void)f_close(&f); + if ((fr != FR_OK) || (bw != SOE_REC_SIZE)) { + /* L1 文件级自愈:陈旧/损坏条目 → 删除重建、重试一次(同 run.log; + * unlink 返回值刻意不检查——失败也只是重试失败 → 留痕,不升级) */ + (void)f_unlink(SOE_PATH); + bw = 0u; + fr = f_open(&f, SOE_PATH, FA_WRITE | FA_OPEN_APPEND); + if (fr == FR_OK) { + fr = f_write(&f, rec, SOE_REC_SIZE, &bw); + (void)f_close(&f); + } + if ((fr != FR_OK) || (bw != SOE_REC_SIZE)) { + rc = -1; + } + } + } else { + rc = -1; + } + if (rc == 0) { + s_fault_reported = 0u; + } else if (s_fault_reported == 0u) { + s_fault_reported = 1u; + DBG_ERROR("write " SOE_PATH " fail (%d)", (int)fr); + } + vol_lock_release(); + return rc; +} + +/* + * 函数功能:排空 SOE 待发(待发环 + 队列,FIFO)并落盘 + * 入口参数:无 + * 返回值:无 + * 限定条件:仅 defaultTask 调用(syslog_drain 尾部,卷就绪门之后); + * 首次调用惰性创建消息队列(内核对象创建在 osKernelStart 之后) + */ +void soe_store_drain(void) { + uint8_t rec[SOE_REC_SIZE]; + + /* 丢弃留痕(defaultTask 上下文 = 日志安全区):post 侧禁 DBG(protect 链), + * 此处按差值上报一次,不刷屏 */ + if (s_drop_cnt != s_drop_logged) { + s_drop_logged = s_drop_cnt; + DBG_ERROR("soe dropped total=%lu (queue/ring full)", + (unsigned long)s_drop_cnt); + } + if (s_queue == NULL) { + s_queue = osMessageQueueNew(SOE_QUEUE_MAX, SOE_REC_SIZE, &s_queue_attr); + if (s_queue == NULL) { + DBG_ERROR("queue create fail"); /* 条目滞留待发环,下轮再试 */ + } + } + /* 待发环优先(保时序:环内条目早于队列条目) */ + while (s_ring_tail != s_ring_head) { + uint8_t tail = s_ring_tail; + (void)memcpy(rec, s_ring[tail & SOE_RING_MASK], SOE_REC_SIZE); + if (s_soe_append(rec) != 0) { + return; /* 写失败/卷忙:不推进 tail,下轮重试 */ + } + s_ring_tail = (uint8_t)(tail + 1u); + } + if (s_queue == NULL) { + return; + } + for (;;) { + if (osMessageQueueGet(s_queue, rec, 0u, 0u) != osOK) { + break; /* 队列空 */ + } + if (s_soe_append(rec) == -2) { + break; /* 卷被占用:本轮顺延(条目已出队,尽力而为) */ + } + /* rc==-1 写失败:s_soe_append 已留痕;继续排空(SOE 尽力而为语义, + * 单条坏记录不卡死后续事件) */ + } +} + +/* + * 函数功能:累计丢弃计数(见 soe_store.h 声明注释) + */ +uint32_t soe_store_dropped(void) { + return s_drop_cnt; +} diff --git a/App/log/soe_store.h b/App/log/soe_store.h new file mode 100644 index 00000000..48f10fdd --- /dev/null +++ b/App/log/soe_store.h @@ -0,0 +1,66 @@ +/* + * 模块名称:SOE Store — 事件顺序记录落盘(MCU 胶水,单写者) + * 模块功能:protectTask 动作/复归边沿 → soe_store_post() 投递(纯 RAM: + * sys_clock 取时 + soe_rec_build + 消息队列,**零文件系统**); + * defaultTask(/log 唯一写者,经 syslog_drain 尾部挂接)消费落盘 + * /log/soe.log(定长 64B,容量满删除重建,照 run.log 先例)。 + * 记录格式/类型:见 soe_rec.h(纯编解码在 soe_rec.c,主机单测覆盖)。 + * 线程约定:post = protectTask(唯一生产者;内核启动后任意任务上下文亦可, + * 队列未建时走静态待发数组,照 syslog_post 先例); + * drain = 仅 defaultTask(syslog_drain 尾部调用,继承其卷就绪门)。 + * seq 口径:RAM 计数器,上电内单调;容量满 unlink 重建**不重置**(计数继续), + * 仅设备复位后重置(主机解码断言单调性须按"同一 boot 段"分段看)。 + * 创建日期:2026-10-12(B9) + */ +#ifndef SOE_STORE_H +#define SOE_STORE_H + +#include + +#include "soe_rec.h" /* 记录类型/标志/SOE_ELM_NONE 常量(post 入参口径) */ + +#ifdef __cplusplus +extern "C" { +#endif + +/* 容量上限(条数;满时删除重建,照 SYSLOG_RUN_MAX 先例):1024 × 64B = 64KB */ +#define SOE_STORE_MAX 1024u + +/* + * 函数功能:投递一条 SOE 记录(构建 + 入队;非阻塞,队列满则丢弃并计数) + * 入口参数:type - 记录类型 soe_type_t uint8_t + * elm_id - 元件号(非元件事件传 SOE_ELM_NONE) uint8_t + * value - 类型相关值(见 soe_rec.h 类型表) uint32_t + * 返回值:无 + * 限定条件:内核启动后调用(protectTask 上下文);**绝不触碰文件系统**—— + * 取时标(sys_clock 纯 RAM)+ 组记录 + osMessageQueuePut(0 超时)。 + * 队列未创建(defaultTask 首次 drain 前)走静态待发数组;数组满 + * = 丢弃 + 计数 + 首次留痕日志(绝不阻塞、绝不崩)。 + */ +void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value); + +/* + * 函数功能:排空 SOE 待发(静态数组 + 队列,FIFO)并落 /log/soe.log + * 入口参数:无 + * 返回值:无 + * 限定条件:仅 defaultTask(唯一写者)调用,且由 syslog_drain 在"卷就绪"门 + * 之后挂接(继承 s_fs_ready 语义,勿单独调用——/log 目录可能未建) + * 函数说明:写路径照 sys_log_mcu.c s_append 全套实证纪律:vol_lock try(busy + * = -2 顺延)、FA_WRITE|FA_OPEN_APPEND(本移植 OPEN_APPEND 不含 + * WRITE)、校验写回字节数(FR_OK+短写=失败)、容量满 unlink 重建。 + * unlink/名字类错误码**严禁**归入任何"卷损坏"触发集(存储架构 + * §10.1 教训)——本模块无任何重格式化路径,故障只留痕不升级。 + */ +void soe_store_drain(void); + +/* + * 函数功能:累计丢弃计数(队列满/待发数组满;可观测性) + * 返回值:丢弃条数 uint32_t + */ +uint32_t soe_store_dropped(void); + +#ifdef __cplusplus +} +#endif + +#endif /* SOE_STORE_H */ diff --git a/App/log/sys_log_mcu.c b/App/log/sys_log_mcu.c index 701d99bc..a5530985 100644 --- a/App/log/sys_log_mcu.c +++ b/App/log/sys_log_mcu.c @@ -32,6 +32,7 @@ #include "dbg_log.h" #include "sys_log.h" +#include "soe_store.h" /* B9:SOE 落盘挂本函数尾部(同写者、同卷就绪门) */ #include "ff.h" #include "cmsis_os.h" #include "sys_clock.h" @@ -307,6 +308,10 @@ void syslog_drain(void) { } } } + /* B9:SOE 落盘挂本函数尾部——同一唯一写者(defaultTask)、同一卷就绪门 + (上方 s_fs_ready 早退蕴含 /log 已建)、同一卷锁 try 语义;protectTask + 只投递不写盘(动作路径零 NAND I/O,DoD 硬约束)。 */ + soe_store_drain(); } /* diff --git a/App/protocol/iec104/iec104_asdu.h b/App/protocol/iec104/iec104_asdu.h index 7f47d631..da1a6e68 100644 --- a/App/protocol/iec104/iec104_asdu.h +++ b/App/protocol/iec104/iec104_asdu.h @@ -20,6 +20,7 @@ /* ---------------- 类型标识(DL/T 634.5104,仅定义本期子集) ---------------- */ #define IEC104_T_M_SP_NA_1 1U /* 单点遥信 SIQ */ #define IEC104_T_M_DP_NA_1 3U /* 双点遥信 DIQ */ +#define IEC104_T_M_SP_TB_1 30U /* 单点遥信+CP56时标 SIQ+7B(B9 SOE 自发) */ #define IEC104_T_M_ME_NC_1 13U /* 遥测短浮点 float+QDS(§1.5 口径) */ #define IEC104_T_C_SE_NC_1 50U /* 定值写(短浮点)float+QDS */ #define IEC104_T_C_DC_NA_1 52U /* 双点遥控 QDC(82H 选 / 02H 执行) */ diff --git a/App/protocol/iec104/iec104_link.c b/App/protocol/iec104/iec104_link.c index df881608..25533a74 100644 --- a/App/protocol/iec104/iec104_link.c +++ b/App/protocol/iec104/iec104_link.c @@ -270,11 +270,18 @@ static int s_scan_step(iec104_link_t *lk, uint8_t *budget, uint8_t periodic) { /* * 函数功能:突发(COV)上送——pend 暂存逐点 SQ=0 COT=3,发成功才清位 + * 函数说明:单点遥信(idx≥4)自发用 **M_SP_TB_1**(SIQ + CP56Time2a,B9: + * 变位带时标 = SOE 上送路径);双点(idx<4,V0.1 恒 IV)保持 + * M_DP_NA_1;总召/周期扫描仍走 s_scan_step 的 M_SP_NA_1/M_DP_NA_1 + * (时标只用于自发,标准语义——防"全量换型")。 + * 时标 = cfg->epoch_ms(ctx);NULL/UINT64_MAX → CP56 IV 位如实置位。 */ static void s_spont_step(iec104_link_t *lk, uint8_t *budget) { - uint8_t body[5]; + uint8_t body[8]; /* 最大 = SIQ + CP56(7)(M_SP_TB_1) */ uint8_t i; uint8_t d; + uint8_t blen; + uint8_t type; int rc; while ((*budget > 0u) && ((lk->pend_yx != 0u) || (lk->pend_yc != 0u))) { @@ -288,11 +295,24 @@ static void s_spont_step(iec104_link_t *lk, uint8_t *budget) { } iec104_pts_yx_read(i, &d); body[0] = d; - rc = s_send_asdu(lk, - (i < 4u) ? IEC104_T_M_DP_NA_1 - : IEC104_T_M_SP_NA_1, - 1u, IEC104_COT_SPONT, 0u, 1u, - (uint32_t)i + 1u, body, 1u, 0u); + blen = 1u; + if (i < 4u) { + type = (uint8_t)IEC104_T_M_DP_NA_1; + } else { + uint64_t ems = (lk->cfg->epoch_ms != NULL) + ? lk->cfg->epoch_ms(lk->ctx) + : UINT64_MAX; + + type = (uint8_t)IEC104_T_M_SP_TB_1; + (void)iec104_epoch_ms_to_cp56((ems == UINT64_MAX) ? 0u : ems, + &body[1]); + if (ems == UINT64_MAX) { + body[1 + 2] |= 0x80u; /* CP56 byte2 bit7 IV:时钟无效如实上送 */ + } + blen = 8u; + } + rc = s_send_asdu(lk, type, 1u, IEC104_COT_SPONT, 0u, 1u, + (uint32_t)i + 1u, body, blen, 0u); if (rc != 0) { return; /* k 窗满:pend 位保留,下次续发 */ } diff --git a/App/protocol/iec104/iec104_link.h b/App/protocol/iec104/iec104_link.h index 0405a998..c150876c 100644 --- a/App/protocol/iec104/iec104_link.h +++ b/App/protocol/iec104/iec104_link.h @@ -56,6 +56,10 @@ typedef struct { void (*send_apdu)(void *ctx, const uint8_t *apdu, uint16_t len); /* C_CS 时钟同步:epoch 毫秒;NULL 或返回 -1 → 否定确认 */ int (*clock_sync)(void *ctx, uint64_t epoch_ms); + /* 时标源(B9):当前 epoch 毫秒(M_SP_TB_1 自发用)。 + * NULL 或返回 UINT64_MAX = 时钟无效 → CP56 IV 位如实置位。 + * 纯读(sys_clock 推算),不得阻塞、不得有副作用。 */ + uint64_t (*epoch_ms)(void *ctx); } iec104_link_cfg_t; /* 链路实例(胶水层静态持有,零堆) */ diff --git a/App/task/iec104_serv.c b/App/task/iec104_serv.c index db6ba923..ab6d2ab7 100644 --- a/App/task/iec104_serv.c +++ b/App/task/iec104_serv.c @@ -48,9 +48,10 @@ static uint8_t s_conn_dead; /* 发送连败 → 本轮 link 调用返回 /* 前向声明(cfg 初始化引用) */ static void s_send_apdu(void *ctx, const uint8_t *apdu, uint16_t len); static int s_clock_sync(void *ctx, uint64_t epoch_ms); +static uint64_t s_epoch_ms(void *ctx); /* V0.1 公共地址固定 1(点表口径;可配置项挂二期) */ -static const iec104_link_cfg_t s_cfg = { 1u, s_send_apdu, s_clock_sync }; +static const iec104_link_cfg_t s_cfg = { 1u, s_send_apdu, s_clock_sync, s_epoch_ms }; /* * 函数功能:link 层发送回调——经 net_send 串行 RPC 发出一个 APDU @@ -98,6 +99,23 @@ static int s_clock_sync(void *ctx, uint64_t epoch_ms) { return sys_clock_set_unix((uint32_t)secs); } +/* + * 函数功能:link 层时标源——当前墙钟 epoch 毫秒(B9:M_SP_TB_1 自发用) + * 入口参数:ctx 未用 + * 返回值:epoch 毫秒;墙钟未建(UINT32_MAX)→ UINT64_MAX(link 置 CP56 IV) + * 函数说明:sys_clock 秒域时基 → ms 字段恒 0(不造假亚秒,B9 口径); + * 纯 RAM 推算,无 I/O、无副作用。 + */ +static uint64_t s_epoch_ms(void *ctx) { + uint32_t s = sys_clock_get(); + + (void)ctx; + if (s == UINT32_MAX) { + return UINT64_MAX; + } + return (uint64_t)s * 1000ULL; +} + /* * 函数功能:创建并监听 2404(socket+bind+listen,抄 ui_net_sink bind_listen) * 返回值:0 成功(s_listen 写回)/ -1 失败 diff --git a/App/task/protect_task.c b/App/task/protect_task.c index 62d8c2c6..6647094e 100644 --- a/App/task/protect_task.c +++ b/App/task/protect_task.c @@ -3,7 +3,8 @@ * 模块功能:见 protect_task.h。每拍三件事: * ① L5-4 采样故障闭锁:adc_health_flags() 电平位命中 → 元件状态 * 复位(防陈旧判据)+ 本拍不 eval; - * ② prot_set_selfcheck()(L5-1 定值完整性巡检,31 项 sum32,开销 + * ② prot_set_selfcheck()(L5-1 定值完整性巡检,40 项 sum32—— + * B8 后 PS_ID_COUNT=40,开销 * 可忽略;失配 → prot_set 内部闭锁,eval 自动复位全元件); * ③ prot_elm_run()(phasor rms → 判据步进)+ 动作位图边沿日志; * ④ 全局软压板 PS_GLOBAL_EN(104 YK12 两段式投退,B7):退出 = @@ -25,6 +26,7 @@ #include "console_log.h" /* dbg_console_writer_ready():启动锚点行门控(B6) */ #include "prot_elm.h" #include "prot_set.h" +#include "soe_store.h" /* B9:动作/复归边沿 → SOE 投递(零 FS,队列异步) */ #include "task_watchdog.h" /* L5-4 采样故障电平闭锁位:stall(可自愈)+ backward(锁存)都须停判据。 @@ -37,9 +39,12 @@ static uint8_t s_prev_hf_lock; /* 上拍采样闭锁态(边沿日志用) static uint8_t s_prev_plate_out; /* 上拍全局软压板退出态(边沿日志用,B7) */ /* - * 函数功能:动作位图变化沿日志(逐变化元件打名字与状态) + * 函数功能:动作位图变化沿日志 + SOE 投递(逐变化元件) * 入口参数:active - 本拍 prot_elm_active_mask() - * 函数说明:V0.1 无出口硬件(规划 R7),动作 = 遥信 + 日志;SOE 落盘随 L4。 + * 函数说明:V0.1 无出口硬件(规划 R7),动作 = 遥信 + 日志 + SOE。 + * SOE 走 soe_store_post(纯 RAM 投递,落盘由 defaultTask 异步执行, + * 本任务零 NAND I/O——protect 静态最坏链不变,B9 DoD 硬约束)。 + * value = 元件状态打包(bit0 asserted / bit1 picked / bit2 enabled)。 */ static void s_log_active_edge(uint32_t active) { uint32_t chg = active ^ s_prev_active; @@ -56,6 +61,12 @@ static void s_log_active_edge(uint32_t active) { DBG_INFO("elm %s asserted=%u picked=%u", prot_elm_name((prot_elm_id_t)i), (unsigned)st.asserted, (unsigned)st.picked); + (void)soe_store_post( + ((active & bit) != 0u) ? (uint8_t)SOE_T_ELM_ASSERT + : (uint8_t)SOE_T_ELM_RETURN, + (uint8_t)i, + (uint32_t)st.asserted | ((uint32_t)st.picked << 1) + | ((uint32_t)st.enabled << 2)); } } s_prev_active = active; @@ -94,12 +105,17 @@ void protect_task(void *argument) { if (s_prev_hf_lock == 0u) { DBG_ERROR("sampling FAULT -> protection blocked (hf=0x%08lX)", (unsigned long)(hf & PROT_HF_LOCK_MASK)); + (void)soe_store_post((uint8_t)SOE_T_SAMPLE_BLOCK, + SOE_ELM_NONE, + hf & (uint32_t)PROT_HF_LOCK_MASK); } s_prev_hf_lock = 1u; prot_elm_init(); } else { if (s_prev_hf_lock != 0u) { DBG_INFO("sampling recovered -> protection resumed"); + (void)soe_store_post((uint8_t)SOE_T_SAMPLE_RESUME, + SOE_ELM_NONE, 0u); s_prev_hf_lock = 0u; } /* L5-1 巡检:定值区与封存校验和不符 → prot_set 闭锁(下一拍起 @@ -112,12 +128,16 @@ void protect_task(void *argument) { if (prot_set_get_ctl(PS_GLOBAL_EN) == 0u) { if (s_prev_plate_out == 0u) { DBG_INFO("global plate OUT -> protection disabled"); + (void)soe_store_post((uint8_t)SOE_T_PLATE_OUT, + SOE_ELM_NONE, 0u); s_prev_plate_out = 1u; } prot_elm_init(); } else { if (s_prev_plate_out != 0u) { DBG_INFO("global plate IN -> protection enabled"); + (void)soe_store_post((uint8_t)SOE_T_PLATE_IN, + SOE_ELM_NONE, 1u); s_prev_plate_out = 0u; } prot_elm_run(now); diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index bdbb0fc4..d885e2c1 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -902,6 +902,16 @@ 1 ..\App\log\console_log.c + + soe_rec.c + 1 + ..\App\log\soe_rec.c + + + soe_store.c + 1 + ..\App\log\soe_store.c + sys_clock.c 1 diff --git a/docs/子系统设计/IEC104上行设计说明.md b/docs/子系统设计/IEC104上行设计说明.md index bc0bf441..1fd5690d 100644 --- a/docs/子系统设计/IEC104上行设计说明.md +++ b/docs/子系统设计/IEC104上行设计说明.md @@ -13,7 +13,7 @@ |---|---|---| | 链路 | APCI U/S/I、STARTDT/STOPDT/TESTFR、k/w 窗、t1/t2/t3 | 多主站并发(单连接语义,见 §5) | | 遥测 YC | 短浮点 M_ME_NC_1(系数 1):背景/周期/死区上送 | 归一化/标度、谐波轨(L2-8) | -| 遥信 YX | 单点 M_SP + 双点 M_DP:背景 + 突发(COT=3) | SOE 带时标 CP56(M_SP_TB,随 L4-4 SOE 落地) | +| 遥信 YX | 单点 M_SP + 双点 M_DP:背景 + 突发(COT=3);**突发单点带 CP56 时标 = M_SP_TB_1(B9,随 L4-4 SOE 落地)** | 双点带时标(V0.1 双点恒 IV,无意义) | | 遥控 YK | 双点 C_DC 两段式(选择 82H/执行 02H) | 带时标、升降/步位 | | 定值 | C_RD 读 + C_SE_NC 两段写(地址 = prot_set `comm_addr`) | 定值区切换/下装文件 | | 其他 | C_CS 对时(→ `sys_clock`)、C_TS、C_RP | 固有参数 8001H~800AH(字符串类)、C_CI 电度、文件传输(录波) | @@ -67,6 +67,15 @@ > > **保护类 YX(15/16~18/23/31)自 B5(2026-10-12)起为活数据**:protectTask > 20 ms 判据节拍驱动 `prot_elm` 状态 → pts COV 变位上送(≤120 ms 端到端)。 +> +> **突发单点遥信自 B9(2026-10-12)起带时标 = M_SP_TB_1(30)**:仅自发 +> (COT=3)单点组(idx≥4)换型,信息体 = SIQ + CP56Time2a(8B);**总召/ +> 周期扫描保持 M_SP_NA_1/M_DP_NA_1**(时标只用于变位事件,标准语义,防 +> "全量换型")。时标源 = `sys_clock`(link cfg `epoch_ms` 回调):秒域时基 +> → ms 域 = 秒×1000(亚秒恒 0,不造假);墙钟未建 → CP56 IV 位如实置位。 +> 与 `/log/soe.log` 落盘记录同源同时基(`App/log/soe_store.c`,见 +> `App/log/README.md`)——104 时标 = 事件上送时刻,SOE 记录 = 事件发生 +> 时刻(20 ms 判据拍),两者差 ≤ 一拍 + 排空延迟(≤50 ms)。 ### 3.3 遥控 YK(IOA 0x6000+,双点两段式) @@ -152,12 +161,13 @@ |---|---| | L0.5 | `test/host/iec104/`:APCI 重组/坏帧、STARTDT 握手、总召全流程(含 RBT/分片/预算)、COV、k/w 窗、t1/t2/t3(假时基)、遥控两段式与 PN、YK12 软压板两段转真 + 置脏(B7)、定值两段写 + set_sealed 后 selfcheck、C_RD(含本地项不可寻址)、C_CS(含闰年)、错地址/错类型 EVI;`test/host/prot_set/`:blob 往返 + 损坏整表拒绝(all-or-nothing)| | L0 | `iec104-unit-test` 钩子 + cppcheck/coding-standard | -| 台架 | `@test` 锚点(104 服务启动/监听行);**PC 主站客户端 `test/host/iec104_master.py`(纯标准库,B6 2026-10-12 就绪)**:STARTDT/总召四组+actterm/C_SE 定值两段写→YX15·16 变位(COT=3)/恢复复归/YK11 两段正答/C_RD 读回,台架往返 **18/18 PASS**(证据 `../测试说明/boot_log_data/20261012_B6_iec104_roundtrip_log.txt`);**B7 持久化模式 `--b7-pre/--b7-post/--b7-mark/--b7-check`**(中间 ST-LINK 复位):YK12 两段投退→保护退出/联动、**定值与压板态跨复位保持**(含非默认 7.5 往返),**21/21 + 13/13 + 4/4+4/4 PASS**(证据 `../测试说明/boot_log_data/20261012_B7_iec104_persist_log.txt`);lib60870 级第三方全流程归 B10 | +| 台架 | `@test` 锚点(104 服务启动/监听行);**PC 主站客户端 `test/host/iec104_master.py`(纯标准库,B6 2026-10-12 就绪)**:STARTDT/总召四组+actterm/C_SE 定值两段写→YX15·16 变位(COT=3)/恢复复归/YK11 两段正答/C_RD 读回,台架往返 **18/18 PASS**(证据 `../测试说明/boot_log_data/20261012_B6_iec104_roundtrip_log.txt`);**B7 持久化模式 `--b7-pre/--b7-post/--b7-mark/--b7-check`**(中间 ST-LINK 复位):YK12 两段投退→保护退出/联动、**定值与压板态跨复位保持**(含非默认 7.5 往返),**21/21 + 13/13 + 4/4+4/4 PASS**(证据 `../测试说明/boot_log_data/20261012_B7_iec104_persist_log.txt`);**B9 SOE 腿**(同工具同轮回归):自发 YX 断言 **M_SP_TB_1 带 ts**(工具解析 type 30/1 双型)+ FTP 下载 `/log/soe.log` 经 `test/host/soe/decode_soe.py` 解码断言(校验和/seq 单调/CP56↔epoch UTC 交叉),pre 21/21 + post 13/13 不回归;lib60870 级第三方全流程归 B10 | ## 8. V0.1 限制与待办(如实登记) 1. 固有参数 8001H~800AH 未实现(字符串类无法短浮点上送)→ 未知 IOA 应答;随送检需求再设计。 -2. SOE 带时标(M_SP_TB_1)随 L4-4 SOE 记录落地;现突发只发 COT=3。 +2. ~~SOE 带时标~~ **突发单点已带 CP56 时标 = M_SP_TB_1(B9 落地,见 §3.2)**; + 双点带时标不做(V0.1 双点恒 IV,无意义)。 3. YK1~4/13/14 否定确认——出口硬件确认后(R7)转真执行(YK12 已于 B7 转真)。 4. 遥测死区 10.0 码值为拟定值,L1-5 标定后按工程量死区(点表 8020H 段)复核。 5. 多主站/双链路冗余不做(单连接语义)。 @@ -175,3 +185,4 @@ | 2026-10-12 | B4 落地回填:承载改**专用 iec104Task**(netTask 承载会 RPC 自死锁);§5 全量回填(门控/代次/拆链/对时/看门狗位 7);抢占策略修正为**首到优先**(§8 第 7 条);§6 实测(640 words,链 1456 B,余 43% PASS) | | 2026-10-12 | **B7 定值持久化 + 软压板**:YK12 转真(§3.3,两段式 → `PS_GLOBAL_EN`);定值执行段改 `prot_set_set_sealed` 原子写+封存(§3.4,收口 B5 竞态窗口);本地项 `comm_addr=0` 不可寻址;持久化 = 脏标志 → `SYS_EVT_PROT_SET_SAVE` → defaultTask 原子写 `/prot_set.bin`,开机屏障前整表原子装载(损坏拒绝保默认 + 自检留痕);单测 +7(iec104 94 断言)+ prot_set blob 往返/损坏 33 断言 | | 2026-10-12 | **B7 台架腿闭环**(§7 台架行回填):主站工具加 `--b7-pre/--b7-post/--b7-mark/--b7-check` 四模式(中间 ST-LINK 复位);pre 21/21(YK12 OFF→低定值不动作 / ON→YX15·16 即动作)、post 13/13(**压板退出态跨复位保持** + 重启后联动在位)、mark/check 4/4+4/4(**非默认定值 7.5 跨复位保持**);开机日志 `[CFG] prot set loaded (/prot_set.bin)`;证据 `../测试说明/boot_log_data/20261012_B7_*` | +| 2026-10-12 | **B9 时标 SOE**:自发单点 YX 换 **M_SP_TB_1(30)**(SIQ+CP56,§3.2 注;总召/周期保持 NA 型);link cfg 增 `epoch_ms` 时标源(NULL/无效 → IV 如实);serv 绑 `sys_clock`(秒域 → ms 域=秒×1000);主站工具解析 type 30/1 双型;单测 iec104 94→97(TB 时标 + IV 路径,两种类型都断言);台架腿见 §7 B9 行;落盘侧见 `App/log/README.md` soe 节 | diff --git a/scripts/check_registration.py b/scripts/check_registration.py index e01581ae..7cc20344 100644 --- a/scripts/check_registration.py +++ b/scripts/check_registration.py @@ -83,6 +83,8 @@ SIM_EXEMPT = [ ("App/algo/prot_set.c", "L5 保护定值纯算法,模拟器无消费路径"), ("App/algo/freq.c", "L2 派生测频纯算法(输入=phasor 使用区),模拟器无消费路径"), ("App/cfg/prot_set_store.c", "定值持久化 MCU 胶水(依赖 prot_set 纯算法),模拟器无消费路径"), + ("App/log/soe_rec.c", "SOE 记录编解码纯逻辑(B9),L0.5 主机单测覆盖,模拟器无消费路径"), + ("App/log/soe_store.c", "SOE 落盘 MCU 胶水(队列+FatFs 单写者,B9),模拟器无消费路径"), ("App/algo/sample_ring.c", "L2 环形缓冲纯算法,模拟器无消费路径"), ("App/app/app_main.c", "固件启动/就绪屏障,MCU 专属"), ("App/fs/vol_lock.c", "FreeRTOS 互斥量卷锁,MCU 专属"), diff --git a/test/host/iec104/iec104_test.c b/test/host/iec104/iec104_test.c index 1c898ed4..3464e93b 100644 --- a/test/host/iec104/iec104_test.c +++ b/test/host/iec104/iec104_test.c @@ -125,9 +125,16 @@ static int f_clock(void *ctx, uint64_t ms) s_clock_ms = ms; return s_clock_rc; } +/* B9 时标源假回调:s_epoch_val = UINT64_MAX → 时钟无效(CP56 IV) */ +static uint64_t s_epoch_val; +static uint64_t f_epoch(void *ctx) +{ + (void)ctx; + return s_epoch_val; +} static iec104_link_t g_lk; -static const iec104_link_cfg_t g_cfg = { 1u, cap_send, f_clock }; +static const iec104_link_cfg_t g_cfg = { 1u, cap_send, f_clock, f_epoch }; /* ---------------- 主站侧模拟(N(R) 单调,照真实主站) ---------------- */ static uint16_t m_ns; @@ -204,6 +211,7 @@ static void reset_all(uint32_t now) | (1uLL << 22) | (1uLL << 30); s_clock_rc = 0; s_clock_ms = 0u; + s_epoch_val = 1760234567000ULL; /* B9:固定时标(2025-10-12 UTC) */ iec104_link_init(&g_lk, &g_cfg, NULL); iec104_link_open(&g_lk, now); m_reset(); @@ -460,6 +468,7 @@ static void tc_cov_yx(void) { uint32_t now = 1000u; cap_t c; + uint64_t back; reset_all(now); handshake(now); @@ -467,16 +476,30 @@ static void tc_cov_yx(void) CHECK(cap_i_count() == 0, "TC-007 首 poll 无自发"); s_f_bits |= (1uLL << 15); /* YX16(idx15)动作 */ iec104_link_poll(&g_lk, now + 100u); - CHECK((cap_i_count() == 1) && (cap_i(0, &c) == 0) && (c.type == 1u) + /* B9:单点 YX 自发 = M_SP_TB_1(30)带 CP56Time2a 时标(SOE 上送路径) */ + CHECK((cap_i_count() == 1) && (cap_i(0, &c) == 0) && (c.type == 30u) && (c.cot == IEC104_COT_SPONT) && (c.ioa == 16u) - && (c.obj_len == 1u) && (c.obj[0] == 1u), "TC-007 YX16 变位 COT3"); + && (c.obj_len == 8u) && (c.obj[0] == 1u), "TC-007 YX16 变位 COT3 TB"); + if ((cap_i(0, &c) == 0) && (c.obj_len == 8u)) { + CHECK(iec104_cp56_to_epoch_ms(&c.obj[1], &back) == 0, + "TC-007 CP56 可解"); + CHECK(back == s_epoch_val, "TC-007 时标 = epoch_ms 源"); + } s_f_bits &= ~(1uLL << 15); /* 复归 */ iec104_link_poll(&g_lk, now + 200u); - CHECK((cap_i_count() == 2) && (cap_i(1, &c) == 0) && (c.ioa == 16u) - && (c.obj[0] == 0u), "TC-007 YX16 复归再变"); - s_f_bits |= (1uLL << 0); /* idx0 无效位 → 不触发 */ + CHECK((cap_i_count() == 2) && (cap_i(1, &c) == 0) && (c.type == 30u) + && (c.ioa == 16u) && (c.obj[0] == 0u), "TC-007 YX16 复归再变"); + /* B9 IV 路径:时标源无效 → CP56 IV 位如实置位 */ + s_epoch_val = UINT64_MAX; + s_f_bits |= (1uLL << 16); /* YX17(idx16)动作 */ iec104_link_poll(&g_lk, now + 300u); - CHECK(cap_i_count() == 2, "TC-007 无效位变位不触发"); + CHECK((cap_i_count() == 3) && (cap_i(2, &c) == 0) && (c.type == 30u) + && (c.ioa == 17u) && ((c.obj[3] & 0x80u) != 0u), + "TC-007 时钟无效 → CP56 IV 位"); + s_epoch_val = 1760234567000ULL; + s_f_bits |= (1uLL << 0); /* idx0 无效位 → 不触发 */ + iec104_link_poll(&g_lk, now + 400u); + CHECK(cap_i_count() == 3, "TC-007 无效位变位不触发"); } static void tc_cov_yc(void) diff --git a/test/host/iec104_master.py b/test/host/iec104_master.py index 168a0d14..f985ab2b 100644 --- a/test/host/iec104_master.py +++ b/test/host/iec104_master.py @@ -55,6 +55,7 @@ U_STARTDT_ACT, U_STARTDT_CON = 0x07, 0x0B U_TESTFR_ACT, U_TESTFR_CON = 0x43, 0x83 T_M_SP_NA_1, T_M_DP_NA_1, T_M_ME_NC_1 = 1, 3, 13 +T_M_SP_TB_1 = 30 # single point + CP56Time2a (B9 SOE spontaneous) T_C_SE_NC_1, T_C_DC_NA_1 = 50, 52 T_C_IC_NA_1, T_C_RD_NA_1 = 100, 102 @@ -109,6 +110,28 @@ def asdu(type_id, vsq, cot, cot_hi, ioa=None, payload=b""): return h + payload +def cp56_str(b): + """Decode CP56Time2a (7B) -> 'YYYY-MM-DD HH:MM:SS.mmm' (UTC) or 'IV-...'. + B9: M_SP_TB_1 spontaneous time tag (sec-resolution, ms field = sec*1000).""" + iv = (b[2] & 0x80) != 0 + ms_min = b[0] | (b[1] << 8) + minute = b[2] & 0x3F + hour = b[3] & 0x1F + day = b[4] & 0x1F + mon = b[5] & 0x0F + year = 2000 + (b[6] & 0x7F) + sec = ms_min // 1000 + ms = ms_min % 1000 + try: + import datetime + d = datetime.datetime(year, mon, day, hour, minute, sec, ms * 1000) + s = d.strftime("%Y-%m-%d %H:%M:%S") + (".%03d" % ms) + except ValueError: + s = "%04d-%02d-%02d %02d:%02d:%02d.%03d" % ( + year, mon, day, hour, minute, sec, ms) + return ("IV " if iv else "") + s + + class Master(object): """Minimal 104 master. k/w/t are covered by "S after every recv batch" (far faster than t1=15s / t2=10s), so no timers are needed here.""" @@ -208,26 +231,32 @@ class Master(object): n = vsq & 0x7F obj = a[6:] - if type_id in (T_M_SP_NA_1, T_M_DP_NA_1): - pts = [] + if type_id in (T_M_SP_NA_1, T_M_DP_NA_1, T_M_SP_TB_1): + # B9: M_SP_TB_1 (30) = SIQ + CP56Time2a (8B/point); NA forms = 1B. + esz = 8 if type_id == T_M_SP_TB_1 else 1 + pts = [] # (ioa, raw, elem_off_in_obj) if sq: base = get_ioa(obj) for i in range(n): - pts.append((base + i, obj[3 + i])) + pts.append((base + i, obj[3 + i * esz], 3 + i * esz)) else: off = 0 for _ in range(n): - pts.append((get_ioa(obj, off), obj[off + 3])) - off += 4 - for ioa, raw in pts: + pts.append((get_ioa(obj, off), obj[off + 3], off + 3)) + off += 3 + esz + for ioa, raw, eoff in pts: val = (raw & 0x03) if type_id == T_M_DP_NA_1 else (raw & 0x01) iv = (raw & 0x80) != 0 self.yx[ioa] = (val, iv) + ts = "" + if type_id == T_M_SP_TB_1: + ts = " ts=%s" % cp56_str(obj[eoff + 1:eoff + 8]) self.events.append(("yx", (ioa, val, iv, cot))) if ioa in YX_NAMES or cot == COT_SPONT: - log("RX I %s ioa=%d val=%d iv=%d cot=%d [N(R)=%d]" - % ("M_DP" if type_id == T_M_DP_NA_1 else "M_SP", - ioa, val, 1 if iv else 0, cot, nr)) + log("RX I %s ioa=%d val=%d iv=%d cot=%d%s [N(R)=%d]" + % ({T_M_DP_NA_1: "M_DP", T_M_SP_NA_1: "M_SP", + T_M_SP_TB_1: "M_SP_TB"}[type_id], + ioa, val, 1 if iv else 0, cot, ts, nr)) elif type_id == T_M_ME_NC_1: pts = [] if sq: diff --git a/test/host/soe/decode_soe.py b/test/host/soe/decode_soe.py new file mode 100644 index 00000000..448bbf38 --- /dev/null +++ b/test/host/soe/decode_soe.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""decode_soe.py - /log/soe.log 主机解码器 + 断言(B9 台架腿证据工具,纯标准库)。 + +做什么:把 FTP 下载的设备端 /log/soe.log(定长 64B 二进制记录,格式见 + App/log/soe_rec.h)逐条解码成可读行,并断言: + 1) 文件大小 = 64 的整数倍(定长不变量); + 2) 每条记录版本 = 1 且 sum8 校验和通过(撕裂/半写检测); + 3) seq 单调:同一 boot 段内严格 +1(seq 回落 = 设备复位/容量重建段边界, + 如实分段计数,不算失败); + 4) 时标:IV 记录如实报告;非 IV 记录时标必须落在合理窗口 + (2020..2099,对时链已就绪的台架前提); + 5) 类型合法(1..6);元件事件 elm_id < --max-elm(默认 16 留余量; + PE_COUNT 现 7,B8 过频/低频并入后 9——写死 7 会在 B8 合并后误报)。 + 任一断言失败 -> 退出码 1(台架腿 FAIL 判据之一)。 + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' test/host/soe/decode_soe.py + 可选:--expect-assert N 断言至少 N 条"元件动作"记录(台架腿用 1) + +退出码:0=全部断言 PASS / 1=断言失败 / 2=参数或文件错误。 + +格式镜像:App/log/soe_rec.h(改格式必须同步本工具)。 +创建日期:2026-10-12(B9) +""" +import argparse +import calendar +import datetime +import struct +import sys + +REC_SIZE = 64 +VER = 1 +OFF_VER, OFF_TYPE, OFF_ELM, OFF_FLAGS = 0, 1, 2, 3 +OFF_SEQ, OFF_CP56, OFF_EPOCH, OFF_VALUE, OFF_SUM = 4, 8, 15, 19, 63 +FLG_IV = 0x80 + +TYPE_NAMES = { + 1: "ELM_ASSERT", 2: "ELM_RETURN", 3: "PLATE_OUT", 4: "PLATE_IN", + 5: "SAMPLE_BLOCK", 6: "SAMPLE_RESUME", +} + + +def cp56_decode(b): + """CP56Time2a 7B -> (datetime|None, iv). ms 域 = sec*1000(B9 秒域时基)。""" + iv = (b[2] & 0x80) != 0 + ms_min = b[0] | (b[1] << 8) + minute = b[2] & 0x3F + hour = b[3] & 0x1F + day = b[4] & 0x1F + mon = b[5] & 0x0F + year = 2000 + (b[6] & 0x7F) + sec, ms = divmod(ms_min, 1000) + try: + dt = datetime.datetime(year, mon, day, hour, minute, sec, ms * 1000) + except ValueError: + dt = None + return dt, iv + + +def main(): + ap = argparse.ArgumentParser(description="decode /log/soe.log") + ap.add_argument("path", help="downloaded soe.log file") + ap.add_argument("--expect-assert", type=int, default=0, + help="min ELM_ASSERT records required (bench leg: 1)") + ap.add_argument("--max-elm", type=int, default=16, + help="exclusive upper bound for element elm_id " + "(PE_COUNT: 7 pre-B8, 9 post-B8; 16 = headroom)") + args = ap.parse_args() + + try: + with open(args.path, "rb") as f: + data = f.read() + except OSError as e: + print("ERROR: cannot read %s: %s" % (args.path, e)) + return 2 + + print("soe.log: %d bytes, %d records" % (len(data), len(data) // REC_SIZE)) + fails = [] + if len(data) == 0: + fails.append("empty file (no SOE records)") + if len(data) % REC_SIZE != 0: + fails.append("size %d not multiple of %d (torn tail)" + % (len(data), REC_SIZE)) + + n_seg = 0 + prev_seq = None + n_assert = 0 + for i in range(len(data) // REC_SIZE): + rec = data[i * REC_SIZE:(i + 1) * REC_SIZE] + if rec[OFF_VER] != VER: + fails.append("rec %d: bad version %d" % (i, rec[OFF_VER])) + continue + s = sum(rec[:OFF_SUM]) & 0xFF + if rec[OFF_SUM] != s: + fails.append("rec %d: checksum mismatch (stored 0x%02X calc 0x%02X)" + % (i, rec[OFF_SUM], s)) + continue + rtype = rec[OFF_TYPE] + elm = rec[OFF_ELM] + flags = rec[OFF_FLAGS] + seq = struct.unpack_from("= args.max_elm: + fails.append("rec %d: elm_id %d out of range (max %d)" + % (i, elm, args.max_elm - 1)) + + # seq monotonic within a boot segment; drop = new segment (reset/rebuild) + if prev_seq is None or seq <= prev_seq: + n_seg += 1 + elif seq != prev_seq + 1: + fails.append("rec %d: seq gap %d -> %d (not strictly +1)" + % (i, prev_seq, seq)) + prev_seq = seq + + if (flags & FLG_IV) != 0: + if not iv: + fails.append("rec %d: flags IV but CP56 IV clear" % i) + ts_s = "IV" + else: + if iv: + fails.append("rec %d: CP56 IV set but flags IV clear" % i) + if dt is None or not (2020 <= dt.year <= 2099): + fails.append("rec %d: timestamp out of window: %r" % (i, dt)) + ts_s = dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "BAD" + # CP56 = UTC 分解;epoch 字段 = UTC 秒 → 用 calendar.timegm(UTC)。 + # ⚠ 本机时区 +08 时 dt.timestamp() 会差 28800 s(首跑实证坑) + if dt and epoch and abs(calendar.timegm(dt.utctimetuple()) - epoch) > 2: + fails.append("rec %d: cp56/epoch cross-check off (%s vs %d)" + % (i, ts_s, epoch)) + + if rtype == 1: + n_assert += 1 + print(" #%03d seq=%-10d %-13s elm=%-3s val=0x%08X ts=%s" + % (i, seq, TYPE_NAMES.get(rtype, "?%d" % rtype), + "-" if elm == 0xFF else str(elm), value, ts_s)) + + print("segments (boot/rebuild boundaries): %d, ELM_ASSERT records: %d" + % (n_seg, n_assert)) + if n_assert < args.expect_assert: + fails.append("ELM_ASSERT count %d < expected %d" + % (n_assert, args.expect_assert)) + + if fails: + for x in fails: + print("FAIL: %s" % x) + print("SUMMARY: FAIL (%d)" % len(fails)) + return 1 + print("SUMMARY: PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/host/soe/run_unit_test.py b/test/host/soe/run_unit_test.py new file mode 100644 index 00000000..9988bb94 --- /dev/null +++ b/test/host/soe/run_unit_test.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""SOE 记录编解码(B9)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么:用本机 MSVC 离线编译 test/host/soe/soe_test.c(#include 纳入 + 生产源文件 soe_rec.c + iec104_asdu.c),验证 64B 定长记录构建/解析 + 往返、sum8 校验和硬门槛、CP56Time2a 时标编码(与 iec104 解码器交叉 + 互证)、IV 语义、钳位、结构不变量。 + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' test/host/soe/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/算法/继电保护功能复现规划.md §3.4(L4-4/L4-5,B9)。 +创建日期:2026-10-12 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +BUILD = TestBuild( + name="SOE Record Tests", + src=os.path.join(HERE, "soe_test.c"), + include_dirs=[ + os.path.join(ROOT, "App", "log"), + os.path.join(ROOT, "App", "protocol", "iec104"), + HERE, + ], + summary_marker="=== SOE Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/soe/soe_test.c b/test/host/soe/soe_test.c new file mode 100644 index 00000000..c94b1540 --- /dev/null +++ b/test/host/soe/soe_test.c @@ -0,0 +1,213 @@ +/* + * 模块名称:SOE 记录编解码主机侧单元测试(B9,L0.5) + * 模块功能:离线验证 App/log/soe_rec.c——64B 定长记录构建/解析往返、 + * 校验和硬门槛(撕裂/半写拒绝)、版本门槛、CP56Time2a 时标 + * 编码正确性(与 iec104_asdu.c 的 CP56 解码交叉验证:同一算法族 + * 两个实现互证)、IV 语义、钳位、保留区清零。 + * + * 被测对象:真实源文件(#include 原样纳入): + * soe_rec.c(B9 记录编解码) + * iec104_asdu.c(交叉验证用 CP56 解码器,纯逻辑) + * + * 覆盖点: + * TC-SOE-001 构建/解析字段往返(type/elm/flags/seq/epoch/value) + * TC-SOE-002 校验和硬门槛:单字节翻转 → -3;版本篡改 → -2 + * TC-SOE-003 CP56 编码正确性:已知 epoch → 期望字节序列; + * 与 iec104_cp56_to_epoch_ms 往返互证(含闰日 2024-02-29) + * TC-SOE-004 IV 语义:flags IV → CP56 byte2 bit7 置位 + epoch 字段 0 + * TC-SOE-005 钳位:epoch < 2000 → 2000-01-01;epoch > 2127 上界 → 2127-12-31 + * TC-SOE-006 结构不变量:size=64、保留区 [23..62] 恒 0、sum=sum8([0..62]) + * TC-SOE-007 序号域:0 / 0xFFFFFFFF 边界往返(uint32 回绕语义) + * + * 通过:全部 TC PASS(main 返回 0)。 + * 创建日期:2026-10-12(B9) + */ +#include +#include +#include + +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +#include "soe_rec.c" +#include "iec104_asdu.c" + +/* ---------------- TC-SOE-001 字段往返 ---------------- */ +static void tc_roundtrip(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + int rc; + + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 2u, 0u, 12345u, + 1760234567u, 0xDEADBEEFu); + rc = soe_rec_parse(rec, &out); + CHECK(rc == 0, "TC-001 parse ok"); + CHECK(out.ver == SOE_REC_VER, "TC-001 ver"); + CHECK(out.type == (uint8_t)SOE_T_ELM_ASSERT, "TC-001 type"); + CHECK(out.elm_id == 2u, "TC-001 elm_id"); + CHECK(out.flags == 0u, "TC-001 flags"); + CHECK(out.seq == 12345u, "TC-001 seq"); + CHECK(out.epoch_s == 1760234567u, "TC-001 epoch"); + CHECK(out.value == 0xDEADBEEFu, "TC-001 value"); + CHECK(out.cp56[6] == (uint8_t)((2025u - 2000u) & 0x7Fu), "TC-001 cp56 year=2025"); +} + +/* ---------------- TC-SOE-002 校验和/版本硬门槛 ---------------- */ +static void tc_gates(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + uint32_t i; + int bad = 0; + + soe_rec_build(rec, (uint8_t)SOE_T_ELM_RETURN, 0u, 0u, 7u, 1760234567u, 3u); + /* 每个数据字节单独翻转:解析必须全部拒绝(sum 覆盖 [0..62]) */ + for (i = 0u; i < SOE_OFF_SUM; i++) { + uint8_t save = rec[i]; + rec[i] ^= 0x01u; + if (soe_rec_parse(rec, &out) == 0) { + bad = 1; + } + rec[i] = save; + } + CHECK(bad == 0, "TC-002 any byte flip rejected"); + CHECK(soe_rec_parse(rec, &out) == 0, "TC-002 intact parses ok"); + + rec[SOE_OFF_VER] = (uint8_t)(SOE_REC_VER + 1u); /* 版本篡改:sum 仍按新 + * 字节算会自洽,但版本 + * 门槛先拒 */ + CHECK(soe_rec_parse(rec, &out) == -2, "TC-002 bad version -> -2"); + CHECK(soe_rec_parse(NULL, &out) == -1, "TC-002 null args -> -1"); +} + +/* ---------------- TC-SOE-003 CP56 正确性 + 交叉往返 ---------------- */ +static void tc_cp56(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + uint64_t back; + int rc; + + /* 2026-10-12 12:34:56 UTC = 1791808496(手算:2026-10-12 = epoch 1791806400 + *(当日 00:00 UTC)+ 12*3600+34*60+56 = 45296 → 1791851696? 以解码器为准, + * 这里用"往返互证 + 显式字段"双保险,不依赖手算) */ + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 0u, 0u, 1u, 1791808496u, 0u); + (void)soe_rec_parse(rec, &out); + /* 显式字段:12:34:56 → ms 域 = 56*1000(秒域时基如实编码,亚秒 0) */ + CHECK(((uint32_t)out.cp56[0] | ((uint32_t)out.cp56[1] << 8)) == 56000u, + "TC-003 ms field = sec*1000 (56000)"); + rc = iec104_cp56_to_epoch_ms(out.cp56, &back); + CHECK(rc == 0, "TC-003 cross decode ok"); + CHECK(back == 1791808496000ULL, "TC-003 cross roundtrip exact"); + + /* 闰日 2024-02-29 00:00:00 UTC = 1709164800 */ + soe_rec_build(rec, (uint8_t)SOE_T_SAMPLE_BLOCK, 0u, 0u, 2u, 1709164800u, 0u); + (void)soe_rec_parse(rec, &out); + CHECK(out.cp56[4] == 29u && out.cp56[5] == 2u && out.cp56[6] == 24u, + "TC-003 leap day fields"); + rc = iec104_cp56_to_epoch_ms(out.cp56, &back); + CHECK((rc == 0) && (back == 1709164800000ULL), "TC-003 leap roundtrip"); + + /* 2000-01-01 00:00:00 边界 */ + soe_rec_build(rec, (uint8_t)SOE_T_PLATE_IN, 0u, 0u, 3u, 946684800u, 0u); + (void)soe_rec_parse(rec, &out); + rc = iec104_cp56_to_epoch_ms(out.cp56, &back); + CHECK((rc == 0) && (back == 946684800000ULL), "TC-003 year-2000 boundary"); +} + +/* ---------------- TC-SOE-004 IV 语义 ---------------- */ +static void tc_iv(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + uint64_t back; + + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 1u, SOE_FLG_IV, 9u, 0u, 1u); + (void)soe_rec_parse(rec, &out); + CHECK((out.cp56[2] & 0x80u) != 0u, "TC-004 cp56 IV bit set"); + CHECK(out.epoch_s == 0u, "TC-004 epoch field zeroed"); + CHECK(iec104_cp56_to_epoch_ms(out.cp56, &back) == -2, + "TC-004 cross decoder rejects IV"); +} + +/* ---------------- TC-SOE-005 钳位 ---------------- */ +static void tc_clamp(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + uint64_t back; + + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 0u, 0u, 4u, 100u, 0u); + (void)soe_rec_parse(rec, &out); + CHECK((out.cp56[4] == 1u) && (out.cp56[5] == 1u) && (out.cp56[6] == 0u), + "TC-005 clamp low -> 2000-01-01"); + (void)iec104_cp56_to_epoch_ms(out.cp56, &back); + CHECK(back == 946684800000ULL, "TC-005 clamp low exact"); + + /* epoch_s 为 uint32 → 最大 2106-02-07(0xFFFFFFFF),恒在 CP56 域内: + * 上界钳位是防御性死分支(epoch 域扩展时生效),此处断言精确往返 */ + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 0u, 0u, 5u, 0xFFFFFFFFu, 0u); + (void)soe_rec_parse(rec, &out); + CHECK(out.cp56[6] == 106u, "TC-005 uint32 max -> year 2106 (no clamp needed)"); + (void)iec104_cp56_to_epoch_ms(out.cp56, &back); + CHECK(back == 4294967295000ULL, "TC-005 uint32 max exact roundtrip"); +} + +/* ---------------- TC-SOE-006 结构不变量 ---------------- */ +static void tc_layout(void) +{ + uint8_t rec[SOE_REC_SIZE]; + uint32_t i; + uint8_t s = 0u; + int zero_ok = 1; + + CHECK(SOE_REC_SIZE == 64u, "TC-006 record size 64"); + soe_rec_build(rec, (uint8_t)SOE_T_SAMPLE_RESUME, SOE_ELM_NONE, 0u, + 0u, 946684800u, 0u); + for (i = 23u; i <= 62u; i++) { + if (rec[i] != 0u) { + zero_ok = 0; + } + } + CHECK(zero_ok, "TC-006 reserved [23..62] zero"); + for (i = 0u; i < SOE_OFF_SUM; i++) { + s = (uint8_t)(s + rec[i]); + } + CHECK(rec[SOE_OFF_SUM] == s, "TC-006 sum8 covers [0..62]"); + CHECK(rec[SOE_OFF_ELM] == SOE_ELM_NONE, "TC-006 elm none"); +} + +/* ---------------- TC-SOE-007 序号边界 ---------------- */ +static void tc_seq_edges(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 0u, 0u, 0u, 946684800u, 0u); + CHECK((soe_rec_parse(rec, &out) == 0) && (out.seq == 0u), "TC-007 seq 0"); + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 0u, 0u, 0xFFFFFFFFu, + 946684800u, 0u); + CHECK((soe_rec_parse(rec, &out) == 0) && (out.seq == 0xFFFFFFFFu), + "TC-007 seq 0xFFFFFFFF"); +} + +int main(void) +{ + printf("=== SOE Record Codec Host Tests ===\n"); + tc_roundtrip(); + tc_gates(); + tc_cp56(); + tc_iv(); + tc_clamp(); + tc_layout(); + tc_seq_edges(); + printf("=== SOE Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +}