Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@
无处理器或应用拒绝返回显式失败应答。
- 报告配置严格拒绝畸形 Item、重复标识、未知变量和未知报告,成功配置在
应答写出前原子可见,替换报告集会移除引用已删除报告的旧事件链接。
- 增加可替换的报警通知消息对、不可变动态报警模型、Equipment 发送 API
和可释放的 Host 单处理器注册;报警代码按原始字节透传,不推断位语义。
- 报警通知严格校验 W-Bit、三字段正文、单字节 Binary 代码、ASCII 文本、
Secondary 和应答值,并增加协议向量及真实 TCP 接受/拒绝测试。
- 增加定义、链接、S6F11 动态 Item 向量和真实 TCP 垂直测试,覆盖嵌套值、
空报告、配置拒绝、触发前快照与 Host 拒绝。

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor
配置、动态消息收发、状态等待和单消费者事件流。
独立 <code>SecsFrame.Gem</code> 已提供可配置 GEM 通用行为,包含通讯建立、
上下线、动态变量/常量、应用托管时钟、报告定义、事件链接和 Collection
Event。Item 用法与边界见
Event,以及最小报警通知链路。Item 用法与边界见
[docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见
[docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见
[docs/STREAMFRAME-ADAPTER.md](docs/STREAMFRAME-ADAPTER.md),状态机边界见
Expand Down
34 changes: 26 additions & 8 deletions docs/GEM-FOUNDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<code>SecsFrame.Gem</code> 是只依赖 <code>SecsFrame</code> 的独立程序集。
当前切片提供 Host/Equipment 两侧的通讯建立、在线/离线、状态变量读取、
设备常量读取、应用托管时钟、报告定义、事件链接和 Collection Event,
不把 GEM 状态或消息目录放入 HSMS 状态机。
以及最小报警通知链路,不把 GEM 状态或消息目录放入 HSMS 状态机。

<code>GemHostServices</code> 依赖 <code>SecsHost</code>;
<code>GemEquipmentServices</code> 依赖 <code>SecsEquipment</code>。服务不拥有
Expand All @@ -30,6 +30,7 @@
| 报告定义 | S2F33 | S2F34 |
| 事件链接 | S2F35 | S2F36 |
| Collection Event | S6F11 | S6F12 |
| 报警通知 | S5F1 | S5F2 |

这张表描述仓库自己的默认配置,不是复制的 SEMI 规范表,也不表示已经
核对 E30 的必选能力、状态条件、数据项定义、报告生命周期或应答枚举。
Expand Down Expand Up @@ -98,6 +99,20 @@ Host 使用 <code>RegisterCollectionEventHandler</code> 注册单一可释放处
应答;没有处理器时明确失败。释放注册只影响后续事件,已经取得处理器快照
的分派会完成。应用处理器异常和取消继续传播给事件循环,不被库隐藏。

## 报警通知

Equipment 使用 <code>GemAlarmNotification</code> 提供一个精确 Binary 代码
字节、动态 <code>SecsItem</code> 报警标识和七位 ASCII 文本,再通过
<code>SendAlarmNotificationAsync</code> 发送。库原样保留代码字节,不解释
其中的 set/clear、类别或厂商位;这些语义在取得授权 E30 副本并完成设备
互操作核对前由应用负责。

Host 使用 <code>RegisterAlarmNotificationHandler</code> 注册单一可释放
处理器。处理器返回 <code>true</code> 发送成功应答,返回
<code>false</code> 或没有处理器时发送失败应答。注册快照、释放、异常和
取消语义与 Collection Event 处理器一致。本切片不包含报警目录查询、
启停/屏蔽、历史、持久化或基于 GEM 状态的发送门控。

## 严格失败边界

当前实现严格要求:
Expand All @@ -110,24 +125,27 @@ Host 使用 <code>RegisterCollectionEventHandler</code> 注册单一可释放处
- 时钟字符串由显式 codec 完整解析;
- 报告/链接请求是二元素 List,Collection Event 是三元素 List,各嵌套项
的标识和值列表形状必须完整且标识不能产生歧义;
- 报警通知是三元素 List,代码必须是恰好一个 Binary 字节,文本必须为
ASCII,报警标识保持为任意非 null 动态 Item;
- 报告配置请求和 Collection Event 必须设置 W-Bit,Secondary 的消息对和
应答字节必须与 profile 匹配。
应答字节必须与 profile 匹配;报警通知遵循相同要求。

畸形输入和提供器失败会作为异常返回应用事件循环;语义有效但引用未知变量
或报告的配置使用失败应答拒绝。本切片尚未实现自动 S9Fx、错误 Secondary、
通讯建立策略拒绝、状态切换策略、报警、远程命令或完整 GEM 错误恢复,
这些行为不能从当前 API 推断为标准合规。
通讯建立策略拒绝、状态切换策略、报警目录/启停/历史、远程命令或完整
GEM 错误恢复,这些行为不能从当前 API 推断为标准合规。

## 标准与验证边界

公开 SEMI 目录当前标识 GEM 基线为 E30-0526。仍需使用团队合法获得的
副本核对消息条件、COMMACK/ONLACK/OFLACK/TIACK 语义、MDLN 条件结构、
空 SVID/ECID 列表行为、报告定义与链接生命周期、Collection Event 数据
结构和应答、时间格式选择、状态转换和错误响应。不得把标准正文、消息表、
状态图或 Schema 提交仓库。完整版本和版权边界见
结构和应答、报警通知结构与代码位语义、时间格式选择、状态转换和错误
响应。不得把标准正文、消息表、状态图或 Schema 提交仓库。完整版本和版权
边界见
[STANDARDS.md](STANDARDS.md)。

当前证据包括三目标框架的严格输入测试,以及 Host Active / Equipment
Passive 真实 TCP 下的双向通讯建立、上下线、异构动态标识、报告定义、
事件链接、Collection Event 嵌套值与空报告、配置/事件拒绝、时钟读写和
Linktest。它们是独立工程验证,不是 SEMI 一致性认证。
事件链接、Collection Event 嵌套值与空报告、报警通知接受/拒绝、配置/事件
拒绝、时钟读写和 Linktest。它们是独立工程验证,不是 SEMI 一致性认证。
10 changes: 6 additions & 4 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@
- [x] 通讯建立、在线/离线状态、动态变量读取、设备常量和应用托管时钟。
- [x] 面向 Host 与 Equipment 的独立基础能力 API 和真实 TCP 垂直测试。
- [x] 报告定义、事件链接和 Collection Event。
- [ ] 报警、远程命令和更完整的 GEM 状态/错误恢复。
- [x] 报警通知的动态标识、代码透传、Host 处理器和 Equipment 发送链路。
- [ ] 报警目录/启停/历史、远程命令和更完整的 GEM 状态/错误恢复。
- [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。

当前最小报告切片使用可替换工程 profile、完整配置集原子替换和动态
<code>SecsItem</code> 标识,不宣称已经核对 E30 的报告生命周期或应答
枚举。下一优先级是报警与远程命令,再扩展更完整的 GEM 状态和错误恢复。
当前报告和报警通知切片使用可替换工程 profile、动态
<code>SecsItem</code> 标识,并保留报警代码原始字节,不宣称已经核对
E30 的报告生命周期、报警代码位语义或应答枚举。下一优先级是远程命令,
再扩展报警管理以及更完整的 GEM 状态和错误恢复。

## 阶段 3:元数据与工具

Expand Down
4 changes: 2 additions & 2 deletions docs/SECS-ENDPOINT-ROLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,6 @@ await equipment.WaitUntilSelectedAsync(cancellationToken);
而不是修改 HSMS 状态机或根据 Active/Passive 猜测角色。

当前 GEM 切片已实现工程配置下的通讯建立、在线状态、变量、设备常量、
时钟、报告定义、事件链接和 Collection Event;报警与远程命令仍未实现。
相关行为、事件循环要求和未核对边界见
时钟、报告定义、事件链接、Collection Event 和最小报警通知;报警目录、
启停/历史与远程命令仍未实现。相关行为、事件循环要求和未核对边界见
[GEM-FOUNDATION.md](GEM-FOUNDATION.md),项目不声明 GEM 合规。
26 changes: 26 additions & 0 deletions src/SecsFrame.Gem/GemAlarmNotification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace SecsFrame.Gem;

/// <summary>Contains one decoded alarm notification.</summary>
public sealed class GemAlarmNotification
{
/// <summary>Creates an alarm notification value.</summary>
public GemAlarmNotification(byte code, SecsItem alarmId, string text)
{
Code = code;
AlarmId = alarmId ?? throw new ArgumentNullException(nameof(alarmId));
Text = text ?? throw new ArgumentNullException(nameof(text));

_ = SecsItem.Ascii(text);
}

/// <summary>
/// Gets the exact alarm code byte without interpreting its bit fields.
/// </summary>
public byte Code { get; }

/// <summary>Gets the exact dynamic alarm identifier.</summary>
public SecsItem AlarmId { get; }

/// <summary>Gets the seven-bit ASCII alarm text.</summary>
public string Text { get; }
}
7 changes: 7 additions & 0 deletions src/SecsFrame.Gem/GemAlarmNotificationHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SecsFrame.Gem;

/// <summary>Handles a decoded alarm notification and chooses its acknowledgement.</summary>
/// <returns><see langword="true"/> to accept the notification; otherwise false.</returns>
public delegate ValueTask<bool> GemAlarmNotificationHandler(
GemAlarmNotification notification,
CancellationToken cancellationToken);
21 changes: 21 additions & 0 deletions src/SecsFrame.Gem/GemAlarmNotificationRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace SecsFrame.Gem;

/// <summary>Owns one Host alarm-notification handler registration.</summary>
public sealed class GemAlarmNotificationRegistration : IDisposable
{
private Action<GemAlarmNotificationRegistration>? _unregister;

internal GemAlarmNotificationRegistration(
GemAlarmNotificationHandler handler,
Action<GemAlarmNotificationRegistration> unregister)
{
Handler = handler;
_unregister = unregister;
}

internal GemAlarmNotificationHandler Handler { get; }

/// <summary>Removes this exact handler registration. Repeated calls are harmless.</summary>
public void Dispose()
=> Interlocked.Exchange(ref _unregister, null)?.Invoke(this);
}
20 changes: 20 additions & 0 deletions src/SecsFrame.Gem/GemEquipmentServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,26 @@ public async Task SendCollectionEventAsync(
"Collection Event reply"));
}

/// <summary>Sends one alarm notification to the Host.</summary>
public async Task SendAlarmNotificationAsync(
GemAlarmNotification notification,
CancellationToken cancellationToken = default)
{
if (notification is null)
throw new ArgumentNullException(nameof(notification));

var response = await _services.SendRequestAsync(
Profile.AlarmNotification,
GemOperation.AlarmNotification,
GemMessageCodec.EncodeAlarmNotification(notification),
cancellationToken).ConfigureAwait(false);
_services.RequireAccepted(
GemOperation.AlarmNotification,
GemMessageCodec.DecodeAcknowledgement(
response.Message.RootItem,
"alarm-notification reply"));
}

/// <summary>
/// Observes session state and dispatches a registered GEM or application route.
/// </summary>
Expand Down
63 changes: 63 additions & 0 deletions src/SecsFrame.Gem/GemHostServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public sealed class GemHostServices : IDisposable
private readonly object _gate = new();
#endif
private GemCollectionEventRegistration? _collectionEventHandler;
private GemAlarmNotificationRegistration? _alarmNotificationHandler;
private int _disposed;

/// <summary>Creates Host GEM services without taking ownership of the endpoint.</summary>
Expand All @@ -30,6 +31,9 @@ public GemHostServices(
_services.AddRoute(
_services.Profile.CollectionEvent,
HandleCollectionEventAsync);
_services.AddRoute(
_services.Profile.AlarmNotification,
HandleAlarmNotificationAsync);
}
catch
{
Expand Down Expand Up @@ -191,6 +195,30 @@ public GemCollectionEventRegistration RegisterCollectionEventHandler(
}
}

/// <summary>Registers the single application alarm-notification handler.</summary>
public GemAlarmNotificationRegistration RegisterAlarmNotificationHandler(
GemAlarmNotificationHandler handler)
{
if (handler is null)
throw new ArgumentNullException(nameof(handler));

lock (_gate)
{
ThrowIfDisposed();
if (_alarmNotificationHandler is not null)
{
throw new InvalidOperationException(
"An alarm-notification handler is already registered.");
}

var registration = new GemAlarmNotificationRegistration(
handler,
UnregisterAlarmNotificationHandler);
_alarmNotificationHandler = registration;
return registration;
}
}

/// <summary>
/// Observes session state and dispatches a registered GEM or application route.
/// </summary>
Expand All @@ -206,7 +234,10 @@ public void Dispose()
return;

lock (_gate)
{
_collectionEventHandler = null;
_alarmNotificationHandler = null;
}
_services.Dispose();
}

