diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09d0475..5996c12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -102,6 +102,10 @@
在线状态和已解码命令决定是否交给执行处理器,未注册策略时保持现有行为。
- 策略拒绝使用 profile 失败应答和空参数结果,且不调用执行处理器;应用
改变决策后可在同一会话重试。增加注册生命周期和真实 TCP 状态门控测试。
+- 增加可释放的 Equipment Collection Event 发送策略;应用可依据当前
+ 通讯/在线状态、DATAID 和 CEID 决定是否采值并发送,未注册时保持现有行为。
+- Collection Event 策略拒绝在状态变量提供器和 S6F11 事务之前短路;应用
+ 改变决策后可在同一会话显式重试。增加注册生命周期和真实 TCP 状态测试。
- 增加可替换的报警目录查询消息对、不可变报警定义、Equipment 运行期注册
和 Host 全量/按动态标识查询;全量目录保持注册顺序,未知标识被忽略。
- 报警目录严格校验 W-Bit、请求 List、标识唯一性、三字段定义、单字节
diff --git a/README.md b/README.md
index 1136856..70dde8a 100644
--- a/README.md
+++ b/README.md
@@ -32,8 +32,9 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor
配置、动态消息收发、状态等待和单消费者事件流。
独立 SecsFrame.Gem 已提供可配置 GEM 通用行为,包含双向通讯
建立及应用接受策略、同会话显式重试恢复、上下线与应用转换策略、动态变量/
-常量、应用托管时钟、报告定义、事件链接和 Collection Event、报警目录查询、
-单报警发送启停,以及最小报警通知、远程命令链路和应用状态接受策略。
+常量、应用托管时钟、报告定义、事件链接和带 Equipment 应用发送策略的
+Collection Event、报警目录查询、单报警发送启停,以及最小报警通知、
+远程命令链路和应用状态接受策略。
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 4e0b942..afc4e00 100644
--- a/docs/GEM-FOUNDATION.md
+++ b/docs/GEM-FOUNDATION.md
@@ -5,9 +5,9 @@
SecsFrame.Gem 是只依赖 SecsFrame 的独立程序集。
当前切片提供 Host/Equipment 两侧的通讯建立及应用接受策略、在线/离线及
应用转换策略、状态变量读取、设备常量读取、应用托管时钟、报告定义、事件
-链接和 Collection Event,以及报警目录查询、单报警发送启停、最小报警
-通知、远程命令链路与应用状态接受策略,不把 GEM 状态或消息目录放入 HSMS
-状态机。
+链接和带应用发送策略的 Collection Event,以及报警目录查询、单报警发送
+启停、最小报警通知、远程命令链路与应用状态接受策略,不把 GEM 状态或消息
+目录放入 HSMS 状态机。
GemHostServices 依赖 SecsHost;
GemEquipmentServices 依赖 SecsEquipment。服务不拥有
@@ -94,9 +94,10 @@ Equipment 可使用 RegisterOnlineStateTransitionHandler 注册
应用应遵守角色端点的单消费者事件循环约束,使决策和状态转换按请求顺序
执行。
-当前切片仍没有把变量、常量、时钟、报告、报警或命令硬性门控在某个 GEM
-状态上,也不自动重新建立通讯。这样可在尚未获得授权 E30 状态表前避免把
-未经核对的状态条件固化为公共行为。
+除前述通讯建立/在线转换策略外,后文还为远程命令和 Collection Event
+提供显式应用策略。当前仍不把变量读取、常量、时钟、报警通知等其他操作
+硬性门控在某个 GEM 状态上,也不自动重新建立通讯。这样可在尚未获得授权
+E30 状态表前避免把未经核对的状态条件固化为公共行为。
## 动态数据与时钟
@@ -130,6 +131,20 @@ Host 使用 DefineReportsAsync 和
注册不会改变正在生成的这一条事件;提供器仍可能按应用取消或失败。值保持
任意动态 Item,包括嵌套 List 和空报告。
+Equipment 可另用 RegisterCollectionEventSendPolicyHandler
+注册单一可释放发送策略。配置有效的每次发送会把当前
+CommunicationState、OnlineState、DATAID 和
+CEID 交给策略。返回 false 时
+SendCollectionEventAsync 抛出本地
+InvalidOperationException;状态变量提供器不会执行,也不会
+创建 S6F11 事务,因此 Host 不会收到消息。
+
+没有注册策略时继续采值并发送,保持此前行为。策略、状态和报告执行计划在
+Equipment 锁内一起快照,策略在锁外执行;释放注册只影响后续发送,已取得
+的快照会完成。策略异常和取消直接传播给调用方。应用可以改变策略结果并在
+同一 Selected 会话再次显式调用发送;库不自动重试,也不内置未经授权
+E30-0526 核对的通讯/在线状态矩阵。
+
Host 使用 RegisterCollectionEventHandler 注册单一可释放处理器。
处理器返回 true 发送成功应答,返回 false 发送失败
应答;没有处理器时明确失败。释放注册只影响后续事件,已经取得处理器快照
@@ -255,7 +270,8 @@ Schema、权限、排队、自动重试、幂等策略或执行历史。
Passive 真实 TCP 下的双向通讯建立、首次建立拒绝后同会话恢复、已通讯
重新建立拒绝时的状态/身份保持与恢复、在线转换接受/拒绝与状态保持、异构
动态标识、报告定义、
-事件链接、Collection Event 嵌套值与空报告、报警通知接受/拒绝、配置/事件
-拒绝、报警目录全量/选择查询与释放快照、单报警发送启停与未知标识拒绝、
-远程命令动态参数与结果、无处理器拒绝、应用状态拒绝短路与同会话重试、
-时钟读写和 Linktest。它们是独立工程验证,不是 SEMI 一致性认证。
+事件链接、Collection Event 嵌套值与空报告、应用发送策略拒绝零采值/零
+事务和同会话重试、报警通知接受/拒绝、配置/事件拒绝、报警目录全量/选择
+查询与释放快照、单报警发送启停与未知标识拒绝、远程命令动态参数与结果、
+无处理器拒绝、应用状态拒绝短路与同会话重试、时钟读写和 Linktest。它们
+是独立工程验证,不是 SEMI 一致性认证。
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index fc3f9ee..86a9116 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -46,7 +46,9 @@ epoch 收口与 Passive 监听加固。后续切片继续扩展 GEM 通用行为
- [x] Equipment 在线/离线转换的应用决策、显式拒绝与状态保持。
- [x] Host/Equipment 双向通讯建立应用决策、显式拒绝与同会话重试恢复。
- [x] Equipment 入站远程命令的应用状态接受策略、拒绝短路与同会话重试。
-- [ ] Collection Event、报警通知等其余业务消息的应用状态门控。
+- [x] Equipment Collection Event 发送的应用状态策略、拒绝零采值/零事务
+ 与同会话重试。
+- [ ] 报警通知等其余业务消息的应用状态门控。
- [ ] 报警历史/批量控制、命令目录/权限/调度和更完整的状态/错误恢复。
- [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。
@@ -58,9 +60,13 @@ epoch 收口与 Passive 监听加固。后续切片继续扩展 GEM 通用行为
不注册;拒绝不改变既有通讯状态或对端身份,恢复由应用在同一 Selected
会话显式重试,库不启动后台循环。远程命令接受策略也默认不注册;应用可
依据当前通讯/在线状态决定是否执行命令,库不内置未经 E30 核对的状态矩阵。
-下一优先级是把相同的默认关闭模式扩展到 Equipment 主动业务上报,最小
-切片先覆盖 Collection Event 发送入口,再评估报警通知、自动恢复、报警
-历史和批量控制。
+Collection Event 发送策略沿用相同的默认不注册模式;拒绝发生在动态值
+采集和 S6F11 事务之前,应用改变决策后可在同一会话显式重试。
+
+下一优先级是把此模式扩展到报警通知发送入口。最小切片应让应用依据当前
+通讯/在线状态和通知内容决策,在既有单报警发送启停检查之后、创建 S5F1
+事务之前拒绝,并继续保持默认不注册;随后再评估自动恢复、报警历史和批量
+控制。
## 阶段 3:元数据与工具
diff --git a/src/SecsFrame.Gem/GemCollectionEventSendPolicyHandler.cs b/src/SecsFrame.Gem/GemCollectionEventSendPolicyHandler.cs
new file mode 100644
index 0000000..08f6ec8
--- /dev/null
+++ b/src/SecsFrame.Gem/GemCollectionEventSendPolicyHandler.cs
@@ -0,0 +1,11 @@
+namespace SecsFrame.Gem;
+
+///
+/// Decides whether one configured Collection Event may collect values and be sent.
+///
+public delegate ValueTask GemCollectionEventSendPolicyHandler(
+ GemCommunicationState communicationState,
+ GemOnlineState onlineState,
+ SecsItem dataId,
+ SecsItem eventId,
+ CancellationToken cancellationToken);
diff --git a/src/SecsFrame.Gem/GemCollectionEventSendPolicyRegistration.cs b/src/SecsFrame.Gem/GemCollectionEventSendPolicyRegistration.cs
new file mode 100644
index 0000000..d36571b
--- /dev/null
+++ b/src/SecsFrame.Gem/GemCollectionEventSendPolicyRegistration.cs
@@ -0,0 +1,21 @@
+namespace SecsFrame.Gem;
+
+/// Owns one Equipment Collection Event send-policy registration.
+public sealed class GemCollectionEventSendPolicyRegistration : IDisposable
+{
+ private Action? _unregister;
+
+ internal GemCollectionEventSendPolicyRegistration(
+ GemCollectionEventSendPolicyHandler handler,
+ Action unregister)
+ {
+ Handler = handler;
+ _unregister = unregister;
+ }
+
+ internal GemCollectionEventSendPolicyHandler 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 4e7b94b..d4b2391 100644
--- a/src/SecsFrame.Gem/GemEquipmentServices.cs
+++ b/src/SecsFrame.Gem/GemEquipmentServices.cs
@@ -19,6 +19,8 @@ public sealed class GemEquipmentServices : IDisposable
private readonly Dictionary _alarms = new();
private readonly List _alarmCatalog = new();
private GemOnlineStateTransitionRegistration? _onlineStateTransitionHandler;
+ private GemCollectionEventSendPolicyRegistration?
+ _collectionEventSendPolicyHandler;
private GemRemoteCommandAcceptanceRegistration?
_remoteCommandAcceptanceHandler;
private GemRemoteCommandRegistration? _remoteCommandHandler;
@@ -159,6 +161,34 @@ public GemRemoteCommandAcceptanceRegistration
}
}
+ ///
+ /// Registers the single application policy evaluated before Collection Event
+ /// value collection and sending.
+ ///
+ public GemCollectionEventSendPolicyRegistration
+ RegisterCollectionEventSendPolicyHandler(
+ GemCollectionEventSendPolicyHandler handler)
+ {
+ if (handler is null)
+ throw new ArgumentNullException(nameof(handler));
+
+ lock (_gate)
+ {
+ ThrowIfDisposed();
+ if (_collectionEventSendPolicyHandler is not null)
+ {
+ throw new InvalidOperationException(
+ "A Collection Event send-policy handler is already registered.");
+ }
+
+ var registration = new GemCollectionEventSendPolicyRegistration(
+ handler,
+ UnregisterCollectionEventSendPolicyHandler);
+ _collectionEventSendPolicyHandler = registration;
+ return registration;
+ }
+ }
+
/// Registers the single application remote-command handler.
public GemRemoteCommandRegistration RegisterRemoteCommandHandler(
GemRemoteCommandHandler handler)
@@ -218,7 +248,23 @@ public async Task SendCollectionEventAsync(
if (eventId is null)
throw new ArgumentNullException(nameof(eventId));
- var executions = GetReportExecutions(eventId);
+ var executions = GetReportExecutions(
+ eventId,
+ out var sendPolicyHandler,
+ out var communicationState,
+ out var onlineState);
+ if (sendPolicyHandler is not null &&
+ !await sendPolicyHandler(
+ communicationState,
+ onlineState,
+ dataId,
+ eventId,
+ cancellationToken).ConfigureAwait(false))
+ {
+ throw new InvalidOperationException(
+ $"The application policy rejected Collection Event {eventId}.");
+ }
+
var reports = new GemCollectedReport[executions.Count];
for (var reportIndex = 0; reportIndex < executions.Count; reportIndex++)
{
@@ -306,6 +352,7 @@ public void Dispose()
_alarms.Clear();
_alarmCatalog.Clear();
_onlineStateTransitionHandler = null;
+ _collectionEventSendPolicyHandler = null;
_remoteCommandAcceptanceHandler = null;
_remoteCommandHandler = null;
}
@@ -775,7 +822,11 @@ private void ApplyEventReportLinks(
}
}
- private IReadOnlyList GetReportExecutions(SecsItem eventId)
+ private IReadOnlyList GetReportExecutions(
+ SecsItem eventId,
+ out GemCollectionEventSendPolicyHandler? sendPolicyHandler,
+ out GemCommunicationState communicationState,
+ out GemOnlineState onlineState)
{
lock (_gate)
{
@@ -815,6 +866,9 @@ private IReadOnlyList GetReportExecutions(SecsItem eventId)
Array.AsReadOnly(providers));
}
+ sendPolicyHandler = _collectionEventSendPolicyHandler?.Handler;
+ communicationState = CommunicationState;
+ onlineState = OnlineState;
return Array.AsReadOnly(executions);
}
}
@@ -849,6 +903,16 @@ private void UnregisterRemoteCommandAcceptanceHandler(
}
}
+ private void UnregisterCollectionEventSendPolicyHandler(
+ GemCollectionEventSendPolicyRegistration registration)
+ {
+ lock (_gate)
+ {
+ if (ReferenceEquals(_collectionEventSendPolicyHandler, registration))
+ _collectionEventSendPolicyHandler = null;
+ }
+ }
+
private void UnregisterOnlineStateTransitionHandler(
GemOnlineStateTransitionRegistration registration)
{
diff --git a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs
index e216712..bf42a19 100644
--- a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs
+++ b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs
@@ -352,6 +352,32 @@ public async Task Remote_command_acceptance_handler_is_exact_disposable_and_repl
services.RegisterRemoteCommandAcceptanceHandler(handler));
}
+ [Fact]
+ public async Task Collection_event_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));
+ GemCollectionEventSendPolicyHandler handler = static (_, _, _, _, _) =>
+ new ValueTask(true);
+
+ Assert.Throws(() =>
+ services.RegisterCollectionEventSendPolicyHandler(null!));
+ var first = services.RegisterCollectionEventSendPolicyHandler(handler);
+ Assert.Throws(() =>
+ services.RegisterCollectionEventSendPolicyHandler(handler));
+ first.Dispose();
+ first.Dispose();
+ using var replacement =
+ services.RegisterCollectionEventSendPolicyHandler(handler);
+ services.Dispose();
+ Assert.Throws(() =>
+ services.RegisterCollectionEventSendPolicyHandler(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 b734eb8..c7342c4 100644
--- a/test/SecsFrame.Tests/GemFoundationTcpTests.cs
+++ b/test/SecsFrame.Tests/GemFoundationTcpTests.cs
@@ -253,27 +253,15 @@ await ConfigureCollectionEventAsync(
eventId,
reportId,
emptyReportId).ConfigureAwait(true);
- var received = new TaskCompletionSource(
- TaskCreationOptions.RunContinuationsAsynchronously);
- using (context.HostServices.RegisterCollectionEventHandler(
- (collectionEvent, cancellationToken) =>
- {
- cancellationToken.ThrowIfCancellationRequested();
- received.TrySetResult(collectionEvent);
- return new ValueTask(true);
- }))
- {
- await context.EquipmentServices.SendCollectionEventAsync(
- SecsItem.U4(9002),
- eventId,
- context.Token).ConfigureAwait(true);
- }
-
+ var collectionEvent =
+ await AssertCollectionEventSendPolicyAsync(context, eventId)
+ .ConfigureAwait(true);
AssertCollectionEvent(
- await received.Task.ConfigureAwait(true),
+ collectionEvent,
eventId,
reportId,
emptyReportId);
+ var providerCalls = context.CollectionEventProviderCalls;
var rejectedEvent = await Assert.ThrowsAsync(
() => context.EquipmentServices.SendCollectionEventAsync(
SecsItem.U4(9003),
@@ -281,6 +269,61 @@ await received.Task.ConfigureAwait(true),
context.Token)).ConfigureAwait(true);
Assert.Equal(GemOperation.CollectionEvent, rejectedEvent.Operation);
Assert.Equal((byte)1, rejectedEvent.Acknowledgement);
+ Assert.Equal(providerCalls + 1, context.CollectionEventProviderCalls);
+ }
+
+ private static async Task
+ AssertCollectionEventSendPolicyAsync(
+ GemTcpContext context,
+ SecsItem eventId)
+ {
+ var received = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var acceptSend = false;
+ var providerCalls = context.CollectionEventProviderCalls;
+ var observed = new List<(
+ GemCommunicationState Communication,
+ GemOnlineState Online,
+ SecsItem DataId,
+ SecsItem EventId)>();
+ using var hostRegistration =
+ context.HostServices.RegisterCollectionEventHandler(
+ (collectionEvent, _) =>
+ {
+ received.TrySetResult(collectionEvent);
+ return new ValueTask(true);
+ });
+ using var policyRegistration =
+ context.EquipmentServices.RegisterCollectionEventSendPolicyHandler(
+ (communication, online, dataId, observedEventId, _) =>
+ {
+ observed.Add((communication, online, dataId, observedEventId));
+ return new ValueTask(acceptSend);
+ });
+
+ await Assert.ThrowsAsync(
+ () => context.EquipmentServices.SendCollectionEventAsync(
+ SecsItem.U4(9000),
+ eventId,
+ context.Token)).ConfigureAwait(true);
+ Assert.Equal(providerCalls, context.CollectionEventProviderCalls);
+ Assert.False(received.Task.IsCompleted);
+
+ acceptSend = true;
+ await context.EquipmentServices.SendCollectionEventAsync(
+ SecsItem.U4(9002),
+ eventId,
+ context.Token).ConfigureAwait(true);
+
+ Assert.Equal(providerCalls + 1, context.CollectionEventProviderCalls);
+ Assert.Equal(2, observed.Count);
+ Assert.All(observed, item => Assert.Equal(
+ (GemCommunicationState.Communicating, GemOnlineState.Online, eventId),
+ (item.Communication, item.Online, item.EventId)));
+ Assert.Equal(
+ new[] { SecsItem.U4(9000), SecsItem.U4(9002) },
+ observed.Select(item => item.DataId));
+ return await received.Task.ConfigureAwait(true);
}
private static async Task AssertRejectedReportDefinitionAsync(
@@ -756,6 +799,7 @@ private sealed class GemTcpContext : IAsyncDisposable
private readonly GemValueRegistration _variable2;
private readonly GemValueRegistration _variable3;
private readonly GemValueRegistration _constant;
+ private int _collectionEventProviderCalls;
private Task _hostPump = Task.CompletedTask;
private Task _equipmentPump = Task.CompletedTask;
@@ -783,7 +827,11 @@ internal GemTcpContext()
Clock);
_variable1 = EquipmentServices.RegisterStatusVariable(
SecsItem.U4(1001),
- static _ => new ValueTask(SecsItem.Ascii("READY")));
+ _ =>
+ {
+ Interlocked.Increment(ref _collectionEventProviderCalls);
+ return new ValueTask(SecsItem.Ascii("READY"));
+ });
_variable2 = EquipmentServices.RegisterStatusVariable(
SecsItem.Ascii("TEMP"),
static _ => new ValueTask(SecsItem.F8(23.5)));
@@ -811,6 +859,9 @@ internal GemTcpContext()
internal DateTimeOffset InitialTime { get; }
+ internal int CollectionEventProviderCalls =>
+ Volatile.Read(ref _collectionEventProviderCalls);
+
internal CancellationToken Token => _lifetime.Token;
internal async Task StartAsync()