From a0718d336b5fc017cde9a6a7f7cec360843aa1b8 Mon Sep 17 00:00:00 2001 From: Marci Date: Sat, 29 Aug 2026 12:18:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=8A=A5=E8=AD=A6?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E7=8A=B6=E6=80=81=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + README.md | 2 +- docs/GEM-FOUNDATION.md | 35 +++++-- docs/ROADMAP.md | 14 ++- .../GemAlarmNotificationSendPolicyHandler.cs | 8 ++ ...AlarmNotificationSendPolicyRegistration.cs | 21 ++++ src/SecsFrame.Gem/GemEquipmentServices.cs | 60 ++++++++++++ .../GemEquipmentServicesTests.cs | 27 ++++++ test/SecsFrame.Tests/GemFoundationTcpTests.cs | 95 ++++++++++++++----- 9 files changed, 228 insertions(+), 38 deletions(-) create mode 100644 src/SecsFrame.Gem/GemAlarmNotificationSendPolicyHandler.cs create mode 100644 src/SecsFrame.Gem/GemAlarmNotificationSendPolicyRegistration.cs 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 =