diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5996c12..3ee6e6d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -106,6 +106,10 @@
通讯/在线状态、DATAID 和 CEID 决定是否采值并发送,未注册时保持现有行为。
- Collection Event 策略拒绝在状态变量提供器和 S6F11 事务之前短路;应用
改变决策后可在同一会话显式重试。增加注册生命周期和真实 TCP 状态测试。
+- 增加可释放的 Equipment 报警通知发送策略;应用可依据当前通讯/在线状态
+ 和完整通知决定是否发送,未注册时保持现有行为。
+- 已注册报警的发送启停检查优先于应用策略;策略拒绝在 S5F1 事务之前短路,
+ 改变决策后可在同一会话显式重试。增加注册生命周期和真实 TCP 状态测试。
- 增加可替换的报警目录查询消息对、不可变报警定义、Equipment 运行期注册
和 Host 全量/按动态标识查询;全量目录保持注册顺序,未知标识被忽略。
- 报警目录严格校验 W-Bit、请求 List、标识唯一性、三字段定义、单字节
diff --git a/README.md b/README.md
index 70dde8a..a824d23 100644
--- a/README.md
+++ b/README.md
@@ -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),传输适配边界见
diff --git a/docs/GEM-FOUNDATION.md b/docs/GEM-FOUNDATION.md
index afc4e00..8d33e4a 100644
--- a/docs/GEM-FOUNDATION.md
+++ b/docs/GEM-FOUNDATION.md
@@ -94,10 +94,10 @@ Equipment 可使用 RegisterOnlineStateTransitionHandler 注册
应用应遵守角色端点的单消费者事件循环约束,使决策和状态转换按请求顺序
执行。
-除前述通讯建立/在线转换策略外,后文还为远程命令和 Collection Event
-提供显式应用策略。当前仍不把变量读取、常量、时钟、报警通知等其他操作
-硬性门控在某个 GEM 状态上,也不自动重新建立通讯。这样可在尚未获得授权
-E30 状态表前避免把未经核对的状态条件固化为公共行为。
+除前述通讯建立/在线转换策略外,后文还为远程命令、Collection Event 和
+报警通知提供显式应用策略。当前仍不把变量读取、常量、时钟等其他操作硬性
+门控在某个 GEM 状态上,也不自动重新建立通讯。这样可在尚未获得授权 E30
+状态表前避免把未经核对的状态条件固化为公共行为。
## 动态数据与时钟
@@ -162,7 +162,8 @@ Host 使用 RegisterAlarmNotificationHandler 注册单一可释放
处理器。处理器返回 true 发送成功应答,返回
false 或没有处理器时发送失败应答。注册快照、释放、异常和
取消语义与 Collection Event 处理器一致。本切片不解释报警条件状态,也
-不包含历史、持久化或基于 GEM 在线状态的发送门控。
+不包含历史或持久化。Equipment 发送策略只向应用暴露当前状态,不内置
+基于 GEM 在线状态的矩阵。
Equipment 使用 RegisterAlarm 注册不可变
GemAlarmDefinition。报警定义保留原始单字节代码、动态报警
@@ -186,6 +187,21 @@ Host 使用 SetAlarmSendEnabledAsync 控制一个已注册报警。
检查的并发发送会继续完成。未注册 ID 的原始通知保持既有发送行为。释放并
重新注册会恢复默认允许状态,HSMS 会话变化不会重置注册状态。
+Equipment 可另用
+RegisterAlarmNotificationSendPolicyHandler 注册单一可释放
+发送策略。每个通过上述单报警发送许可检查的通知会把当前
+CommunicationState、OnlineState 和完整
+GemAlarmNotification 交给策略。返回 false
+时 SendAlarmNotificationAsync 抛出本地
+InvalidOperationException,不创建 S5F1 事务,因此 Host
+不会收到消息。已禁用的已注册报警在策略之前拒绝,策略不会执行。
+
+没有注册策略时继续发送,保持此前行为。发送许可、策略与状态在 Equipment
+锁内按调用时一起快照,策略在锁外执行;释放注册只影响后续发送,已取得的
+策略快照会完成。策略异常和取消直接传播给调用方。应用可以改变策略结果并
+在同一 Selected 会话显式重试;库不自动重试,也不内置未经授权 E30-0526
+核对的通讯/在线状态矩阵。
+
当前 API 故意不提供“全部报警”启停、批量控制、报警条件屏蔽或历史。
这些语义以及控制码含义仍需依据授权 E30-0526 和目标设备互操作结果核对。
@@ -271,7 +287,8 @@ Passive 真实 TCP 下的双向通讯建立、首次建立拒绝后同会话恢
重新建立拒绝时的状态/身份保持与恢复、在线转换接受/拒绝与状态保持、异构
动态标识、报告定义、
事件链接、Collection Event 嵌套值与空报告、应用发送策略拒绝零采值/零
-事务和同会话重试、报警通知接受/拒绝、配置/事件拒绝、报警目录全量/选择
-查询与释放快照、单报警发送启停与未知标识拒绝、远程命令动态参数与结果、
-无处理器拒绝、应用状态拒绝短路与同会话重试、时钟读写和 Linktest。它们
-是独立工程验证,不是 SEMI 一致性认证。
+事务和同会话重试、报警通知接受/拒绝、应用发送策略拒绝零事务与同会话
+重试、已禁用报警优先拒绝、配置/事件拒绝、报警目录全量/选择查询与释放
+快照、单报警发送启停与未知标识拒绝、远程命令动态参数与结果、无处理器
+拒绝、应用状态拒绝短路与同会话重试、时钟读写和 Linktest。它们是独立
+工程验证,不是 SEMI 一致性认证。
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 86a9116..4a6a436 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -48,7 +48,8 @@ epoch 收口与 Passive 监听加固。后续切片继续扩展 GEM 通用行为
- [x] Equipment 入站远程命令的应用状态接受策略、拒绝短路与同会话重试。
- [x] Equipment Collection Event 发送的应用状态策略、拒绝零采值/零事务
与同会话重试。
-- [ ] 报警通知等其余业务消息的应用状态门控。
+- [x] Equipment 报警通知发送的应用状态策略、发送启停优先级、拒绝零事务
+ 与同会话重试。
- [ ] 报警历史/批量控制、命令目录/权限/调度和更完整的状态/错误恢复。
- [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。
@@ -62,11 +63,14 @@ epoch 收口与 Passive 监听加固。后续切片继续扩展 GEM 通用行为
依据当前通讯/在线状态决定是否执行命令,库不内置未经 E30 核对的状态矩阵。
Collection Event 发送策略沿用相同的默认不注册模式;拒绝发生在动态值
采集和 S6F11 事务之前,应用改变决策后可在同一会话显式重试。
+报警通知发送策略同样默认不注册;已注册报警的发送启停检查优先,策略拒绝
+发生在 S5F1 事务之前,并可在同一会话显式重试。至此当前 Equipment 主动
+业务发送入口的应用状态策略已经闭环。
-下一优先级是把此模式扩展到报警通知发送入口。最小切片应让应用依据当前
-通讯/在线状态和通知内容决策,在既有单报警发送启停检查之后、创建 S5F1
-事务之前拒绝,并继续保持默认不注册;随后再评估自动恢复、报警历史和批量
-控制。
+下一优先级转向报警历史/批量控制。最小切片应先用授权 E30-0526 核对消息
+条件、数据结构和代码语义,再确定可替换 profile 下的不可变历史模型、
+Equipment 快照边界、Host 查询 API、严格向量和真实 TCP 证据;核对完成前
+不固化新的默认消息对。自动恢复和命令目录/权限/调度继续保持后续评估项。
## 阶段 3:元数据与工具
diff --git a/src/SecsFrame.Gem/GemAlarmNotificationSendPolicyHandler.cs b/src/SecsFrame.Gem/GemAlarmNotificationSendPolicyHandler.cs
new file mode 100644
index 0000000..282f031
--- /dev/null
+++ b/src/SecsFrame.Gem/GemAlarmNotificationSendPolicyHandler.cs
@@ -0,0 +1,8 @@
+namespace SecsFrame.Gem;
+
+/// Decides whether one alarm notification may be sent.
+public delegate ValueTask GemAlarmNotificationSendPolicyHandler(
+ GemCommunicationState communicationState,
+ GemOnlineState onlineState,
+ GemAlarmNotification notification,
+ CancellationToken cancellationToken);
diff --git a/src/SecsFrame.Gem/GemAlarmNotificationSendPolicyRegistration.cs b/src/SecsFrame.Gem/GemAlarmNotificationSendPolicyRegistration.cs
new file mode 100644
index 0000000..acd3b9f
--- /dev/null
+++ b/src/SecsFrame.Gem/GemAlarmNotificationSendPolicyRegistration.cs
@@ -0,0 +1,21 @@
+namespace SecsFrame.Gem;
+
+/// Owns one Equipment alarm-notification send-policy registration.
+public sealed class GemAlarmNotificationSendPolicyRegistration : IDisposable
+{
+ private Action? _unregister;
+
+ internal GemAlarmNotificationSendPolicyRegistration(
+ GemAlarmNotificationSendPolicyHandler handler,
+ Action unregister)
+ {
+ Handler = handler;
+ _unregister = unregister;
+ }
+
+ internal GemAlarmNotificationSendPolicyHandler Handler { get; }
+
+ /// Removes this exact policy registration. Repeated calls are harmless.
+ public void Dispose()
+ => Interlocked.Exchange(ref _unregister, null)?.Invoke(this);
+}
diff --git a/src/SecsFrame.Gem/GemEquipmentServices.cs b/src/SecsFrame.Gem/GemEquipmentServices.cs
index d4b2391..26c92fa 100644
--- a/src/SecsFrame.Gem/GemEquipmentServices.cs
+++ b/src/SecsFrame.Gem/GemEquipmentServices.cs
@@ -21,6 +21,8 @@ public sealed class GemEquipmentServices : IDisposable
private GemOnlineStateTransitionRegistration? _onlineStateTransitionHandler;
private GemCollectionEventSendPolicyRegistration?
_collectionEventSendPolicyHandler;
+ private GemAlarmNotificationSendPolicyRegistration?
+ _alarmNotificationSendPolicyHandler;
private GemRemoteCommandAcceptanceRegistration?
_remoteCommandAcceptanceHandler;
private GemRemoteCommandRegistration? _remoteCommandHandler;
@@ -189,6 +191,34 @@ public GemCollectionEventSendPolicyRegistration
}
}
+ ///
+ /// Registers the single application policy evaluated before alarm-notification
+ /// sending.
+ ///
+ 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;
+ }
+ }
+
/// Registers the single application remote-command handler.
public GemRemoteCommandRegistration RegisterRemoteCommandHandler(
GemRemoteCommandHandler handler)
@@ -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();
@@ -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(
@@ -353,6 +402,7 @@ public void Dispose()
_alarmCatalog.Clear();
_onlineStateTransitionHandler = null;
_collectionEventSendPolicyHandler = null;
+ _alarmNotificationSendPolicyHandler = null;
_remoteCommandAcceptanceHandler = null;
_remoteCommandHandler = null;
}
@@ -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)
{
diff --git a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs
index bf42a19..aff0cae 100644
--- a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs
+++ b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs
@@ -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(true);
+
+ Assert.Throws(() =>
+ services.RegisterAlarmNotificationSendPolicyHandler(null!));
+ var first =
+ services.RegisterAlarmNotificationSendPolicyHandler(handler);
+ Assert.Throws(() =>
+ services.RegisterAlarmNotificationSendPolicyHandler(handler));
+ first.Dispose();
+ first.Dispose();
+ using var replacement =
+ services.RegisterAlarmNotificationSendPolicyHandler(handler);
+ services.Dispose();
+ Assert.Throws(() =>
+ services.RegisterAlarmNotificationSendPolicyHandler(handler));
+ }
+
[Fact]
public async Task Alarm_registration_is_exact_disposable_and_replaceable()
{
diff --git a/test/SecsFrame.Tests/GemFoundationTcpTests.cs b/test/SecsFrame.Tests/GemFoundationTcpTests.cs
index c7342c4..dda5c27 100644
--- a/test/SecsFrame.Tests/GemFoundationTcpTests.cs
+++ b/test/SecsFrame.Tests/GemFoundationTcpTests.cs
@@ -443,25 +443,9 @@ await context.HostServices.SetClockAsync(replacementTime, context.Token)
private static async Task AssertAlarmNotificationsAsync(GemTcpContext context)
{
- var received = new TaskCompletionSource(
- TaskCreationOptions.RunContinuationsAsynchronously);
- using (context.HostServices.RegisterAlarmNotificationHandler(
- (notification, cancellationToken) =>
- {
- cancellationToken.ThrowIfCancellationRequested();
- received.TrySetResult(notification);
- return new ValueTask(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);
@@ -477,6 +461,53 @@ await context.EquipmentServices.SendAlarmNotificationAsync(
Assert.Equal((byte)1, rejected.Acknowledgement);
}
+ private static async Task
+ AssertAlarmNotificationSendPolicyAsync(GemTcpContext context)
+ {
+ var received = new TaskCompletionSource(
+ 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(true);
+ });
+ using var policyRegistration =
+ context.EquipmentServices.RegisterAlarmNotificationSendPolicyHandler(
+ (communication, online, value, _) =>
+ {
+ observed.Add((communication, online, value));
+ return new ValueTask(acceptSend);
+ });
+
+ await Assert.ThrowsAsync(
+ () => 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(
@@ -525,10 +556,8 @@ await context.HostServices.SetAlarmSendEnabledAsync(
alarmId,
Assert.Single(await context.HostServices.ListAlarmsAsync(
cancellationToken: context.Token).ConfigureAwait(true)).AlarmId);
- await Assert.ThrowsAsync(
- () => context.EquipmentServices.SendAlarmNotificationAsync(
- new GemAlarmNotification(0x81, alarmId, "CONTROLLED ALARM"),
- context.Token)).ConfigureAwait(true);
+ await AssertDisabledAlarmPrecedesSendPolicyAsync(context, alarmId)
+ .ConfigureAwait(true);
await context.HostServices.SetAlarmSendEnabledAsync(
alarmId,
@@ -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(true);
+ });
+
+ await Assert.ThrowsAsync(
+ () => 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 =