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 @@ -106,6 +106,10 @@
通讯/在线状态、DATAID 和 CEID 决定是否采值并发送,未注册时保持现有行为。
- Collection Event 策略拒绝在状态变量提供器和 S6F11 事务之前短路;应用
改变决策后可在同一会话显式重试。增加注册生命周期和真实 TCP 状态测试。
- 增加可释放的 Equipment 报警通知发送策略;应用可依据当前通讯/在线状态
和完整通知决定是否发送,未注册时保持现有行为。
- 已注册报警的发送启停检查优先于应用策略;策略拒绝在 S5F1 事务之前短路,
改变决策后可在同一会话显式重试。增加注册生命周期和真实 TCP 状态测试。
- 增加可替换的报警目录查询消息对、不可变报警定义、Equipment 运行期注册
和 Host 全量/按动态标识查询;全量目录保持注册顺序,未知标识被忽略。
- 报警目录严格校验 W-Bit、请求 List、标识唯一性、三字段定义、单字节
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor
建立及应用接受策略、同会话显式重试恢复、上下线与应用转换策略、动态变量/
常量、应用托管时钟、报告定义、事件链接和带 Equipment 应用发送策略的
Collection Event、报警目录查询、单报警发送启停,以及最小报警通知、
远程命令链路和应用状态接受策略。
Equipment 报警通知发送策略、远程命令链路和应用状态接受策略。
Item 用法与边界见
[docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见
[docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见
Expand Down
35 changes: 26 additions & 9 deletions docs/GEM-FOUNDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ Equipment 可使用 <code>RegisterOnlineStateTransitionHandler</code> 注册
应用应遵守角色端点的单消费者事件循环约束,使决策和状态转换按请求顺序
执行。

除前述通讯建立/在线转换策略外,后文还为远程命令和 Collection Event
提供显式应用策略。当前仍不把变量读取、常量、时钟、报警通知等其他操作
硬性门控在某个 GEM 状态上,也不自动重新建立通讯。这样可在尚未获得授权
E30 状态表前避免把未经核对的状态条件固化为公共行为。
除前述通讯建立/在线转换策略外,后文还为远程命令、Collection Event 和
报警通知提供显式应用策略。当前仍不把变量读取、常量、时钟等其他操作硬性
门控在某个 GEM 状态上,也不自动重新建立通讯。这样可在尚未获得授权 E30
状态表前避免把未经核对的状态条件固化为公共行为。

## 动态数据与时钟

Expand Down Expand Up @@ -162,7 +162,8 @@ Host 使用 <code>RegisterAlarmNotificationHandler</code> 注册单一可释放
处理器。处理器返回 <code>true</code> 发送成功应答,返回
<code>false</code> 或没有处理器时发送失败应答。注册快照、释放、异常和
取消语义与 Collection Event 处理器一致。本切片不解释报警条件状态,也
不包含历史、持久化或基于 GEM 在线状态的发送门控。
不包含历史或持久化。Equipment 发送策略只向应用暴露当前状态,不内置
基于 GEM 在线状态的矩阵。

Equipment 使用 <code>RegisterAlarm</code> 注册不可变
<code>GemAlarmDefinition</code>。报警定义保留原始单字节代码、动态报警
Expand All @@ -186,6 +187,21 @@ Host 使用 <code>SetAlarmSendEnabledAsync</code> 控制一个已注册报警。
检查的并发发送会继续完成。未注册 ID 的原始通知保持既有发送行为。释放并
重新注册会恢复默认允许状态,HSMS 会话变化不会重置注册状态。

Equipment 可另用
<code>RegisterAlarmNotificationSendPolicyHandler</code> 注册单一可释放
发送策略。每个通过上述单报警发送许可检查的通知会把当前
<code>CommunicationState</code>、<code>OnlineState</code> 和完整
<code>GemAlarmNotification</code> 交给策略。返回 <code>false</code>
时 <code>SendAlarmNotificationAsync</code> 抛出本地
<code>InvalidOperationException</code>,不创建 S5F1 事务,因此 Host
不会收到消息。已禁用的已注册报警在策略之前拒绝,策略不会执行。

没有注册策略时继续发送,保持此前行为。发送许可、策略与状态在 Equipment
锁内按调用时一起快照,策略在锁外执行;释放注册只影响后续发送,已取得的
策略快照会完成。策略异常和取消直接传播给调用方。应用可以改变策略结果并
在同一 Selected 会话显式重试;库不自动重试,也不内置未经授权 E30-0526
核对的通讯/在线状态矩阵。

当前 API 故意不提供“全部报警”启停、批量控制、报警条件屏蔽或历史。
这些语义以及控制码含义仍需依据授权 E30-0526 和目标设备互操作结果核对。

Expand Down Expand Up @@ -271,7 +287,8 @@ Passive 真实 TCP 下的双向通讯建立、首次建立拒绝后同会话恢
重新建立拒绝时的状态/身份保持与恢复、在线转换接受/拒绝与状态保持、异构
动态标识、报告定义、
事件链接、Collection Event 嵌套值与空报告、应用发送策略拒绝零采值/零
事务和同会话重试、报警通知接受/拒绝、配置/事件拒绝、报警目录全量/选择
查询与释放快照、单报警发送启停与未知标识拒绝、远程命令动态参数与结果、
无处理器拒绝、应用状态拒绝短路与同会话重试、时钟读写和 Linktest。它们
是独立工程验证,不是 SEMI 一致性认证。
事务和同会话重试、报警通知接受/拒绝、应用发送策略拒绝零事务与同会话
重试、已禁用报警优先拒绝、配置/事件拒绝、报警目录全量/选择查询与释放
快照、单报警发送启停与未知标识拒绝、远程命令动态参数与结果、无处理器
拒绝、应用状态拒绝短路与同会话重试、时钟读写和 Linktest。它们是独立
工程验证,不是 SEMI 一致性认证。
14 changes: 9 additions & 5 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ epoch 收口与 Passive 监听加固。后续切片继续扩展 GEM 通用行为
- [x] Equipment 入站远程命令的应用状态接受策略、拒绝短路与同会话重试。
- [x] Equipment Collection Event 发送的应用状态策略、拒绝零采值/零事务
与同会话重试。
- [ ] 报警通知等其余业务消息的应用状态门控。
- [x] Equipment 报警通知发送的应用状态策略、发送启停优先级、拒绝零事务
与同会话重试。
- [ ] 报警历史/批量控制、命令目录/权限/调度和更完整的状态/错误恢复。
- [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。

Expand All @@ -62,11 +63,14 @@ epoch 收口与 Passive 监听加固。后续切片继续扩展 GEM 通用行为
依据当前通讯/在线状态决定是否执行命令,库不内置未经 E30 核对的状态矩阵。
Collection Event 发送策略沿用相同的默认不注册模式;拒绝发生在动态值
采集和 S6F11 事务之前,应用改变决策后可在同一会话显式重试。
报警通知发送策略同样默认不注册;已注册报警的发送启停检查优先,策略拒绝
发生在 S5F1 事务之前,并可在同一会话显式重试。至此当前 Equipment 主动
业务发送入口的应用状态策略已经闭环。

下一优先级是把此模式扩展到报警通知发送入口。最小切片应让应用依据当前
通讯/在线状态和通知内容决策,在既有单报警发送启停检查之后、创建 S5F1
事务之前拒绝,并继续保持默认不注册;随后再评估自动恢复、报警历史和批量
控制。
下一优先级转向报警历史/批量控制。最小切片应先用授权 E30-0526 核对消息
条件、数据结构和代码语义,再确定可替换 profile 下的不可变历史模型、
Equipment 快照边界、Host 查询 API、严格向量和真实 TCP 证据;核对完成前
不固化新的默认消息对。自动恢复和命令目录/权限/调度继续保持后续评估项。

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

Expand Down
8 changes: 8 additions & 0 deletions src/SecsFrame.Gem/GemAlarmNotificationSendPolicyHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace SecsFrame.Gem;

/// <summary>Decides whether one alarm notification may be sent.</summary>
public delegate ValueTask<bool> GemAlarmNotificationSendPolicyHandler(
GemCommunicationState communicationState,
GemOnlineState onlineState,
GemAlarmNotification notification,
CancellationToken cancellationToken);
21 changes: 21 additions & 0 deletions src/SecsFrame.Gem/GemAlarmNotificationSendPolicyRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace SecsFrame.Gem;

/// <summary>Owns one Equipment alarm-notification send-policy registration.</summary>
public sealed class GemAlarmNotificationSendPolicyRegistration : IDisposable
{
private Action<GemAlarmNotificationSendPolicyRegistration>? _unregister;

internal GemAlarmNotificationSendPolicyRegistration(
GemAlarmNotificationSendPolicyHandler handler,
Action<GemAlarmNotificationSendPolicyRegistration> unregister)
{
Handler = handler;
_unregister = unregister;
}

internal GemAlarmNotificationSendPolicyHandler Handler { get; }

/// <summary>Removes this exact policy registration. Repeated calls are harmless.</summary>
public void Dispose()
=> Interlocked.Exchange(ref _unregister, null)?.Invoke(this);
}
60 changes: 60 additions & 0 deletions src/SecsFrame.Gem/GemEquipmentServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public sealed class GemEquipmentServices : IDisposable
private GemOnlineStateTransitionRegistration? _onlineStateTransitionHandler;
private GemCollectionEventSendPolicyRegistration?
_collectionEventSendPolicyHandler;
private GemAlarmNotificationSendPolicyRegistration?
_alarmNotificationSendPolicyHandler;
private GemRemoteCommandAcceptanceRegistration?
_remoteCommandAcceptanceHandler;
private GemRemoteCommandRegistration? _remoteCommandHandler;
Expand Down Expand Up @@ -189,6 +191,34 @@ public GemCollectionEventSendPolicyRegistration
}
}

/// <summary>
/// Registers the single application policy evaluated before alarm-notification
/// sending.
/// </summary>
public GemAlarmNotificationSendPolicyRegistration
RegisterAlarmNotificationSendPolicyHandler(
GemAlarmNotificationSendPolicyHandler handler)
{
if (handler is null)
throw new ArgumentNullException(nameof(handler));

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

var registration = new GemAlarmNotificationSendPolicyRegistration(
handler,
UnregisterAlarmNotificationSendPolicyHandler);
_alarmNotificationSendPolicyHandler = registration;
return registration;
}
}