Expand Down Expand Up @@ -258,6 +289,38 @@ private void UnregisterCollectionEventHandler(
}
}

private async ValueTask<SecsMessage?> HandleAlarmNotificationAsync(
HsmsPrimaryContext context,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
GemMessageCodec.RequireReplyExpected(context, "alarm notification");
var notification = GemMessageCodec.DecodeAlarmNotification(
context.Message.RootItem);
GemAlarmNotificationHandler? handler;
lock (_gate)
handler = _alarmNotificationHandler?.Handler;

var accepted = handler is not null &&
await handler(notification, cancellationToken).ConfigureAwait(false);
return GemEndpointServices.CreateSecondary(
Profile.AlarmNotification,
GemMessageCodec.EncodeAcknowledgement(
accepted
? Profile.AcceptedAcknowledgement
: Profile.FailedAcknowledgement));
}

private void UnregisterAlarmNotificationHandler(
GemAlarmNotificationRegistration registration)
{
lock (_gate)
{
if (ReferenceEquals(_alarmNotificationHandler, registration))
_alarmNotificationHandler = null;
}
}

private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
Expand Down
38 changes: 33 additions & 5 deletions src/SecsFrame.Gem/GemMessageCodec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,17 @@ internal static SecsItem EncodeAcknowledgement(byte value)
=> SecsItem.Binary(value);

