Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@
Equipment 原子更新注册状态,未知标识使用失败应答拒绝。
- 已注册报警默认允许发送;禁用后通知发送入口按调用时快照阻断该标识,目录
保持可见,释放并重新注册会恢复默认状态。增加严格协议向量和真实 TCP 测试。
- 增加可释放的 Equipment 在线状态转换处理器;应用可依据当前状态和请求
状态接受或拒绝 Host 在线/离线请求,未注册处理器时保持自动接受。
- 拒绝使用 profile 失败应答且双方状态保持不变;接受仍在 Secondary 完整
写出后更新 Equipment 状态。增加注册生命周期和真实 TCP 状态测试。
- 增加定义、链接、S6F11 动态 Item 向量和真实 TCP 垂直测试,覆盖嵌套值、
空报告、配置拒绝、触发前快照与 Host 拒绝。

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor
隔离。公共 <code>HsmsConnection</code> 将这些内部层组合成显式计时
配置、动态消息收发、状态等待和单消费者事件流。
独立 <code>SecsFrame.Gem</code> 已提供可配置 GEM 通用行为,包含通讯建立、
上下线、动态变量/常量、应用托管时钟、报告定义、事件链接和 Collection
Event、报警目录查询、单报警发送启停,以及最小报警通知和远程命令链路。
上下线与应用转换策略、动态变量/常量、应用托管时钟、报告定义、事件链接和
Collection Event、报警目录查询、单报警发送启停,以及最小报警通知和
远程命令链路。
Item 用法与边界见
[docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见
[docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见
Expand Down
32 changes: 24 additions & 8 deletions docs/GEM-FOUNDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
## 模块与范围

<code>SecsFrame.Gem</code> 是只依赖 <code>SecsFrame</code> 的独立程序集。
当前切片提供 Host/Equipment 两侧的通讯建立、在线/离线、状态变量读取、
设备常量读取、应用托管时钟、报告定义、事件链接和 Collection Event,
以及报警目录查询、单报警发送启停、最小报警通知与远程命令链路,不把
GEM 状态或消息目录放入 HSMS 状态机。
当前切片提供 Host/Equipment 两侧的通讯建立、在线/离线及应用转换策略、
状态变量读取、设备常量读取、应用托管时钟、报告定义、事件链接和
Collection Event,以及报警目录查询、单报警发送启停、最小报警通知与
远程命令链路,不把 GEM 状态或消息目录放入 HSMS 状态机。

<code>GemHostServices</code> 依赖 <code>SecsHost</code>;
<code>GemEquipmentServices</code> 依赖 <code>SecsEquipment</code>。服务不拥有
Expand Down Expand Up @@ -65,8 +65,21 @@ await foreach (var connectionEvent in equipment.GetEventsAsync(cancellationToken
非 Selected 的状态事件后,这些状态会重置;因此只分派数据事件会遗漏
断线重置。

第一切片没有把变量、常量和时钟处理硬性门控在某个 GEM 状态上。这样可在
尚未获得授权 E30 状态表前避免把未经核对的状态条件固化为公共行为。
Equipment 可使用 <code>RegisterOnlineStateTransitionHandler</code> 注册
单一可释放策略。每个结构有效的 Host 在线/离线请求都会把当前状态和目标
状态交给处理器;返回 <code>false</code> 时 Equipment 发送 profile 的失败
应答且保持当前状态,Host 抛出包含操作和原始应答字节的
<code>GemRequestRejectedException</code>,不会更新观察状态。返回
<code>true</code> 时仍先完整写出成功 Secondary,再更新 Equipment 状态。

没有注册处理器时继续自动接受,保持此前行为。处理器在服务锁外执行;释放
注册只影响后续请求,已经取得的处理器快照会完成。处理器异常和取消继续
传播给事件循环,不会被折叠为失败应答。同状态请求也会交给处理器。应用应
遵守角色端点的单消费者事件循环约束,使状态转换按请求顺序执行。

当前切片仍没有把变量、常量、时钟、报告、报警或命令硬性门控在某个 GEM
状态上,也不自动重新建立通讯。这样可在尚未获得授权 E30 状态表前避免把
未经核对的状态条件固化为公共行为。

## 动态数据与时钟

Expand Down Expand Up @@ -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,各嵌套项
Expand All @@ -190,7 +205,7 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码

畸形输入和提供器失败会作为异常返回应用事件循环;语义有效但引用未知变量
或报告的配置使用失败应答拒绝。本切片尚未实现自动 S9Fx、错误 Secondary、
通讯建立策略拒绝、状态切换策略、报警历史/批量控制、
通讯建立策略拒绝、自动重新建立、业务消息状态门控、报警历史/批量控制、
命令目录/权限/调度或完整 GEM 错误恢复,这些行为不能从当前 API 推断为
标准合规。

Expand All @@ -206,7 +221,8 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码
[STANDARDS.md](STANDARDS.md)。

当前证据包括三目标框架的严格输入测试,以及 Host Active / Equipment
Passive 真实 TCP 下的双向通讯建立、上下线、异构动态标识、报告定义、
Passive 真实 TCP 下的双向通讯建立、在线转换接受/拒绝与状态保持、异构
动态标识、报告定义、
事件链接、Collection Event 嵌套值与空报告、报警通知接受/拒绝、配置/事件
拒绝、报警目录全量/选择查询与释放快照、单报警发送启停与未知标识拒绝、
远程命令动态参数与结果、无处理器拒绝、时钟读写和 Linktest。它们是独立
Expand Down
6 changes: 4 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@
- [x] 远程命令的动态参数/结果、Host 发送和 Equipment 处理链路。
- [x] Equipment 运行期报警目录注册与 Host 全量/按标识查询。
- [x] 单个已注册报警的发送启停、未知标识拒绝和通知发送门控。
- [x] Equipment 在线/离线转换的应用决策、显式拒绝与状态保持。
- [ ] 报警历史/批量控制、命令目录/权限/调度和更完整的状态/错误恢复。
- [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。

当前报告、报警通知和远程命令切片使用可替换工程 profile、动态
<code>SecsItem</code> 标识,并保留报警与命令结果原始代码,不宣称已经
核对 E30 的生命周期、代码语义或应答枚举。报警目录查询同样是非规范性
工程基线;单报警发送启停也使用显式可替换控制码。下一优先级是更完整的
GEM 状态/错误恢复,再评估报警历史与批量控制。
工程基线;单报警发送启停也使用显式可替换控制码。在线转换策略默认不注册,
保持既有宽松接受行为。下一优先级是通讯建立拒绝与重新建立恢复,再评估
业务消息状态门控、报警历史和批量控制。

## 阶段 3:元数据与工具

Expand Down
9 changes: 5 additions & 4 deletions docs/SECS-ENDPOINT-ROLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ await equipment.WaitUntilSelectedAsync(cancellationToken);
<code>SecsHost</code> 或 <code>SecsEquipment</code> 类型提供不同业务能力,
而不是修改 HSMS 状态机或根据 Active/Passive 猜测角色。

当前 GEM 切片已实现工程配置下的通讯建立、在线状态、变量、设备常量、
时钟、报告定义、事件链接、Collection Event,以及最小报警通知和远程命令;
当前 GEM 切片已实现工程配置下的通讯建立、在线状态与应用转换策略、变量、
设备常量、时钟、报告定义、事件链接、Collection Event,以及最小报警通知
和远程命令;
报警目录查询支持运行期注册,Host 也可启停单个已注册报警的通知发送。
报警历史/批量控制、命令目录/权限/调度仍未实现。相关行为、事件循环要求和
未核对边界见
通讯建立拒绝、自动重新建立、业务消息状态门控、报警历史/批量控制、命令
目录/权限/调度仍未实现。相关行为、事件循环要求和未核对边界见
[GEM-FOUNDATION.md](GEM-FOUNDATION.md),项目不声明 GEM 合规。
2 changes: 1 addition & 1 deletion docs/STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
的必选/可选边界;还需核对基础消息条件、COMMACK/ONLACK/OFLACK/TIACK
取值、MDLN 条件结构、空 SVID/ECID 列表、报告定义与链接生命周期、
Collection Event 数据结构和应答值、报警通知/目录/发送启停的数据结构与
控制码、时间格式、状态转换与错误响应。
控制码、应用在线转换策略、时间格式、状态转换与错误响应。
- E172/E173:允许分发的 Schema/表示形式、版本标识、扩展点和验证规则。

## 版权与合规边界
Expand Down
57 changes: 55 additions & 2 deletions src/SecsFrame.Gem/GemEquipmentServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public sealed class GemEquipmentServices : IDisposable
private readonly Dictionary<SecsItem, GemEventReportLink> _eventReportLinks = new();
private readonly Dictionary<SecsItem, GemAlarmRegistration> _alarms = new();
private readonly List<GemAlarmRegistration> _alarmCatalog = new();
private GemOnlineStateTransitionRegistration? _onlineStateTransitionHandler;
private GemRemoteCommandRegistration? _remoteCommandHandler;
private int _disposed;

Expand Down Expand Up @@ -92,6 +93,33 @@ public GemValueRegistration RegisterEquipmentConstant(
provider,
"equipment constant");

/// <summary>
/// Registers the single application policy for Host-requested online-state
/// transitions.
/// </summary>
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;
}
}

/// <summary>Registers the single application remote-command handler.</summary>
public GemRemoteCommandRegistration RegisterRemoteCommandHandler(
GemRemoteCommandHandler handler)
Expand Down Expand Up @@ -238,6 +266,7 @@ public void Dispose()
_eventReportLinks.Clear();
_alarms.Clear();
_alarmCatalog.Clear();
_onlineStateTransitionHandler = null;
_remoteCommandHandler = null;
}
_services.Dispose();
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src/SecsFrame.Gem/GemOnlineStateTransitionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SecsFrame.Gem;

/// <summary>Decides whether to accept a Host-requested online-state transition.</summary>
public delegate ValueTask<bool> GemOnlineStateTransitionHandler(
GemOnlineState currentState,
GemOnlineState requestedState,
CancellationToken cancellationToken);
21 changes: 21 additions & 0 deletions src/SecsFrame.Gem/GemOnlineStateTransitionRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace SecsFrame.Gem;

/// <summary>Owns one Equipment online-state transition handler registration.</summary>
public sealed class GemOnlineStateTransitionRegistration : IDisposable
{
private Action<GemOnlineStateTransitionRegistration>? _unregister;

internal GemOnlineStateTransitionRegistration(
GemOnlineStateTransitionHandler handler,
Action<GemOnlineStateTransitionRegistration> unregister)
{
Handler = handler;
_unregister = unregister;
}

internal GemOnlineStateTransitionHandler Handler { get; }

/// <summary>Removes this exact handler registration. Repeated calls are harmless.</summary>
public void Dispose()
=> Interlocked.Exchange(ref _unregister, null)?.Invoke(this);
}
26 changes: 26 additions & 0 deletions test/SecsFrame.Tests/GemEquipmentServicesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>(true);

Assert.Throws<ArgumentNullException>(() =>
services.RegisterOnlineStateTransitionHandler(null!));
var first = services.RegisterOnlineStateTransitionHandler(handler);
Assert.Throws<InvalidOperationException>(() =>
services.RegisterOnlineStateTransitionHandler(handler));
first.Dispose();
first.Dispose();
using var replacement =
services.RegisterOnlineStateTransitionHandler(handler);
services.Dispose();
Assert.Throws<ObjectDisposedException>(() =>
services.RegisterOnlineStateTransitionHandler(handler));
}

[Fact]
public async Task Event_link_rejects_duplicate_report_identifiers()
{
Expand Down
56 changes: 56 additions & 0 deletions test/SecsFrame.Tests/GemFoundationTcpTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<bool>(acceptTransitions);
}))
{
var rejected = await Assert.ThrowsAsync<GemRequestRejectedException>(
() => 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(
Expand Down
Loading