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(