From 39ad68495395f8523435f640466c35aaec6267d9 Mon Sep 17 00:00:00 2001 From: Marci Date: Sat, 29 Aug 2026 12:35:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=BF=90=E8=A1=8C=E6=9C=9F=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++ README.md | 3 +- docs/GEM-FOUNDATION.md | 35 +++++++----- docs/ROADMAP.md | 16 ++++-- docs/STANDARDS.md | 2 +- src/SecsFrame.Gem/GemEquipmentServices.cs | 54 ++++++++++++++++++- .../GemRemoteCommandEntryRegistration.cs | 26 +++++++++ .../GemEquipmentServicesTests.cs | 32 +++++++++++ test/SecsFrame.Tests/GemFoundationTcpTests.cs | 49 ++++++++++++++++- 9 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 src/SecsFrame.Gem/GemRemoteCommandEntryRegistration.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee6e6d..68cb660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,10 @@ 和完整通知决定是否发送,未注册时保持现有行为。 - 已注册报警的发送启停检查优先于应用策略;策略拒绝在 S5F1 事务之前短路, 改变决策后可在同一会话显式重试。增加注册生命周期和真实 TCP 状态测试。 +- 增加 Equipment 运行期远程命令精确目录;应用可按动态 + SecsItem 标识注册多个可释放处理器。 +- 命令分派优先使用精确目录项,未匹配时回退现有单一全局处理器;两者都 + 不存在时保持失败结果且不调用接受策略。增加注册生命周期和真实 TCP 测试。 - 增加可替换的报警目录查询消息对、不可变报警定义、Equipment 运行期注册 和 Host 全量/按动态标识查询;全量目录保持注册顺序,未知标识被忽略。 - 报警目录严格校验 W-Bit、请求 List、标识唯一性、三字段定义、单字节 diff --git a/README.md b/README.md index a824d23..ea57da4 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor 建立及应用接受策略、同会话显式重试恢复、上下线与应用转换策略、动态变量/ 常量、应用托管时钟、报告定义、事件链接和带 Equipment 应用发送策略的 Collection Event、报警目录查询、单报警发送启停,以及最小报警通知、 -Equipment 报警通知发送策略、远程命令链路和应用状态接受策略。 +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 8d33e4a..d5c8008 100644 --- a/docs/GEM-FOUNDATION.md +++ b/docs/GEM-FOUNDATION.md @@ -212,11 +212,17 @@ Host 使用 GemRemoteCommand 携带动态命令标识和有序参 参数值均保持为任意非 null SecsItem;参数名称必须唯一,值可 包含嵌套 List。库不把现场命令限制为预生成枚举或 ASCII 字符串。 -Equipment 使用 RegisterRemoteCommandHandler 注册单一可释放 -处理器。处理器返回 GemRemoteCommandResult,其中包含一个原始 +Equipment 使用 RegisterRemoteCommand 按不可变 +SecsItem 命令标识注册多个精确运行期处理器;返回的 +GemRemoteCommandEntryRegistration 暴露精确标识并可释放。 +同一结构相等标识不能重复注册。应用还可使用 +RegisterRemoteCommandHandler 注册单一全局回退处理器。 +分派优先选择精确处理器,未匹配时才选择全局回退;两者都不存在时 Equipment +使用 profile 的失败应答值和空参数结果立即回复。 + +选中的处理器返回 GemRemoteCommandResult,其中包含一个原始 整体完成码和有序参数结果;每个参数结果保留动态名称与原始单字节代码。 -Host 返回完整结果,不把非零代码折叠成异常,也不解释代码枚举。没有处理器 -时 Equipment 使用 profile 的失败应答值和空参数结果立即回复。 +Host 返回完整结果,不把非零代码折叠成异常,也不解释代码枚举。 Equipment 可另用 RegisterRemoteCommandAcceptanceHandler 注册单一可释放接受策略。存在执行处理器时,每个结构有效的请求先把当前 @@ -225,15 +231,16 @@ Equipment 可另用 RegisterRemoteCommandAcceptanceHandler 和空参数结果回复,执行处理器不会被调用,双方 GEM 状态不变;Host 仍返回 原始 GemRemoteCommandResult,不会把非零结果转换为异常。 -没有注册接受策略时命令直接进入执行处理器,保持此前行为。没有执行处理器 -时请求仍立即失败,接受策略不会执行。两个处理器在 Equipment 锁内按请求 -一起快照并在锁外按策略、执行顺序调用;释放注册只影响后续请求,已取得的 -快照会完成。策略或执行处理器的异常和取消继续传播给事件循环,不会被折叠 -为失败应答。 +没有注册接受策略时命令直接进入选中的执行处理器,保持此前行为。没有执行 +处理器时请求仍立即失败,接受策略不会执行。接受策略、精确/回退选择和当前 +状态在 Equipment 锁内按请求一起快照,并在锁外按策略、执行顺序调用;释放 +注册只影响后续请求,已取得的快照会完成。策略或执行处理器的异常和取消 +继续传播给事件循环,不会被折叠为失败应答。 当前切片只要求请求参数名和结果参数名各自唯一,不推断结果必须覆盖哪些 -请求参数,也不内置任何通讯/在线状态矩阵。本切片不包含命令目录、参数 -Schema、权限、排队、自动重试、幂等策略或执行历史。 +请求参数,也不内置任何通讯/在线状态矩阵。运行期目录只负责应用侧精确 +分派,不新增 E30 消息、不提供 Host 目录查询,也不包含参数 Schema、权限、 +排队、自动重试、幂等策略或执行历史。 ## 严格失败边界 @@ -289,6 +296,6 @@ Passive 真实 TCP 下的双向通讯建立、首次建立拒绝后同会话恢 事件链接、Collection Event 嵌套值与空报告、应用发送策略拒绝零采值/零 事务和同会话重试、报警通知接受/拒绝、应用发送策略拒绝零事务与同会话 重试、已禁用报警优先拒绝、配置/事件拒绝、报警目录全量/选择查询与释放 -快照、单报警发送启停与未知标识拒绝、远程命令动态参数与结果、无处理器 -拒绝、应用状态拒绝短路与同会话重试、时钟读写和 Linktest。它们是独立 -工程验证,不是 SEMI 一致性认证。 +快照、单报警发送启停与未知标识拒绝、远程命令动态参数与结果、精确目录 +优先/全局回退/释放恢复、无处理器拒绝、应用状态拒绝短路与同会话重试、 +时钟读写和 Linktest。它们是独立工程验证,不是 SEMI 一致性认证。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4a6a436..5ee65fe 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -41,6 +41,7 @@ epoch 收口与 Passive 监听加固。后续切片继续扩展 GEM 通用行为 - [x] 报告定义、事件链接和 Collection Event。 - [x] 报警通知的动态标识、代码透传、Host 处理器和 Equipment 发送链路。 - [x] 远程命令的动态参数/结果、Host 发送和 Equipment 处理链路。 +- [x] Equipment 运行期远程命令精确目录、全局回退与可释放生命周期。 - [x] Equipment 运行期报警目录注册与 Host 全量/按标识查询。 - [x] 单个已注册报警的发送启停、未知标识拒绝和通知发送门控。 - [x] Equipment 在线/离线转换的应用决策、显式拒绝与状态保持。 @@ -50,7 +51,7 @@ epoch 收口与 Passive 监听加固。后续切片继续扩展 GEM 通用行为 与同会话重试。 - [x] Equipment 报警通知发送的应用状态策略、发送启停优先级、拒绝零事务 与同会话重试。 -- [ ] 报警历史/批量控制、命令目录/权限/调度和更完整的状态/错误恢复。 +- [ ] 报警历史/批量控制、命令权限/调度和更完整的状态/错误恢复。 - [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。 当前报告、报警通知和远程命令切片使用可替换工程 profile、动态 @@ -67,10 +68,15 @@ Collection Event 发送策略沿用相同的默认不注册模式;拒绝发生 发生在 S5F1 事务之前,并可在同一会话显式重试。至此当前 Equipment 主动 业务发送入口的应用状态策略已经闭环。 -下一优先级转向报警历史/批量控制。最小切片应先用授权 E30-0526 核对消息 -条件、数据结构和代码语义,再确定可替换 profile 下的不可变历史模型、 -Equipment 快照边界、Host 查询 API、严格向量和真实 TCP 证据;核对完成前 -不固化新的默认消息对。自动恢复和命令目录/权限/调度继续保持后续评估项。 +报警历史/批量控制仍是标准核对后的优先项:最小切片必须先用授权 E30-0526 +核对消息条件、数据结构和代码语义,再确定可替换 profile 下的不可变历史 +模型、Equipment 快照边界、Host 查询 API、严格向量和真实 TCP 证据;核对 +完成前不固化新的默认消息对。 + +当前仓库没有可用于该核对的授权材料,因此先完成不新增消息映射的 Equipment +运行期命令目录:动态标识精确处理器优先,现有单一处理器作为全局回退, +未知命令保持失败结果。下一可执行优先级是评估命令权限/参数约束如何复用 +现有全局接受策略而不重复策略层;自动恢复和调度继续保持后续评估项。 ## 阶段 3:元数据与工具 diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md index 564f5bc..dc6837f 100644 --- a/docs/STANDARDS.md +++ b/docs/STANDARDS.md @@ -9,7 +9,7 @@ |---|---|---| | SECS-II 消息内容 | SEMI E5-0725 | Item、动态消息编解码与内部 T3 数据事务已有工程实现;完整事务规则待核对 | | HSMS / HSMS-SS | SEMI E37-0222 / E37.1-0819 | 帧、Data Payload、内部传输、T5/T8 工程计时、控制状态机与会话绑定数据发送已有实现;仍待授权标准和一致性测试核对 | -| GEM | SEMI E30-0526 | 通讯建立与双向应用接受策略、上下线、动态变量/常量、时钟、报告/事件、报警通知/目录/发送启停,以及带应用状态接受策略的远程命令已有可替换 profile 的工程实现;规范性条件待核对 | +| GEM | SEMI E30-0526 | 通讯建立与双向应用接受策略、上下线、动态变量/常量、时钟、报告/事件、报警通知/目录/发送启停,以及带应用状态接受策略和运行期精确分派目录的远程命令已有可替换 profile 的工程实现;规范性条件待核对 | | SEDD | SEMI E172-0225 | 规划中 | | SMN | SEMI E173-0721 | 规划中 | diff --git a/src/SecsFrame.Gem/GemEquipmentServices.cs b/src/SecsFrame.Gem/GemEquipmentServices.cs index 26c92fa..bd569d4 100644 --- a/src/SecsFrame.Gem/GemEquipmentServices.cs +++ b/src/SecsFrame.Gem/GemEquipmentServices.cs @@ -18,6 +18,8 @@ public sealed class GemEquipmentServices : IDisposable private readonly Dictionary _eventReportLinks = new(); private readonly Dictionary _alarms = new(); private readonly List _alarmCatalog = new(); + private readonly Dictionary + _remoteCommandEntries = new(); private GemOnlineStateTransitionRegistration? _onlineStateTransitionHandler; private GemCollectionEventSendPolicyRegistration? _collectionEventSendPolicyHandler; @@ -219,7 +221,7 @@ public GemAlarmNotificationSendPolicyRegistration } } - /// Registers the single application remote-command handler. + /// Registers the single fallback remote-command handler. public GemRemoteCommandRegistration RegisterRemoteCommandHandler( GemRemoteCommandHandler handler) { @@ -243,6 +245,34 @@ public GemRemoteCommandRegistration RegisterRemoteCommandHandler( } } + /// Registers one exact runtime remote-command handler. + public GemRemoteCommandEntryRegistration RegisterRemoteCommand( + SecsItem command, + GemRemoteCommandHandler handler) + { + if (command is null) + throw new ArgumentNullException(nameof(command)); + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + + lock (_gate) + { + ThrowIfDisposed(); + if (_remoteCommandEntries.ContainsKey(command)) + { + throw new InvalidOperationException( + $"Remote command {command} is already registered."); + } + + var registration = new GemRemoteCommandEntryRegistration( + command, + handler, + UnregisterRemoteCommand); + _remoteCommandEntries.Add(command, registration); + return registration; + } + } + /// Registers one exact runtime alarm definition. public GemAlarmRegistration RegisterAlarm(GemAlarmDefinition definition) { @@ -400,6 +430,7 @@ public void Dispose() _eventReportLinks.Clear(); _alarms.Clear(); _alarmCatalog.Clear(); + _remoteCommandEntries.Clear(); _onlineStateTransitionHandler = null; _collectionEventSendPolicyHandler = null; _alarmNotificationSendPolicyHandler = null; @@ -603,7 +634,11 @@ links is null lock (_gate) { acceptanceHandler = _remoteCommandAcceptanceHandler?.Handler; - handler = _remoteCommandHandler?.Handler; + handler = _remoteCommandEntries.TryGetValue( + command.Command, + out var registration) + ? registration.Handler + : _remoteCommandHandler?.Handler; communicationState = CommunicationState; onlineState = OnlineState; } @@ -939,6 +974,21 @@ private void UnregisterRemoteCommandHandler( } } + private void UnregisterRemoteCommand( + GemRemoteCommandEntryRegistration registration) + { + lock (_gate) + { + if (_remoteCommandEntries.TryGetValue( + registration.Command, + out var current) && + ReferenceEquals(current, registration)) + { + _remoteCommandEntries.Remove(registration.Command); + } + } + } + private void UnregisterRemoteCommandAcceptanceHandler( GemRemoteCommandAcceptanceRegistration registration) { diff --git a/src/SecsFrame.Gem/GemRemoteCommandEntryRegistration.cs b/src/SecsFrame.Gem/GemRemoteCommandEntryRegistration.cs new file mode 100644 index 0000000..7f34520 --- /dev/null +++ b/src/SecsFrame.Gem/GemRemoteCommandEntryRegistration.cs @@ -0,0 +1,26 @@ +namespace SecsFrame.Gem; + +/// Owns one exact Equipment remote-command directory entry. +public sealed class GemRemoteCommandEntryRegistration : IDisposable +{ + private Action? _unregister; + + internal GemRemoteCommandEntryRegistration( + SecsItem command, + GemRemoteCommandHandler handler, + Action unregister) + { + Command = command; + Handler = handler; + _unregister = unregister; + } + + /// Gets the exact dynamic command identifier. + public SecsItem Command { get; } + + internal GemRemoteCommandHandler Handler { get; } + + /// Removes this exact directory entry. Repeated calls are harmless. + public void Dispose() + => Interlocked.Exchange(ref _unregister, null)?.Invoke(this); +} diff --git a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs index aff0cae..b8646e7 100644 --- a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs +++ b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs @@ -325,6 +325,38 @@ public async Task Remote_command_handler_is_exact_disposable_and_replaceable() services.RegisterRemoteCommandHandler(handler)); } + [Fact] + public async Task Remote_command_entry_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)); + var command = SecsItem.Ascii("START"); + GemRemoteCommandHandler handler = static (_, _) => + new ValueTask( + new GemRemoteCommandResult( + 0, + Array.Empty())); + + Assert.Throws(() => + services.RegisterRemoteCommand(null!, handler)); + Assert.Throws(() => + services.RegisterRemoteCommand(command, null!)); + var first = services.RegisterRemoteCommand(command, handler); + Assert.Equal(command, first.Command); + Assert.Throws(() => + services.RegisterRemoteCommand(SecsItem.Ascii("START"), handler)); + first.Dispose(); + first.Dispose(); + using var replacement = services.RegisterRemoteCommand(command, handler); + services.Dispose(); + Assert.Throws(() => + services.RegisterRemoteCommand(SecsItem.U4(2), handler)); + } + [Fact] public async Task Remote_command_acceptance_handler_is_exact_disposable_and_replaceable() { diff --git a/test/SecsFrame.Tests/GemFoundationTcpTests.cs b/test/SecsFrame.Tests/GemFoundationTcpTests.cs index dda5c27..4a40b29 100644 --- a/test/SecsFrame.Tests/GemFoundationTcpTests.cs +++ b/test/SecsFrame.Tests/GemFoundationTcpTests.cs @@ -632,6 +632,7 @@ await AssertRemoteCommandAcceptancePolicyAsync(context) SecsItem.List(SecsItem.Boolean(true), SecsItem.F8(1.5)), command.Parameters[1].Value); + await AssertRemoteCommandDirectoryAsync(context).ConfigureAwait(true); var acceptanceCalls = 0; using (context.EquipmentServices.RegisterRemoteCommandAcceptanceHandler( (_, _, _, _) => @@ -649,6 +650,49 @@ await AssertRemoteCommandAcceptancePolicyAsync(context) Assert.Equal(0, acceptanceCalls); } + private static async Task AssertRemoteCommandDirectoryAsync( + GemTcpContext context) + { + var exactCalls = 0; + var fallbackCalls = 0; + using var fallbackRegistration = + context.EquipmentServices.RegisterRemoteCommandHandler( + (_, _) => + { + fallbackCalls++; + return new ValueTask( + CreateAcceptedRemoteCommandResult()); + }); + using var exactRegistration = + context.EquipmentServices.RegisterRemoteCommand( + SecsItem.U4(7001), + (_, _) => + { + exactCalls++; + return new ValueTask( + CreateAcceptedRemoteCommandResult()); + }); + + AssertAcceptedRemoteCommandResult( + await context.HostServices.ExecuteRemoteCommandAsync( + CreateRemoteCommand(), + context.Token).ConfigureAwait(true)); + Assert.Equal((1, 0), (exactCalls, fallbackCalls)); + + AssertAcceptedRemoteCommandResult( + await context.HostServices.ExecuteRemoteCommandAsync( + CreateRemoteCommand(SecsItem.Ascii("FALLBACK")), + context.Token).ConfigureAwait(true)); + Assert.Equal((1, 1), (exactCalls, fallbackCalls)); + + exactRegistration.Dispose(); + AssertAcceptedRemoteCommandResult( + await context.HostServices.ExecuteRemoteCommandAsync( + CreateRemoteCommand(), + context.Token).ConfigureAwait(true)); + Assert.Equal((1, 2), (exactCalls, fallbackCalls)); + } + private static async Task AssertRemoteCommandAcceptancePolicyAsync(GemTcpContext context) { @@ -762,8 +806,11 @@ private static void AssertRemoteCommandPolicyObservations( observed); private static GemRemoteCommand CreateRemoteCommand() + => CreateRemoteCommand(SecsItem.U4(7001)); + + private static GemRemoteCommand CreateRemoteCommand(SecsItem command) => new( - SecsItem.U4(7001), + command, new[] { new GemRemoteCommandParameter(