internal static byte DecodeAcknowledgement(SecsItem? root, string operation)
=> RequireBinaryByte(root, $"{operation} acknowledgement");

private static byte RequireBinaryByte(SecsItem? item, string field)
{
if (root is null || root.Format != SecsItemFormat.Binary || root.Count != 1)
if (item is null || item.Format != SecsItemFormat.Binary || item.Count != 1)
{
throw new GemProtocolException(
$"The {operation} body must be one Binary acknowledgement byte.");
$"The {field} must be one Binary byte.");
}

return root.GetValues<byte>()[0];
return item.GetValues<byte>()[0];
}

internal static SecsItem EncodeIdentifiers(IEnumerable<SecsItem> identifiers)
Expand Down Expand Up @@ -223,6 +226,28 @@ internal static GemCollectionEvent DecodeCollectionEvent(SecsItem? root)
reports);
}

internal static SecsItem EncodeAlarmNotification(
GemAlarmNotification notification)
{
if (notification is null)
throw new ArgumentNullException(nameof(notification));

return SecsItem.List(
SecsItem.Binary(notification.Code),
notification.AlarmId,
SecsItem.Ascii(notification.Text));
}

internal static GemAlarmNotification DecodeAlarmNotification(SecsItem? root)
{
const string operation = "alarm notification";
var body = RequireList(root, 3, operation);
return new GemAlarmNotification(
RequireBinaryByte(body[0], "alarm-notification code"),
body[1],
RequireAscii(body[2], operation, "text"));
}

internal static SecsItem EncodeClock(DateTimeOffset value, GemClockCodec codec)
=> SecsItem.Ascii(codec.Encode(value));

Expand Down Expand Up @@ -294,12 +319,15 @@ private static IReadOnlyList<SecsItem> RequireList(
return root.Items;
}

private static string RequireAscii(SecsItem item, string operation)
private static string RequireAscii(
SecsItem item,
string operation,
string field = "identity value")
{
if (item.Format != SecsItemFormat.Ascii)
{
throw new GemProtocolException(
$"The {operation} identity values must be ASCII.");
$"The {operation} {field} must be ASCII.");
}

return item.GetString();
Expand Down
Loading
Loading