/// <summary>Registers the single application remote-command handler.</summary>
public GemRemoteCommandRegistration RegisterRemoteCommandHandler(
GemRemoteCommandHandler handler)
Expand Down Expand Up @@ -306,6 +336,9 @@ public async Task SendAlarmNotificationAsync(
if (notification is null)
throw new ArgumentNullException(nameof(notification));

GemAlarmNotificationSendPolicyHandler? sendPolicyHandler;
GemCommunicationState communicationState;
GemOnlineState onlineState;
lock (_gate)
{
ThrowIfDisposed();
Expand All @@ -315,6 +348,22 @@ public async Task SendAlarmNotificationAsync(
throw new InvalidOperationException(
$"Alarm sending is disabled for {notification.AlarmId}.");
}

sendPolicyHandler = _alarmNotificationSendPolicyHandler?.Handler;
communicationState = CommunicationState;
onlineState = OnlineState;
}

if (sendPolicyHandler is not null &&
!await sendPolicyHandler(
communicationState,
onlineState,
notification,
cancellationToken).ConfigureAwait(false))
{
throw new InvalidOperationException(
$"The application policy rejected alarm notification " +
$"{notification.AlarmId}.");
}

var response = await _services.SendRequestAsync(
Expand Down Expand Up @@ -353,6 +402,7 @@ public void Dispose()
_alarmCatalog.Clear();
_onlineStateTransitionHandler = null;
_collectionEventSendPolicyHandler = null;
_alarmNotificationSendPolicyHandler = null;
_remoteCommandAcceptanceHandler = null;
_remoteCommandHandler = null;
}
Expand Down Expand Up @@ -913,6 +963,16 @@ private void UnregisterCollectionEventSendPolicyHandler(
}
}

