diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f118ae..0a0e718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,10 @@ Equipment 原子更新注册状态,未知标识使用失败应答拒绝。 - 已注册报警默认允许发送;禁用后通知发送入口按调用时快照阻断该标识,目录 保持可见,释放并重新注册会恢复默认状态。增加严格协议向量和真实 TCP 测试。 +- 增加可释放的 Equipment 在线状态转换处理器;应用可依据当前状态和请求 + 状态接受或拒绝 Host 在线/离线请求,未注册处理器时保持自动接受。 +- 拒绝使用 profile 失败应答且双方状态保持不变;接受仍在 Secondary 完整 + 写出后更新 Equipment 状态。增加注册生命周期和真实 TCP 状态测试。 - 增加定义、链接、S6F11 动态 Item 向量和真实 TCP 垂直测试,覆盖嵌套值、 空报告、配置拒绝、触发前快照与 Host 拒绝。 diff --git a/README.md b/README.md index dc8691b..5dd413b 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor 隔离。公共 HsmsConnection 将这些内部层组合成显式计时 配置、动态消息收发、状态等待和单消费者事件流。 独立 SecsFrame.Gem 已提供可配置 GEM 通用行为,包含通讯建立、 -上下线、动态变量/常量、应用托管时钟、报告定义、事件链接和 Collection -Event、报警目录查询、单报警发送启停,以及最小报警通知和远程命令链路。 +上下线与应用转换策略、动态变量/常量、应用托管时钟、报告定义、事件链接和 +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 0eeb072..d01a738 100644 --- a/docs/GEM-FOUNDATION.md +++ b/docs/GEM-FOUNDATION.md @@ -3,10 +3,10 @@ ## 模块与范围 SecsFrame.Gem 是只依赖 SecsFrame 的独立程序集。 -当前切片提供 Host/Equipment 两侧的通讯建立、在线/离线、状态变量读取、 -设备常量读取、应用托管时钟、报告定义、事件链接和 Collection Event, -以及报警目录查询、单报警发送启停、最小报警通知与远程命令链路,不把 -GEM 状态或消息目录放入 HSMS 状态机。 +当前切片提供 Host/Equipment 两侧的通讯建立、在线/离线及应用转换策略、 +状态变量读取、设备常量读取、应用托管时钟、报告定义、事件链接和 +Collection Event,以及报警目录查询、单报警发送启停、最小报警通知与 +远程命令链路,不把 GEM 状态或消息目录放入 HSMS 状态机。 GemHostServices 依赖 SecsHost; GemEquipmentServices 依赖 SecsEquipment。服务不拥有 @@ -65,8 +65,21 @@ await foreach (var connectionEvent in equipment.GetEventsAsync(cancellationToken 非 Selected 的状态事件后,这些状态会重置;因此只分派数据事件会遗漏 断线重置。 -第一切片没有把变量、常量和时钟处理硬性门控在某个 GEM 状态上。这样可在 -尚未获得授权 E30 状态表前避免把未经核对的状态条件固化为公共行为。 +Equipment 可使用 RegisterOnlineStateTransitionHandler 注册 +单一可释放策略。每个结构有效的 Host 在线/离线请求都会把当前状态和目标 +状态交给处理器;返回 false 时 Equipment 发送 profile 的失败 +应答且保持当前状态,Host 抛出包含操作和原始应答字节的 +GemRequestRejectedException,不会更新观察状态。返回 +true 时仍先完整写出成功 Secondary,再更新 Equipment 状态。 + +没有注册处理器时继续自动接受,保持此前行为。处理器在服务锁外执行;释放 +注册只影响后续请求,已经取得的处理器快照会完成。处理器异常和取消继续 +传播给事件循环,不会被折叠为失败应答。同状态请求也会交给处理器。应用应 +遵守角色端点的单消费者事件循环约束,使状态转换按请求顺序执行。 + +当前切片仍没有把变量、常量、时钟、报告、报警或命令硬性门控在某个 GEM +状态上,也不自动重新建立通讯。这样可在尚未获得授权 E30 状态表前避免把 +未经核对的状态条件固化为公共行为。 ## 动态数据与时钟 @@ -170,6 +183,8 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码 ASCII 或 Identity 结构; - Secondary 的 Stream、Function 和 W-Bit 与配置完全匹配; - 应答是单个 Binary 字节,接受的通讯建立应答包含两项 ASCII Identity; +- 在线/离线请求必须设置 W-Bit 且 Body 为空;应用拒绝使用 profile 失败 + 应答,接受应答写出完成前不得改变 Equipment 在线状态; - 状态变量和设备常量请求只引用已注册标识,提供器不得返回 null; - 时钟字符串由显式 codec 完整解析; - 报告/链接请求是二元素 List,Collection Event 是三元素 List,各嵌套项 @@ -190,7 +205,7 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码 畸形输入和提供器失败会作为异常返回应用事件循环;语义有效但引用未知变量 或报告的配置使用失败应答拒绝。本切片尚未实现自动 S9Fx、错误 Secondary、 -通讯建立策略拒绝、状态切换策略、报警历史/批量控制、 +通讯建立策略拒绝、自动重新建立、业务消息状态门控、报警历史/批量控制、 命令目录/权限/调度或完整 GEM 错误恢复,这些行为不能从当前 API 推断为 标准合规。 @@ -206,7 +221,8 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码 [STANDARDS.md](STANDARDS.md)。 当前证据包括三目标框架的严格输入测试,以及 Host Active / Equipment -Passive 真实 TCP 下的双向通讯建立、上下线、异构动态标识、报告定义、 +Passive 真实 TCP 下的双向通讯建立、在线转换接受/拒绝与状态保持、异构 +动态标识、报告定义、 事件链接、Collection Event 嵌套值与空报告、报警通知接受/拒绝、配置/事件 拒绝、报警目录全量/选择查询与释放快照、单报警发送启停与未知标识拒绝、 远程命令动态参数与结果、无处理器拒绝、时钟读写和 Linktest。它们是独立 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9aa58d3..e841873 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -42,14 +42,16 @@ - [x] 远程命令的动态参数/结果、Host 发送和 Equipment 处理链路。 - [x] Equipment 运行期报警目录注册与 Host 全量/按标识查询。 - [x] 单个已注册报警的发送启停、未知标识拒绝和通知发送门控。 +- [x] Equipment 在线/离线转换的应用决策、显式拒绝与状态保持。 - [ ] 报警历史/批量控制、命令目录/权限/调度和更完整的状态/错误恢复。 - [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。 当前报告、报警通知和远程命令切片使用可替换工程 profile、动态 SecsItem 标识,并保留报警与命令结果原始代码,不宣称已经 核对 E30 的生命周期、代码语义或应答枚举。报警目录查询同样是非规范性 -工程基线;单报警发送启停也使用显式可替换控制码。下一优先级是更完整的 -GEM 状态/错误恢复,再评估报警历史与批量控制。 +工程基线;单报警发送启停也使用显式可替换控制码。在线转换策略默认不注册, +保持既有宽松接受行为。下一优先级是通讯建立拒绝与重新建立恢复,再评估 +业务消息状态门控、报警历史和批量控制。 ## 阶段 3:元数据与工具 diff --git a/docs/SECS-ENDPOINT-ROLES.md b/docs/SECS-ENDPOINT-ROLES.md index 1daf819..67e6e87 100644 --- a/docs/SECS-ENDPOINT-ROLES.md +++ b/docs/SECS-ENDPOINT-ROLES.md @@ -61,9 +61,10 @@ await equipment.WaitUntilSelectedAsync(cancellationToken); SecsHost 或 SecsEquipment 类型提供不同业务能力, 而不是修改 HSMS 状态机或根据 Active/Passive 猜测角色。 -当前 GEM 切片已实现工程配置下的通讯建立、在线状态、变量、设备常量、 -时钟、报告定义、事件链接、Collection Event,以及最小报警通知和远程命令; +当前 GEM 切片已实现工程配置下的通讯建立、在线状态与应用转换策略、变量、 +设备常量、时钟、报告定义、事件链接、Collection Event,以及最小报警通知 +和远程命令; 报警目录查询支持运行期注册,Host 也可启停单个已注册报警的通知发送。 -报警历史/批量控制、命令目录/权限/调度仍未实现。相关行为、事件循环要求和 -未核对边界见 +通讯建立拒绝、自动重新建立、业务消息状态门控、报警历史/批量控制、命令 +目录/权限/调度仍未实现。相关行为、事件循环要求和未核对边界见 [GEM-FOUNDATION.md](GEM-FOUNDATION.md),项目不声明 GEM 合规。 diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md index 0df1100..6388921 100644 --- a/docs/STANDARDS.md +++ b/docs/STANDARDS.md @@ -39,7 +39,7 @@ 的必选/可选边界;还需核对基础消息条件、COMMACK/ONLACK/OFLACK/TIACK 取值、MDLN 条件结构、空 SVID/ECID 列表、报告定义与链接生命周期、 Collection Event 数据结构和应答值、报警通知/目录/发送启停的数据结构与 - 控制码、时间格式、状态转换与错误响应。 + 控制码、应用在线转换策略、时间格式、状态转换与错误响应。 - E172/E173:允许分发的 Schema/表示形式、版本标识、扩展点和验证规则。 ## 版权与合规边界 diff --git a/src/SecsFrame.Gem/GemEquipmentServices.cs b/src/SecsFrame.Gem/GemEquipmentServices.cs index 6dd4e53..a20c0c3 100644 --- a/src/SecsFrame.Gem/GemEquipmentServices.cs +++ b/src/SecsFrame.Gem/GemEquipmentServices.cs @@ -18,6 +18,7 @@ public sealed class GemEquipmentServices : IDisposable private readonly Dictionary _eventReportLinks = new(); private readonly Dictionary _alarms = new(); private readonly List _alarmCatalog = new(); + private GemOnlineStateTransitionRegistration? _onlineStateTransitionHandler; private GemRemoteCommandRegistration? _remoteCommandHandler; private int _disposed; @@ -92,6 +93,33 @@ public GemValueRegistration RegisterEquipmentConstant( provider, "equipment constant"); + /// + /// Registers the single application policy for Host-requested online-state + /// transitions. + /// + public GemOnlineStateTransitionRegistration RegisterOnlineStateTransitionHandler( + GemOnlineStateTransitionHandler handler) + { + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + + lock (_gate) + { + ThrowIfDisposed(); + if (_onlineStateTransitionHandler is not null) + { + throw new InvalidOperationException( + "An online-state transition handler is already registered."); + } + + var registration = new GemOnlineStateTransitionRegistration( + handler, + UnregisterOnlineStateTransitionHandler); + _onlineStateTransitionHandler = registration; + return registration; + } + } + /// Registers the single application remote-command handler. public GemRemoteCommandRegistration RegisterRemoteCommandHandler( GemRemoteCommandHandler handler) @@ -238,6 +266,7 @@ public void Dispose() _eventReportLinks.Clear(); _alarms.Clear(); _alarmCatalog.Clear(); + _onlineStateTransitionHandler = null; _remoteCommandHandler = null; } _services.Dispose(); @@ -288,13 +317,27 @@ private void RegisterEquipmentRoutes() cancellationToken.ThrowIfCancellationRequested(); GemMessageCodec.RequireReplyExpected(context, operation); GemMessageCodec.RequireEmptyBody(context.Message.RootItem, operation); + GemOnlineState currentState; + GemOnlineStateTransitionHandler? handler; + lock (_gate) + { + ThrowIfDisposed(); + currentState = OnlineState; + handler = _onlineStateTransitionHandler?.Handler; + } + + var accepted = handler is null || + await handler(currentState, state, cancellationToken).ConfigureAwait(false); await _services.ReplyAsync( context, pair, GemMessageCodec.EncodeAcknowledgement( - Profile.AcceptedAcknowledgement), + accepted + ? Profile.AcceptedAcknowledgement + : Profile.FailedAcknowledgement), cancellationToken).ConfigureAwait(false); - _services.SetOnlineState(state); + if (accepted) + _services.SetOnlineState(state); return null; } @@ -731,6 +774,16 @@ private void UnregisterRemoteCommandHandler( } } + private void UnregisterOnlineStateTransitionHandler( + GemOnlineStateTransitionRegistration registration) + { + lock (_gate) + { + if (ReferenceEquals(_onlineStateTransitionHandler, registration)) + _onlineStateTransitionHandler = null; + } + } + private void UnregisterAlarm(GemAlarmRegistration registration) { lock (_gate) diff --git a/src/SecsFrame.Gem/GemOnlineStateTransitionHandler.cs b/src/SecsFrame.Gem/GemOnlineStateTransitionHandler.cs new file mode 100644 index 0000000..f45f54e --- /dev/null +++ b/src/SecsFrame.Gem/GemOnlineStateTransitionHandler.cs @@ -0,0 +1,7 @@ +namespace SecsFrame.Gem; + +/// Decides whether to accept a Host-requested online-state transition. +public delegate ValueTask GemOnlineStateTransitionHandler( + GemOnlineState currentState, + GemOnlineState requestedState, + CancellationToken cancellationToken); diff --git a/src/SecsFrame.Gem/GemOnlineStateTransitionRegistration.cs b/src/SecsFrame.Gem/GemOnlineStateTransitionRegistration.cs new file mode 100644 index 0000000..f1129e5 --- /dev/null +++ b/src/SecsFrame.Gem/GemOnlineStateTransitionRegistration.cs @@ -0,0 +1,21 @@ +namespace SecsFrame.Gem; + +/// Owns one Equipment online-state transition handler registration. +public sealed class GemOnlineStateTransitionRegistration : IDisposable +{ + private Action? _unregister; + + internal GemOnlineStateTransitionRegistration( + GemOnlineStateTransitionHandler handler, + Action unregister) + { + Handler = handler; + _unregister = unregister; + } + + internal GemOnlineStateTransitionHandler Handler { get; } + + /// Removes this exact handler registration. 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 dd65b4b..973bb3a 100644 --- a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs +++ b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs @@ -219,6 +219,32 @@ public async Task Dynamic_registration_is_exact_disposable_and_replaceable() services.RegisterStatusVariable(SecsItem.U4(1002), provider)); } + [Fact] + public async Task Online_state_transition_handler_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)); + GemOnlineStateTransitionHandler handler = static (_, _, _) => + new ValueTask(true); + + Assert.Throws(() => + services.RegisterOnlineStateTransitionHandler(null!)); + var first = services.RegisterOnlineStateTransitionHandler(handler); + Assert.Throws(() => + services.RegisterOnlineStateTransitionHandler(handler)); + first.Dispose(); + first.Dispose(); + using var replacement = + services.RegisterOnlineStateTransitionHandler(handler); + services.Dispose(); + Assert.Throws(() => + services.RegisterOnlineStateTransitionHandler(handler)); + } + [Fact] public async Task Event_link_rejects_duplicate_report_identifiers() { diff --git a/test/SecsFrame.Tests/GemFoundationTcpTests.cs b/test/SecsFrame.Tests/GemFoundationTcpTests.cs index 935b86e..045a1a0 100644 --- a/test/SecsFrame.Tests/GemFoundationTcpTests.cs +++ b/test/SecsFrame.Tests/GemFoundationTcpTests.cs @@ -12,6 +12,7 @@ public async Task Host_and_equipment_complete_foundational_dialogues_over_tcp() await context.StartAsync().ConfigureAwait(true); await AssertCommunicationAndOnlineAsync(context).ConfigureAwait(true); + await AssertOnlineTransitionPolicyAsync(context).ConfigureAwait(true); await AssertDynamicDataAsync(context).ConfigureAwait(true); await AssertCollectionEventsAsync(context).ConfigureAwait(true); await AssertAlarmCatalogAsync(context).ConfigureAwait(true); @@ -66,6 +67,61 @@ await WaitUntilAsync( Assert.Equal(GemOnlineState.Online, context.EquipmentServices.OnlineState); } + private static async Task AssertOnlineTransitionPolicyAsync( + GemTcpContext context) + { + var acceptTransitions = false; + var observed = new List<( + GemOnlineState Current, + GemOnlineState Requested)>(); + using (context.EquipmentServices.RegisterOnlineStateTransitionHandler( + (currentState, requestedState, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + observed.Add((currentState, requestedState)); + return new ValueTask(acceptTransitions); + })) + { + var rejected = await Assert.ThrowsAsync( + () => context.HostServices.RequestOfflineAsync(context.Token)) + .ConfigureAwait(true); + Assert.Equal(GemOperation.RequestOffline, rejected.Operation); + Assert.Equal((byte)1, rejected.Acknowledgement); + Assert.Equal(GemOnlineState.Online, context.HostServices.OnlineState); + Assert.Equal( + GemOnlineState.Online, + context.EquipmentServices.OnlineState); + + acceptTransitions = true; + await context.HostServices.RequestOfflineAsync(context.Token) + .ConfigureAwait(true); + await WaitUntilAsync( + () => context.EquipmentServices.OnlineState == + GemOnlineState.Offline, + context.Token).ConfigureAwait(true); + Assert.Equal( + GemOnlineState.Offline, + context.HostServices.OnlineState); + + await context.HostServices.RequestOnlineAsync(context.Token) + .ConfigureAwait(true); + await WaitUntilAsync( + () => context.EquipmentServices.OnlineState == + GemOnlineState.Online, + context.Token).ConfigureAwait(true); + Assert.Equal(GemOnlineState.Online, context.HostServices.OnlineState); + } + + Assert.Equal( + new[] + { + (GemOnlineState.Online, GemOnlineState.Offline), + (GemOnlineState.Online, GemOnlineState.Offline), + (GemOnlineState.Offline, GemOnlineState.Online), + }, + observed); + } + private static async Task AssertDynamicDataAsync(GemTcpContext context) { var variables = await context.HostServices.ReadStatusVariablesAsync(