feat(soe): B9 动作记录 + 时标 SOE(L4-4/L4-5)+ 104 M_SP_TB_1 自发
- 新增 App/log/soe_rec.{c,h}:64B 定长 SOE 记录纯编解码(CP56Time2a
时标 + 类型 + 元件号 + 单调序号 + 值 + sum8 撕裂校验),零 I/O 零 OS
依赖,主机 L0.5 覆盖(32 断言,含与 iec104 CP56 解码器交叉互证)
- 新增 App/log/soe_store.{c,h}:protectTask 边沿投递(取时标 + 组记录 +
SPSC 待发环/消息队列,零文件系统);defaultTask 经 syslog_drain 尾部
排空落 /log/soe.log(卷锁 try / FA_WRITE|FA_OPEN_APPEND / 校验字节数 /
容量满 unlink 重建,全套照 run.log 实证纪律;无卷损坏归类路径)
- protect_task.c:元件动作/返回、软压板投退、采样闭锁/恢复六类边沿
→ soe_store_post;post 内禁 DBG_*(protect 静态最坏链 872 B 不变,
改前/改后 stack_report 九任务逐字节一致),丢弃留痕挪 drain 侧
- iec104:单点 YX 自发改 M_SP_TB_1(30) 带 CP56 时标(总召/周期保持
M_SP_NA_1,单测两种都断言);link cfg 新增 epoch_ms 时标源回调
(NULL/无效 → IV 位如实置位);serv 绑 sys_clock(秒域时基,
ms 域 = 秒×1000,不造假亚秒)
- 主机单测:test/host/soe/(32 断言)+ iec104 94→97(TB 时标/IV);
iec104_master.py 解析 type 30(B6/B7 流不回归);decode_soe.py
台架解码断言工具(seq 单调/校验和/UTC 交叉)
- 注册:uvprojx + check_registration SIM_EXEMPT 点名(MCU-only 胶水)
- 文档(我方属主区):`App/log/README.md` 增 soe 节(64B 记录/单写者/
post 禁 DBG/UTC 交叉坑);`docs/子系统设计/IEC104上行设计说明.md`
§1 范围行 + §3.2 时标注 + §7 台架行 + §8 第 2 条销项 + 变更记录行
台架腿(bench 锁 B9,release --bench-pass):run_boot_test PASS;
--b7-pre 21/21、--b7-post 13/13(B6/B7 回归 + 确定性动作边沿);
自发 YX 上送 M_SP_TB_1 带 ts(11:00:51 动作 / 11:00:53 复归);
FTP 下载 /log/soe.log 30 条记录 decode PASS(seq 0..28 单调、
ELM_ASSERT 14 条、UTC 交叉一致);定值/压板已恢复默认。
台账 +1088 B(SPSC 环 512 + 静态 FIL 528 + 计数器)随合并由 Lead
回填(ram_baseline --write + check_mem_numbers --write)。
gate: only mem-budget/mem-numbers pending lead ledger backfill
(pre-commit --all-files: 29 Passed / 2 Failed / 0 Skipped;
hygiene self-check OK: no BOM/CRLF/mojibake/trailws/non-ASCII literal)
sync 追平 v2-on-ui(B8 freq + N33 抬栈)后复跑:@build 0/0
(Code=209492 RW=1104 ZI=154184);stack_report 九任务全 PASS、
protectTask 872 B/57% 不变;ci_local L0.5 15 runner 951/951
(freq 46 / soe 32 / iec104 97 / prot_set 90 / prot_elm 47)。
顺带修 protect_task.c 陈旧注释:定值巡检 31 项 → 40 项(B8 后)。
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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 <string.h>
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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 <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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 执行) */
|
||||
|
||||
@@ -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 位保留,下次续发 */
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/* 链路实例(胶水层静态持有,零堆) */
|
||||
|
||||
+19
-1
@@ -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 失败
|
||||
|
||||
+23
-3
@@ -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);
|
||||
|
||||
@@ -902,6 +902,16 @@
|
||||
<FileType>1</FileType>
|
||||
<FilePath>..\App\log\console_log.c</FilePath>
|
||||
</File>
|
||||
<File>
|
||||
<FileName>soe_rec.c</FileName>
|
||||
<FileType>1</FileType>
|
||||
<FilePath>..\App\log\soe_rec.c</FilePath>
|
||||
</File>
|
||||
<File>
|
||||
<FileName>soe_store.c</FileName>
|
||||
<FileType>1</FileType>
|
||||
<FilePath>..\App\log\soe_store.c</FilePath>
|
||||
</File>
|
||||
<File>
|
||||
<FileName>sys_clock.c</FileName>
|
||||
<FileType>1</FileType>
|
||||
|
||||
@@ -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 节 |
|
||||
|
||||
@@ -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 专属"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <soe.log 路径>
|
||||
可选:--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("<I", rec, OFF_SEQ)[0]
|
||||
epoch = struct.unpack_from("<I", rec, OFF_EPOCH)[0]
|
||||
value = struct.unpack_from("<I", rec, OFF_VALUE)[0]
|
||||
dt, iv = cp56_decode(rec[OFF_CP56:OFF_CP56 + 7])
|
||||
|
||||
if rtype not in TYPE_NAMES:
|
||||
fails.append("rec %d: bad type %d" % (i, rtype))
|
||||
if rtype in (1, 2) and elm >= 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())
|
||||
@@ -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))
|
||||
@@ -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 <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user