private void UnregisterAlarmNotificationSendPolicyHandler(
GemAlarmNotificationSendPolicyRegistration registration)
{
lock (_gate)
{
if (ReferenceEquals(_alarmNotificationSendPolicyHandler, registration))
_alarmNotificationSendPolicyHandler = null;
}
}

private void UnregisterOnlineStateTransitionHandler(
GemOnlineStateTransitionRegistration registration)
{
Expand Down
27 changes: 27 additions & 0 deletions test/SecsFrame.Tests/GemEquipmentServicesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,33 @@ public async Task Collection_event_send_policy_is_exact_disposable_and_replaceab
services.RegisterCollectionEventSendPolicyHandler(handler));
}

[Fact]
public async Task Alarm_notification_send_policy_is_exact_disposable_and_replaceable()
{
await using var endpoint = new SecsEquipment(
CreateOptions(GetFreePort(), HsmsConnectionMode.Active));
using var services = new GemEquipmentServices(
endpoint,
new GemIdentity("EQ-01", "1.0"),
new TestGemClock(Epoch));
GemAlarmNotificationSendPolicyHandler handler = static (_, _, _, _) =>
new ValueTask<bool>(true);

Assert.Throws<ArgumentNullException>(() =>
services.RegisterAlarmNotificationSendPolicyHandler(null!));
var first =
services.RegisterAlarmNotificationSendPolicyHandler(handler);
Assert.Throws<InvalidOperationException>(() =>
services.RegisterAlarmNotificationSendPolicyHandler(handler));
first.Dispose();
first.Dispose();
using var replacement =
services.RegisterAlarmNotificationSendPolicyHandler(handler);
services.Dispose();
Assert.Throws<ObjectDisposedException>(() =>
services.RegisterAlarmNotificationSendPolicyHandler(handler));
}

