diff --git a/CHANGELOG.md b/CHANGELOG.md
index a30798c..e367399 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,10 @@
### 新增
+- 增加独立 SecsFrame-ControlTrace/1 信封,对公共未认领控制
+ 事件提供不含 Body 的十字节头元数据导出与严格读取。
+- 控制记录保留方向、观察状态和原始头字段,允许未知非零 SType 往返;增加
+ 未匹配 Reject 黄金向量、Data Message 拒绝、畸形字段与资源限制测试。
- 增加独立 SecsFrame-DiagnosticTrace/1 信封,为公共
HsmsDiagnostic 提供确定性受限字段快照导出与严格读取。
- 诊断快照只保留代码、层级、操作、状态、计时器和可选协议标量,明确排除
diff --git a/README.md b/README.md
index 4a13fbf..6d0a0ef 100644
--- a/README.md
+++ b/README.md
@@ -43,8 +43,9 @@ Equipment 应用发送策略的 Collection Event、报警目录查询、单报
不进入线上通讯路径。
SecsFrame.Trace 在该文本层上提供版本化的数据消息 Trace 信封、
严格的 Item 路径脱敏、显式 allowlist 重放,以及默认关闭的缩放/封顶时序
-重放;独立诊断信封只保存稳定标量,不保存异常或原始帧。该包不会接管连接
-事件消费者,也不会复用原事务标识。
+重放;独立诊断信封只保存稳定标量,不保存异常或原始帧;控制信封可保存
+未认领公共控制事件的十字节头元数据。该包不会接管连接事件消费者,也不会
+复用原事务标识。
Item 用法与边界见
[docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见
[docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index ec3af8e..0c15249 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -19,6 +19,8 @@ Trace 的时序重放必须显式启用,只在允许发送记录之间等待
发送 API 创建新事务;默认重放不引入时间等待。
结构化诊断使用独立只读快照信封:只复制公共诊断的稳定标量,明确排除原
异常和未解码帧,也不能进入重放路径。
+控制消息使用另一个只读元数据信封,只从调用方已消费的公共未认领控制事件
+复制十字节头字段;它不增加内部控制面观察者,也不代表完整握手抓包。
## 核心原则
@@ -169,6 +171,7 @@ HSMS 头 Session ID 与 System Bytes。没有 W-Bit 的出站消息在写出后
- `SecsFrame.Trace` 依赖 `SecsFrame.Sml` 与 `SecsFrame`,重放必须回到
公共发送 API,不得直接构造或写出保留旧事务标识的线上帧;可选时序只
控制相邻发送前的等待,不得绕过连接状态、T3 或显式 allowlist。诊断
- 导出不得隐式包含 Exception 或 HsmsFrame。
+ 导出不得隐式包含 Exception 或 HsmsFrame;控制
+ 元数据导出不得包含 Body 或隐式订阅连接事件流。
- 核心包不得依赖 GEM、SML/SMN、依赖注入容器或具体日志实现。
- 状态机计时使用可替换的时间抽象,测试不得依赖真实长时间等待。
diff --git a/docs/HSMS-CONNECTION.md b/docs/HSMS-CONNECTION.md
index 2189f2f..7ff309a 100644
--- a/docs/HSMS-CONNECTION.md
+++ b/docs/HSMS-CONNECTION.md
@@ -83,6 +83,12 @@ var secondary = await connection.SendAsync(
- ControlMessageReceived:未被当前控制或数据事务认领的帧;
- DataMessageDecodeFailed:原始帧和严格解码错误。
+调用方在自己的事件循环中可以使用
+SecsTraceControlRecord.CreateReceived 对
+ControlMessageReceived 创建不含 Body 的头元数据快照。该事件
+不包含已被内部 Select、Linktest、Deselect 或数据事务认领的控制消息,
+因此快照不是完整控制面抓包。格式与安全边界见 [TRACE.md](TRACE.md)。
+
带错误的状态事件和解码失败事件还会提供可选
HsmsDiagnostic,用于按稳定代码、层级、操作与计时器处理,
无需解析异常消息。调用任务抛出的异常可通过
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 08cf76f..d9ee4c2 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -100,7 +100,8 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写
公共发送 API 创建新事务,不复用旧会话或 System Bytes。可选时序重放也已
完成:只使用允许发送记录的相邻时间戳,支持显式加速和缩放后单次等待封顶,
默认重放仍不等待。结构化 HSMS 诊断现可通过独立受限信封导出;快照只包含
-稳定标量,明确排除异常和原始帧。
+稳定标量,明确排除异常和原始帧。现有公共未认领控制事件也可导出不含 Body
+的十字节头元数据,同时保持未知非零 SType 的原始值。
## 阶段 3:元数据与工具
@@ -110,12 +111,14 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写
- [x] 已解码数据消息的 Trace 导出、结构化脱敏和受控重放基线。
- [x] 显式启用、可缩放且单次等待封顶的 Trace 重放时序。
- [x] 不包含异常与原始帧的受限结构化诊断 Trace 导出和严格读取。
-- 控制消息和故障原始样本等互操作诊断扩展。
+- [x] 公共未认领控制消息的受限头元数据 Trace 导出和严格读取。
+- 完整控制面观测和故障原始样本等互操作诊断扩展。
当前 Trace 基线不接管连接事件流,也不把原始标识写回线上。时序重放默认
关闭,并继续经过显式 allowlist 与公共发送 API。诊断快照始终排除
-Error 与 Frame。后续控制与故障原始样本扩展必须先
-定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。
+Error 与 Frame。控制元数据只覆盖应用事件循环已经
+取得的未认领控制帧,不声称观察内部握手。后续完整控制观测与故障原始样本
+扩展必须先定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。
E173 SMN 与 E172 SEDD 仍需先核对授权标准及允许分发的 Schema/表示形式。
## 发布门槛
diff --git a/docs/TRACE.md b/docs/TRACE.md
index 0a52ec5..1d6c28c 100644
--- a/docs/TRACE.md
+++ b/docs/TRACE.md
@@ -8,10 +8,12 @@
- 按 Stream、Function 与 Item List 路径执行的结构化替换脱敏;
- 只选择显式允许的本地发送记录,并通过正常公共发送 API 串行重放;
- 可选按缩放且封顶的源时间间隔执行受控时序重放;
-- 不包含异常和原始帧的结构化 HSMS 诊断快照导出与严格读取。
+- 不包含异常和原始帧的结构化 HSMS 诊断快照导出与严格读取;
+- 公共未认领控制事件的十字节头元数据导出与严格读取。
当前切片不是原始网络抓包器,不记录 TCP 字节、transport Session generation、
-控制帧或异常对象,也不会接管 HsmsConnection 的单消费者事件流。
+控制帧的线上原始字节或异常对象,也不会接管 HsmsConnection 的
+单消费者事件流。
应用应在自己的事件循环中从 HsmsIncomingDataMessage 创建接收记录,
并在调用发送 API 前显式创建发送记录。
@@ -88,6 +90,38 @@ Session ID、System Bytes、远端状态字节和被拒绝的 SType。枚举只
与 SecsFrame-Trace/1 消息信封相互独立,不能交给重放器。协议
标识和状态字节仍是运维元数据;存储和共享文件时仍需执行访问控制与保留策略。
+## 未认领控制消息元数据
+
+应用在自己的连接事件循环中可以对
+ControlMessageReceived 创建只包含头字段的快照:
+
+~~~csharp
+if (connectionEvent.Kind == HsmsConnectionEventKind.ControlMessageReceived)
+{
+ var record = SecsTraceControlRecord.CreateReceived(
+ DateTimeOffset.UtcNow,
+ connectionEvent);
+ var text = new SecsTraceControlCodec().Encode(new[] { record });
+}
+~~~
+
+独立信封固定使用 LF 与 10 个单空格分隔字段:
+
+~~~text
+SecsFrame-ControlTrace/1
+Control 2026-08-29T05:00:00.0000000Z Received Selected 65535 0x05 0x03 0x00 0x07 0x10203040
+~~~
+
+字段依次为 UTC 时间、本地方向、观察到的会话状态,以及原十字节 HSMS 头的
+Session ID、Header Byte 2、Header Byte 3、PType、SType 和 System Bytes。
+字节字段使用固定宽度大写十六进制。SType 以原始字节保存:0x00
+因表示 Data Message 而拒绝,未知非零值允许严格往返,便于保留互操作证据。
+
+控制帧按核心模型不能携带 Body,快照类型也没有 Body 字段。当前公共事件只
+转发未被 Select、Linktest、Deselect 或数据事务认领的控制帧,主要用于未匹配
+Reject;这个工具不会订阅内部状态机,也不是完整控制面抓包。控制信封不能
+进入消息重放器,头字段仍应按运维元数据执行访问控制与保留策略。
+
## 结构化脱敏
脱敏发生在不可变 Item 树上,而不是对 SML 文本做字符串替换:
@@ -179,4 +213,5 @@ var results = await new SecsTraceReplayer().ReplayWithTimingAsync(
测试还验证重新分配 Session ID/System Bytes 并获得新 Secondary。它证明
当前工具组合边界,不代表设备命令安全性或 SEMI 一致性认证。诊断信封另有
黄金向量、可选字段往返、严格枚举/十六进制、资源限制及异常/帧内容不泄漏
-测试。
+测试。控制信封另有未匹配 Reject 黄金向量、未知 SType 往返、Data Message
+拒绝、严格字段与资源限制测试。
diff --git a/src/SecsFrame.Trace/SecsTraceControlCodec.cs b/src/SecsFrame.Trace/SecsTraceControlCodec.cs
new file mode 100644
index 0000000..6316d40
--- /dev/null
+++ b/src/SecsFrame.Trace/SecsTraceControlCodec.cs
@@ -0,0 +1,266 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Text;
+
+namespace SecsFrame.Trace;
+
+/// Encodes and decodes restricted-field control-message trace files.
+public sealed class SecsTraceControlCodec
+{
+ /// The exact control-message trace format identifier.
+ public const string FormatIdentifier = "SecsFrame-ControlTrace/1";
+
+ /// The default maximum number of records in one control trace.
+ public const int DefaultMaxRecordCount = SecsTraceCodec.DefaultMaxRecordCount;
+
+ /// The default maximum control trace text length.
+ public const int DefaultMaxTextLength = SecsTraceCodec.DefaultMaxTextLength;
+
+ /// Creates a control trace codec with explicit resource limits.
+ public SecsTraceControlCodec(
+ int maxRecordCount = DefaultMaxRecordCount,
+ int maxTextLength = DefaultMaxTextLength)
+ {
+ if (maxRecordCount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxRecordCount), maxRecordCount, "The maximum record count must be positive.");
+ if (maxTextLength <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxTextLength), maxTextLength, "The maximum text length must be positive.");
+
+ MaxRecordCount = maxRecordCount;
+ MaxTextLength = maxTextLength;
+ }
+
+ /// Gets the maximum number of records in one control trace.
+ public int MaxRecordCount { get; }
+
+ /// Gets the maximum accepted or produced control trace length.
+ public int MaxTextLength { get; }
+
+ /// Encodes records in enumeration order using LF line endings.
+ public string Encode(IEnumerable records)
+ {
+ if (records is null)
+ throw new ArgumentNullException(nameof(records));
+
+ var text = new StringBuilder(FormatIdentifier);
+ text.Append('\n');
+ EnsureTextLength(text);
+
+ var recordCount = 0;
+ foreach (var record in records)
+ {
+ if (record is null)
+ throw new ArgumentException("The control trace sequence contains a null record.", nameof(records));
+ if (++recordCount > MaxRecordCount)
+ throw new InvalidOperationException($"The control trace record count exceeds the configured maximum {MaxRecordCount}.");
+
+ AppendRecord(text, record);
+ EnsureTextLength(text);
+ }
+
+ return text.ToString();
+ }
+
+ /// Strictly decodes one complete control trace file.
+ public IReadOnlyList Decode(string text)
+ {
+ if (text is null)
+ throw new ArgumentNullException(nameof(text));
+ if (text.Length > MaxTextLength)
+ throw new ArgumentOutOfRangeException(nameof(text), text.Length, $"The control trace text length cannot exceed {MaxTextLength} characters.");
+
+ return new Parser(text, this).Parse();
+ }
+
+ private static void AppendRecord(StringBuilder text, SecsTraceControlRecord record)
+ {
+ text.Append("Control ");
+ text.Append(record.Timestamp.UtcDateTime.ToString("O", CultureInfo.InvariantCulture));
+ text.Append(' ');
+ text.Append(record.Direction == SecsTraceDirection.Sent ? "Sent" : "Received");
+ text.Append(' ');
+ text.Append(record.State);
+ text.Append(' ');
+ text.Append(record.ProtocolSessionId.ToString(CultureInfo.InvariantCulture));
+ text.Append(' ');
+ AppendByte(text, record.HeaderByte2);
+ text.Append(' ');
+ AppendByte(text, record.HeaderByte3);
+ text.Append(' ');
+ AppendByte(text, record.PresentationType);
+ text.Append(' ');
+ AppendByte(text, record.MessageType);
+ text.Append(' ');
+ text.Append("0x");
+ text.Append(record.SystemBytes.ToString("X8", CultureInfo.InvariantCulture));
+ text.Append('\n');
+ }
+
+ private static void AppendByte(StringBuilder text, byte value)
+ {
+ text.Append("0x");
+ text.Append(value.ToString("X2", CultureInfo.InvariantCulture));
+ }
+
+ private void EnsureTextLength(StringBuilder text)
+ {
+ if (text.Length > MaxTextLength)
+ throw new InvalidOperationException($"The control trace text length exceeds the configured maximum {MaxTextLength}.");
+ }
+
+ private sealed class Parser
+ {
+ private readonly string _text;
+ private readonly SecsTraceControlCodec _options;
+ private int _index;
+ private int _recordIndex;
+
+ public Parser(string text, SecsTraceControlCodec options)
+ {
+ _text = text;
+ _options = options;
+ }
+
+ public IReadOnlyList Parse()
+ {
+ var headerOffset = _index;
+ var formatIdentifier = ReadLine();
+ if (!string.Equals(formatIdentifier, FormatIdentifier, StringComparison.Ordinal))
+ throw Error("The control trace format identifier is missing or unsupported", -1, headerOffset);
+
+ var records = new List();
+ while (!IsEnd)
+ {
+ if (_recordIndex >= _options.MaxRecordCount)
+ throw Error($"The control trace record count exceeds the configured maximum {_options.MaxRecordCount}", _recordIndex, _index);
+
+ records.Add(ParseRecord());
+ _recordIndex++;
+ }
+
+ return new ReadOnlyCollection(records);
+ }
+
+ private SecsTraceControlRecord ParseRecord()
+ {
+ var headerOffset = _index;
+ var fields = ReadLine().Split(new[] { ' ' }, StringSplitOptions.None);
+ if (fields.Length != 10 || !string.Equals(fields[0], "Control", StringComparison.Ordinal))
+ throw Error("A control record must contain exactly ten single-space-separated fields", _recordIndex, headerOffset);
+
+ var messageType = ParseByte(fields[8], "SType", headerOffset);
+ if (messageType == (byte)HsmsMessageType.DataMessage)
+ throw Error("A control-message SType must be nonzero", _recordIndex, headerOffset);
+
+ return new SecsTraceControlRecord(
+ ParseTimestamp(fields[1], headerOffset),
+ ParseDirection(fields[2], headerOffset),
+ ParseState(fields[3], headerOffset),
+ ParseSessionId(fields[4], headerOffset),
+ ParseByte(fields[5], "header byte 2", headerOffset),
+ ParseByte(fields[6], "header byte 3", headerOffset),
+ ParseByte(fields[7], "PType", headerOffset),
+ messageType,
+ ParseSystemBytes(fields[9], headerOffset));
+ }
+
+ private DateTimeOffset ParseTimestamp(string value, int offset)
+ {
+ if (!DateTimeOffset.TryParseExact(
+ value,
+ "O",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.RoundtripKind,
+ out var timestamp))
+ {
+ throw Error("The control timestamp must use the round-trip format", _recordIndex, offset);
+ }
+
+ return timestamp;
+ }
+
+ private SecsTraceDirection ParseDirection(string value, int offset)
+ => value switch
+ {
+ "Sent" => SecsTraceDirection.Sent,
+ "Received" => SecsTraceDirection.Received,
+ _ => throw Error("The control direction must be Sent or Received", _recordIndex, offset),
+ };
+
+ private HsmsSessionState ParseState(string value, int offset)
+ {
+ if (!Enum.TryParse(value, ignoreCase: false, out HsmsSessionState parsed) ||
+ !Enum.IsDefined(typeof(HsmsSessionState), parsed) ||
+ !string.Equals(value, parsed.ToString(), StringComparison.Ordinal))
+ {
+ throw Error("The HSMS session state is unknown", _recordIndex, offset);
+ }
+
+ return parsed;
+ }
+
+ private ushort ParseSessionId(string value, int offset)
+ {
+ if (!ushort.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed))
+ throw Error("The protocol Session ID must be an unsigned 16-bit integer", _recordIndex, offset);
+ return parsed;
+ }
+
+ private byte ParseByte(string value, string fieldName, int offset)
+ {
+ if (!HasExactHexForm(value, 2) ||
+ !byte.TryParse(value.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed))
+ {
+ throw Error($"The {fieldName} must use the exact form 0xHH", _recordIndex, offset);
+ }
+
+ return parsed;
+ }
+
+ private uint ParseSystemBytes(string value, int offset)
+ {
+ if (!HasExactHexForm(value, 8) ||
+ !uint.TryParse(value.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed))
+ {
+ throw Error("System Bytes must use the exact form 0xHHHHHHHH", _recordIndex, offset);
+ }
+
+ return parsed;
+ }
+
+ private static bool HasExactHexForm(string value, int digitCount)
+ {
+ if (value.Length != digitCount + 2 || value[0] != '0' || value[1] != 'x')
+ return false;
+
+ for (var index = 2; index < value.Length; index++)
+ {
+ var character = value[index];
+ if (!((character >= '0' && character <= '9') || (character >= 'A' && character <= 'F')))
+ return false;
+ }
+
+ return true;
+ }
+
+ private string ReadLine()
+ {
+ var start = _index;
+ while (!IsEnd && _text[_index] != '\n')
+ _index++;
+
+ var length = _index - start;
+ if (length > 0 && _text[start + length - 1] == '\r')
+ length--;
+ var line = _text.Substring(start, length);
+ if (!IsEnd)
+ _index++;
+ return line;
+ }
+
+ private bool IsEnd => _index >= _text.Length;
+
+ private static SecsTraceParseException Error(string message, int recordIndex, int offset)
+ => new(message, recordIndex, offset);
+ }
+}
diff --git a/src/SecsFrame.Trace/SecsTraceControlRecord.cs b/src/SecsFrame.Trace/SecsTraceControlRecord.cs
new file mode 100644
index 0000000..8660198
--- /dev/null
+++ b/src/SecsFrame.Trace/SecsTraceControlRecord.cs
@@ -0,0 +1,114 @@
+namespace SecsFrame.Trace;
+
+///
+/// Describes a restricted-field snapshot of one HSMS control-message header.
+///
+///
+/// The record stores only the ten-byte header fields and never contains a frame body.
+///
+public sealed class SecsTraceControlRecord
+{
+ /// Creates an immutable control-message metadata record.
+ public SecsTraceControlRecord(
+ DateTimeOffset timestamp,
+ SecsTraceDirection direction,
+ HsmsSessionState state,
+ ushort protocolSessionId,
+ byte headerByte2,
+ byte headerByte3,
+ byte presentationType,
+ byte messageType,
+ uint systemBytes)
+ {
+ if (direction != SecsTraceDirection.Sent && direction != SecsTraceDirection.Received)
+ throw new ArgumentOutOfRangeException(nameof(direction), direction, "Unknown trace direction.");
+ if (!Enum.IsDefined(typeof(HsmsSessionState), state))
+ throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown HSMS session state.");
+ if (messageType == (byte)HsmsMessageType.DataMessage)
+ throw new ArgumentOutOfRangeException(nameof(messageType), messageType, "A control-message SType must be nonzero.");
+
+ Timestamp = timestamp.ToUniversalTime();
+ Direction = direction;
+ State = state;
+ ProtocolSessionId = protocolSessionId;
+ HeaderByte2 = headerByte2;
+ HeaderByte3 = headerByte3;
+ PresentationType = presentationType;
+ MessageType = messageType;
+ SystemBytes = systemBytes;
+ }
+
+ /// Gets the UTC timestamp associated with the local observation.
+ public DateTimeOffset Timestamp { get; }
+
+ /// Gets the local message direction.
+ public SecsTraceDirection Direction { get; }
+
+ /// Gets the HSMS session state observed with the control message.
+ public HsmsSessionState State { get; }
+
+ /// Gets the raw protocol Session ID.
+ public ushort ProtocolSessionId { get; }
+
+ /// Gets raw HSMS header byte 2.
+ public byte HeaderByte2 { get; }
+
+ /// Gets raw HSMS header byte 3.
+ public byte HeaderByte3 { get; }
+
+ /// Gets the raw PType byte.
+ public byte PresentationType { get; }
+
+ /// Gets the raw nonzero SType byte.
+ public byte MessageType { get; }
+
+ /// Gets the raw System Bytes value.
+ public uint SystemBytes { get; }
+
+ /// Creates a metadata record from a control frame.
+ public static SecsTraceControlRecord Create(
+ DateTimeOffset timestamp,
+ SecsTraceDirection direction,
+ HsmsSessionState state,
+ HsmsFrame frame)
+ {
+ if (frame is null)
+ throw new ArgumentNullException(nameof(frame));
+ if (frame.Header.IsDataMessage || !frame.Body.IsEmpty)
+ throw new ArgumentException("The frame must be an HSMS control message without a body.", nameof(frame));
+
+ var header = frame.Header;
+ return new SecsTraceControlRecord(
+ timestamp,
+ direction,
+ state,
+ header.SessionId,
+ header.HeaderByte2,
+ header.HeaderByte3,
+ header.PresentationType,
+ (byte)header.MessageType,
+ header.SystemBytes);
+ }
+
+ ///
+ /// Creates a received metadata record from an unclaimed public control event.
+ ///
+ public static SecsTraceControlRecord CreateReceived(
+ DateTimeOffset timestamp,
+ HsmsConnectionEvent connectionEvent)
+ {
+ if (connectionEvent is null)
+ throw new ArgumentNullException(nameof(connectionEvent));
+ if (connectionEvent.Kind != HsmsConnectionEventKind.ControlMessageReceived ||
+ connectionEvent.Frame is null)
+ {
+ throw new ArgumentException("The connection event must contain an unclaimed control message.", nameof(connectionEvent));
+ }
+
+ return Create(
+ timestamp,
+ SecsTraceDirection.Received,
+ connectionEvent.State,
+ connectionEvent.Frame);
+ }
+}
diff --git a/test/SecsFrame.Tests/SecsTraceControlTests.cs b/test/SecsFrame.Tests/SecsTraceControlTests.cs
new file mode 100644
index 0000000..0bebee3
--- /dev/null
+++ b/test/SecsFrame.Tests/SecsTraceControlTests.cs
@@ -0,0 +1,156 @@
+using SecsFrame.Trace;
+
+namespace SecsFrame.Tests;
+
+public sealed class SecsTraceControlTests
+{
+ [Fact]
+ public void Codec_produces_a_deterministic_unclaimed_reject_vector()
+ {
+ var frame = new HsmsFrame(HsmsMessageHeader.CreateReject(
+ systemBytes: 0x10203040,
+ rejectedMessageType: (byte)HsmsMessageType.LinktestRequest,
+ reason: 0x03));
+ var connectionEvent = HsmsConnectionEvent.ControlMessageReceived(
+ HsmsSessionState.Selected,
+ frame);
+ var timestamp = new DateTimeOffset(2026, 8, 29, 13, 0, 0, TimeSpan.FromHours(8));
+ var record = SecsTraceControlRecord.CreateReceived(timestamp, connectionEvent);
+ var codec = new SecsTraceControlCodec();
+
+ var text = codec.Encode(new[] { record });
+
+ Assert.Equal(
+ "SecsFrame-ControlTrace/1\n" +
+ "Control 2026-08-29T05:00:00.0000000Z Received Selected 65535 0x05 0x03 0x00 0x07 0x10203040\n",
+ text);
+ AssertRecordEqual(record, Assert.Single(codec.Decode(text)));
+ }
+
+ [Fact]
+ public void Codec_preserves_unknown_nonzero_stype_and_sent_direction()
+ {
+ var record = new SecsTraceControlRecord(
+ Epoch,
+ SecsTraceDirection.Sent,
+ HsmsSessionState.Connected,
+ protocolSessionId: 0x1234,
+ headerByte2: 0x56,
+ headerByte3: 0x78,
+ presentationType: 0x9A,
+ messageType: 0x7F,
+ systemBytes: 0xABCDEF01);
+ var codec = new SecsTraceControlCodec();
+
+ var decoded = Assert.Single(codec.Decode(codec.Encode(new[] { record })));
+
+ AssertRecordEqual(record, decoded);
+ }
+
+ [Fact]
+ public void Factories_reject_data_frames_and_non_control_events()
+ {
+ var dataFrame = new HsmsFrame(
+ HsmsMessageHeader.CreateData(10, 1, 1, false, 1));
+ var decodeEvent = HsmsConnectionEvent.DataMessageDecodeFailed(
+ dataFrame,
+ new SecsProtocolException("Invalid data."));
+
+ Assert.Throws(() => SecsTraceControlRecord.Create(
+ Epoch,
+ SecsTraceDirection.Received,
+ HsmsSessionState.Selected,
+ dataFrame));
+ Assert.Throws(() => SecsTraceControlRecord.CreateReceived(Epoch, decodeEvent));
+ Assert.Throws(() => SecsTraceControlRecord.Create(
+ Epoch,
+ SecsTraceDirection.Received,
+ HsmsSessionState.Selected,
+ null!));
+ Assert.Throws(() => SecsTraceControlRecord.CreateReceived(Epoch, null!));
+ }
+
+ [Fact]
+ public void Codec_rejects_malformed_fields_data_stype_and_spacing()
+ {
+ const string valid =
+ "SecsFrame-ControlTrace/1\n" +
+ "Control 1970-01-01T00:00:00.0000000Z Received Selected 65535 0x05 0x03 0x00 0x07 0x10203040\n";
+ var codec = new SecsTraceControlCodec();
+
+ Assert.Throws(() => codec.Decode(string.Empty));
+ Assert.Throws(() => codec.Decode(valid.Replace("ControlTrace/1", "ControlTrace/2")));
+ Assert.Throws(() => codec.Decode(valid.Replace("Received", "Inbound")));
+ Assert.Throws(() => codec.Decode(valid.Replace("Selected", "selected")));
+ Assert.Throws(() => codec.Decode(valid.Replace("0x05", "0x5")));
+ Assert.Throws(() => codec.Decode(valid.Replace("0x03", "0x0a")));
+ Assert.Throws(() => codec.Decode(valid.Replace("0x07", "0x00")));
+ Assert.Throws(() => codec.Decode(valid.Replace("Control ", "Control ")));
+ }
+
+ [Fact]
+ public void Codec_and_record_enforce_resource_and_value_boundaries()
+ {
+ var record = new SecsTraceControlRecord(
+ Epoch,
+ SecsTraceDirection.Received,
+ HsmsSessionState.Selected,
+ ushort.MaxValue,
+ 0,
+ 0,
+ 0,
+ (byte)HsmsMessageType.LinktestRequest,
+ 1);
+ var records = new[] { record, record };
+ var codec = new SecsTraceControlCodec();
+ var text = codec.Encode(records);
+
+ Assert.Throws(() => codec.Encode(null!));
+ Assert.Throws(() => codec.Decode(null!));
+ Assert.Throws(() => codec.Encode(new SecsTraceControlRecord[] { record, null! }));
+ Assert.Throws(() => new SecsTraceControlCodec(maxRecordCount: 1).Encode(records));
+ Assert.Throws(() => new SecsTraceControlCodec(maxRecordCount: 1).Decode(text));
+ Assert.Throws(() => new SecsTraceControlCodec(maxTextLength: 20).Encode(Array.Empty()));
+ Assert.Throws(() => new SecsTraceControlCodec(maxTextLength: 20).Decode(text));
+ Assert.Throws(() => new SecsTraceControlCodec(maxRecordCount: 0));
+ Assert.Throws(() => new SecsTraceControlCodec(maxTextLength: 0));
+ Assert.Throws(() => new SecsTraceControlRecord(
+ Epoch,
+ (SecsTraceDirection)int.MaxValue,
+ HsmsSessionState.Selected,
+ ushort.MaxValue,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1));
+ Assert.Throws(() => new SecsTraceControlRecord(
+ Epoch,
+ SecsTraceDirection.Received,
+ HsmsSessionState.Selected,
+ ushort.MaxValue,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1));
+ }
+
+ private static DateTimeOffset Epoch
+ => new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ private static void AssertRecordEqual(
+ SecsTraceControlRecord expected,
+ SecsTraceControlRecord actual)
+ {
+ Assert.Equal(expected.Timestamp, actual.Timestamp);
+ Assert.Equal(expected.Direction, actual.Direction);
+ Assert.Equal(expected.State, actual.State);
+ Assert.Equal(expected.ProtocolSessionId, actual.ProtocolSessionId);
+ Assert.Equal(expected.HeaderByte2, actual.HeaderByte2);
+ Assert.Equal(expected.HeaderByte3, actual.HeaderByte3);
+ Assert.Equal(expected.PresentationType, actual.PresentationType);
+ Assert.Equal(expected.MessageType, actual.MessageType);
+ Assert.Equal(expected.SystemBytes, actual.SystemBytes);
+ }
+}