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()