[Fact]
public async Task Alarm_registration_is_exact_disposable_and_replaceable()
{
Expand Down
95 changes: 72 additions & 23 deletions test/SecsFrame.Tests/GemFoundationTcpTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -443,25 +443,9 @@ await context.HostServices.SetClockAsync(replacementTime, context.Token)

private static async Task AssertAlarmNotificationsAsync(GemTcpContext context)
{
var received = new TaskCompletionSource<GemAlarmNotification>(
TaskCreationOptions.RunContinuationsAsynchronously);
using (context.HostServices.RegisterAlarmNotificationHandler(
(notification, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();
received.TrySetResult(notification);
return new ValueTask<bool>(true);
}))
{
await context.EquipmentServices.SendAlarmNotificationAsync(
new GemAlarmNotification(
0x81,
SecsItem.Ascii("DOOR-01"),
"DOOR OPEN"),
context.Token).ConfigureAwait(true);
}

var notification = await received.Task.ConfigureAwait(true);
var notification =
await AssertAlarmNotificationSendPolicyAsync(context)
.ConfigureAwait(true);
Assert.Equal((byte)0x81, notification.Code);
Assert.Equal(SecsItem.Ascii("DOOR-01"), notification.AlarmId);
Assert.Equal("DOOR OPEN", notification.Text);
Expand All @@ -477,6 +461,53 @@ await context.EquipmentServices.SendAlarmNotificationAsync(
Assert.Equal((byte)1, rejected.Acknowledgement);
}

private static async Task<GemAlarmNotification>
AssertAlarmNotificationSendPolicyAsync(GemTcpContext context)
{
var received = new TaskCompletionSource<GemAlarmNotification>(
TaskCreationOptions.RunContinuationsAsynchronously);
var notification = new GemAlarmNotification(
0x81,
SecsItem.Ascii("DOOR-01"),
"DOOR OPEN");
var acceptSend = false;
var observed = new List<(
GemCommunicationState Communication,
GemOnlineState Online,
GemAlarmNotification Notification)>();
using var hostRegistration =
context.HostServices.RegisterAlarmNotificationHandler(
(value, _) =>
{
received.TrySetResult(value);
return new ValueTask<bool>(true);
});
using var policyRegistration =
context.EquipmentServices.RegisterAlarmNotificationSendPolicyHandler(
(communication, online, value, _) =>
{
observed.Add((communication, online, value));
return new ValueTask<bool>(acceptSend);
});

await Assert.ThrowsAsync<InvalidOperationException>(
() => context.EquipmentServices.SendAlarmNotificationAsync(
notification,
context.Token)).ConfigureAwait(true);
Assert.False(received.Task.IsCompleted);

acceptSend = true;
await context.EquipmentServices.SendAlarmNotificationAsync(
notification,
context.Token).ConfigureAwait(true);

Assert.Equal(2, observed.Count);
Assert.All(observed, item => Assert.Equal(
(GemCommunicationState.Communicating, GemOnlineState.Online, notification),
item));
return await received.Task.ConfigureAwait(true);
}

private static async Task AssertAlarmCatalogAsync(GemTcpContext context)
{
using var first = context.EquipmentServices.RegisterAlarm(
Expand Down Expand Up @@ -525,10 +556,8 @@ await context.HostServices.SetAlarmSendEnabledAsync(
alarmId,
Assert.Single(await context.HostServices.ListAlarmsAsync(
cancellationToken: context.Token).ConfigureAwait(true)).AlarmId);
await Assert.ThrowsAsync<InvalidOperationException>(
() => context.EquipmentServices.SendAlarmNotificationAsync(
new GemAlarmNotification(0x81, alarmId, "CONTROLLED ALARM"),
context.Token)).ConfigureAwait(true);
await AssertDisabledAlarmPrecedesSendPolicyAsync(context, alarmId)
.ConfigureAwait(true);

await context.HostServices.SetAlarmSendEnabledAsync(
alarmId,
Expand Down Expand Up @@ -569,6 +598,26 @@ await context.HostServices.SetAlarmSendEnabledAsync(
Assert.True(replacement.IsSendEnabled);
}

private static async Task AssertDisabledAlarmPrecedesSendPolicyAsync(
GemTcpContext context,
SecsItem alarmId)
{
var policyCalls = 0;
using var policyRegistration =
context.EquipmentServices.RegisterAlarmNotificationSendPolicyHandler(
(_, _, _, _) =>
{
policyCalls++;
return new ValueTask<bool>(true);
});

await Assert.ThrowsAsync<InvalidOperationException>(
() => context.EquipmentServices.SendAlarmNotificationAsync(
new GemAlarmNotification(0x81, alarmId, "CONTROLLED ALARM"),
context.Token)).ConfigureAwait(true);
Assert.Equal(0, policyCalls);
}

private static async Task AssertRemoteCommandsAsync(GemTcpContext context)
{
var command =
Expand Down
Loading