diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a0e718..38d9caa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -108,6 +108,11 @@
状态接受或拒绝 Host 在线/离线请求,未注册处理器时保持自动接受。
- 拒绝使用 profile 失败应答且双方状态保持不变;接受仍在 Secondary 完整
写出后更新 Equipment 状态。增加注册生命周期和真实 TCP 状态测试。
+- 增加 Host/Equipment 双向可释放通讯建立策略;应用可依据请求方身份接受
+ 或拒绝 S1F13,未注册策略时保持自动接受。
+- 拒绝使用 profile 失败应答和空身份 List,双方既有通讯状态与对端身份均
+ 保持不变;应用随后可在同一 Selected 会话显式重试恢复。增加双向注册
+ 生命周期、首次建立拒绝/恢复和已通讯重建拒绝/恢复的真实 TCP 状态测试。
- 增加定义、链接、S6F11 动态 Item 向量和真实 TCP 垂直测试,覆盖嵌套值、
空报告、配置拒绝、触发前快照与 Host 拒绝。
diff --git a/README.md b/README.md
index 5dd413b..ca4d82f 100644
--- a/README.md
+++ b/README.md
@@ -30,10 +30,10 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor
进一步提供实际写出后启动的 T3、复合事务键关联、入站回复和会话失效
隔离。公共 HsmsConnection 将这些内部层组合成显式计时
配置、动态消息收发、状态等待和单消费者事件流。
-独立 SecsFrame.Gem 已提供可配置 GEM 通用行为,包含通讯建立、
-上下线与应用转换策略、动态变量/常量、应用托管时钟、报告定义、事件链接和
-Collection Event、报警目录查询、单报警发送启停,以及最小报警通知和
-远程命令链路。
+独立 SecsFrame.Gem 已提供可配置 GEM 通用行为,包含双向通讯
+建立及应用接受策略、同会话显式重试恢复、上下线与应用转换策略、动态变量/
+常量、应用托管时钟、报告定义、事件链接和 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 d01a738..09079f4 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,6 +65,21 @@ await foreach (var connectionEvent in equipment.GetEventsAsync(cancellationToken
非 Selected 的状态事件后,这些状态会重置;因此只分派数据事件会遗漏
断线重置。
+Host 与 Equipment 都可使用
+RegisterCommunicationEstablishmentHandler 注册单一可释放
+策略。每个结构有效的对端通讯建立请求都会把请求方
+GemIdentity 交给处理器。返回 false 时服务发送
+profile 的失败应答和空身份 List,发起方抛出包含操作和原始应答字节的
+GemRequestRejectedException。双方均不更新
+CommunicationState 或 PeerIdentity;如果请求是
+已通讯状态下的重新建立,既有状态和身份也会保留。
+
+返回 true 时服务先完整写出成功 Secondary,再记录请求方身份
+并进入 Communicating。没有注册策略时继续自动接受,保持此前
+行为。应用可以改变策略结果并在同一 Selected 会话再次调用
+EstablishCommunicationAsync;库不自动重试、不创建计时器或
+后台恢复循环。
+
Equipment 可使用 RegisterOnlineStateTransitionHandler 注册
单一可释放策略。每个结构有效的 Host 在线/离线请求都会把当前状态和目标
状态交给处理器;返回 false 时 Equipment 发送 profile 的失败
@@ -72,10 +87,11 @@ Equipment 可使用 RegisterOnlineStateTransitionHandler 注册
GemRequestRejectedException,不会更新观察状态。返回
true 时仍先完整写出成功 Secondary,再更新 Equipment 状态。
-没有注册处理器时继续自动接受,保持此前行为。处理器在服务锁外执行;释放
+这两类策略没有注册处理器时都继续自动接受。处理器在服务锁外执行;释放
注册只影响后续请求,已经取得的处理器快照会完成。处理器异常和取消继续
-传播给事件循环,不会被折叠为失败应答。同状态请求也会交给处理器。应用应
-遵守角色端点的单消费者事件循环约束,使状态转换按请求顺序执行。
+传播给事件循环,不会被折叠为失败应答。同状态在线请求也会交给处理器。
+应用应遵守角色端点的单消费者事件循环约束,使决策和状态转换按请求顺序
+执行。
当前切片仍没有把变量、常量、时钟、报告、报警或命令硬性门控在某个 GEM
状态上,也不自动重新建立通讯。这样可在尚未获得授权 E30 状态表前避免把
@@ -182,7 +198,8 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码
- 基础 Primary 设置 W-Bit,请求体符合 profile 对应的空 Body、List、
ASCII 或 Identity 结构;
- Secondary 的 Stream、Function 和 W-Bit 与配置完全匹配;
-- 应答是单个 Binary 字节,接受的通讯建立应答包含两项 ASCII Identity;
+- 通讯建立回复是确认字节与身份组成的二元素 List;接受时必须包含两项
+ ASCII Identity,策略拒绝时使用 profile 失败确认和空身份 List;
- 在线/离线请求必须设置 W-Bit 且 Body 为空;应用拒绝使用 profile 失败
应答,接受应答写出完成前不得改变 Equipment 在线状态;
- 状态变量和设备常量请求只引用已注册标识,提供器不得返回 null;
@@ -205,9 +222,8 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码
畸形输入和提供器失败会作为异常返回应用事件循环;语义有效但引用未知变量
或报告的配置使用失败应答拒绝。本切片尚未实现自动 S9Fx、错误 Secondary、
-通讯建立策略拒绝、自动重新建立、业务消息状态门控、报警历史/批量控制、
-命令目录/权限/调度或完整 GEM 错误恢复,这些行为不能从当前 API 推断为
-标准合规。
+自动重新建立、业务消息状态门控、报警历史/批量控制、命令目录/权限/调度
+或完整 GEM 错误恢复,这些行为不能从当前 API 推断为标准合规。
## 标准与验证边界
@@ -221,7 +237,8 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码
[STANDARDS.md](STANDARDS.md)。
当前证据包括三目标框架的严格输入测试,以及 Host Active / Equipment
-Passive 真实 TCP 下的双向通讯建立、在线转换接受/拒绝与状态保持、异构
+Passive 真实 TCP 下的双向通讯建立、首次建立拒绝后同会话恢复、已通讯
+重新建立拒绝时的状态/身份保持与恢复、在线转换接受/拒绝与状态保持、异构
动态标识、报告定义、
事件链接、Collection Event 嵌套值与空报告、报警通知接受/拒绝、配置/事件
拒绝、报警目录全量/选择查询与释放快照、单报警发送启停与未知标识拒绝、
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index e841873..fd2b3db 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -43,6 +43,7 @@
- [x] Equipment 运行期报警目录注册与 Host 全量/按标识查询。
- [x] 单个已注册报警的发送启停、未知标识拒绝和通知发送门控。
- [x] Equipment 在线/离线转换的应用决策、显式拒绝与状态保持。
+- [x] Host/Equipment 双向通讯建立应用决策、显式拒绝与同会话重试恢复。
- [ ] 报警历史/批量控制、命令目录/权限/调度和更完整的状态/错误恢复。
- [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。
@@ -50,8 +51,10 @@
SecsItem 标识,并保留报警与命令结果原始代码,不宣称已经
核对 E30 的生命周期、代码语义或应答枚举。报警目录查询同样是非规范性
工程基线;单报警发送启停也使用显式可替换控制码。在线转换策略默认不注册,
-保持既有宽松接受行为。下一优先级是通讯建立拒绝与重新建立恢复,再评估
-业务消息状态门控、报警历史和批量控制。
+保持既有宽松接受行为。通讯建立策略在 Host 与 Equipment 两侧同样默认
+不注册;拒绝不改变既有通讯状态或对端身份,恢复由应用在同一 Selected
+会话显式重试,库不启动后台循环。下一优先级是默认关闭、由应用配置的业务
+消息状态门控最小切片,再评估自动恢复、报警历史和批量控制。
## 阶段 3:元数据与工具
diff --git a/docs/SECS-ENDPOINT-ROLES.md b/docs/SECS-ENDPOINT-ROLES.md
index 67e6e87..2ea48d3 100644
--- a/docs/SECS-ENDPOINT-ROLES.md
+++ b/docs/SECS-ENDPOINT-ROLES.md
@@ -65,6 +65,8 @@ await equipment.WaitUntilSelectedAsync(cancellationToken);
设备常量、时钟、报告定义、事件链接、Collection Event,以及最小报警通知
和远程命令;
报警目录查询支持运行期注册,Host 也可启停单个已注册报警的通知发送。
-通讯建立拒绝、自动重新建立、业务消息状态门控、报警历史/批量控制、命令
-目录/权限/调度仍未实现。相关行为、事件循环要求和未核对边界见
+Host 与 Equipment 的 GEM 服务都可注册单一通讯建立策略;拒绝保留双方
+既有通讯状态和对端身份,应用可在同一 Selected 会话显式重试。自动重新
+建立、业务消息状态门控、报警历史/批量控制、命令目录/权限/调度仍未实现。
+相关行为、事件循环要求和未核对边界见
[GEM-FOUNDATION.md](GEM-FOUNDATION.md),项目不声明 GEM 合规。
diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md
index 6388921..ad9a50c 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 | 规划中 |
@@ -39,7 +39,7 @@
的必选/可选边界;还需核对基础消息条件、COMMACK/ONLACK/OFLACK/TIACK
取值、MDLN 条件结构、空 SVID/ECID 列表、报告定义与链接生命周期、
Collection Event 数据结构和应答值、报警通知/目录/发送启停的数据结构与
- 控制码、应用在线转换策略、时间格式、状态转换与错误响应。
+ 控制码、应用通讯建立/在线转换策略、时间格式、状态转换与错误响应。
- E172/E173:允许分发的 Schema/表示形式、版本标识、扩展点和验证规则。
## 版权与合规边界
diff --git a/src/SecsFrame.Gem/GemCommunicationEstablishmentHandler.cs b/src/SecsFrame.Gem/GemCommunicationEstablishmentHandler.cs
new file mode 100644
index 0000000..f979bb6
--- /dev/null
+++ b/src/SecsFrame.Gem/GemCommunicationEstablishmentHandler.cs
@@ -0,0 +1,11 @@
+namespace SecsFrame.Gem;
+
+///
+/// Decides whether to accept a peer-requested communication establishment.
+///
+/// The identity supplied by the requesting peer.
+/// Cancels the pending request.
+/// to accept the request; otherwise, .
+public delegate ValueTask GemCommunicationEstablishmentHandler(
+ GemIdentity peerIdentity,
+ CancellationToken cancellationToken);
diff --git a/src/SecsFrame.Gem/GemCommunicationEstablishmentRegistration.cs b/src/SecsFrame.Gem/GemCommunicationEstablishmentRegistration.cs
new file mode 100644
index 0000000..918b24c
--- /dev/null
+++ b/src/SecsFrame.Gem/GemCommunicationEstablishmentRegistration.cs
@@ -0,0 +1,21 @@
+namespace SecsFrame.Gem;
+
+/// Owns one communication-establishment handler registration.
+public sealed class GemCommunicationEstablishmentRegistration : IDisposable
+{
+ private Action? _unregister;
+
+ internal GemCommunicationEstablishmentRegistration(
+ GemCommunicationEstablishmentHandler handler,
+ Action unregister)
+ {
+ Handler = handler;
+ _unregister = unregister;
+ }
+
+ internal GemCommunicationEstablishmentHandler Handler { get; }
+
+ /// Removes this exact handler registration. Repeated calls are harmless.
+ public void Dispose()
+ => Interlocked.Exchange(ref _unregister, null)?.Invoke(this);
+}
diff --git a/src/SecsFrame.Gem/GemEndpointServices.cs b/src/SecsFrame.Gem/GemEndpointServices.cs
index b2b4d34..767a98e 100644
--- a/src/SecsFrame.Gem/GemEndpointServices.cs
+++ b/src/SecsFrame.Gem/GemEndpointServices.cs
@@ -4,6 +4,13 @@ internal sealed class GemEndpointServices : IDisposable
{
private readonly SecsEndpoint _endpoint;
private readonly List _routes = new();
+#if NET9_0_OR_GREATER
+ private readonly Lock _gate = new();
+#else
+ private readonly object _gate = new();
+#endif
+ private GemCommunicationEstablishmentRegistration?
+ _communicationEstablishmentHandler;
private GemIdentity? _peerIdentity;
private int _communicationState;
private int _onlineState;
@@ -66,6 +73,30 @@ internal async Task EstablishCommunicationAsync(
return reply.Identity;
}
+ internal GemCommunicationEstablishmentRegistration
+ RegisterCommunicationEstablishmentHandler(
+ GemCommunicationEstablishmentHandler handler)
+ {
+ if (handler is null)
+ throw new ArgumentNullException(nameof(handler));
+
+ lock (_gate)
+ {
+ ThrowIfDisposed();
+ if (_communicationEstablishmentHandler is not null)
+ {
+ throw new InvalidOperationException(
+ "A communication-establishment handler is already registered.");
+ }
+
+ var registration = new GemCommunicationEstablishmentRegistration(
+ handler,
+ UnregisterCommunicationEstablishmentHandler);
+ _communicationEstablishmentHandler = registration;
+ return registration;
+ }
+ }
+
internal async Task AreYouOnlineAsync(
CancellationToken cancellationToken)
{
@@ -176,6 +207,9 @@ public void Dispose()
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
+ lock (_gate)
+ _communicationEstablishmentHandler = null;
+
for (var index = _routes.Count - 1; index >= 0; index--)
_routes[index].Dispose();
_routes.Clear();
@@ -193,14 +227,26 @@ public void Dispose()
var identity = GemMessageCodec.DecodeIdentity(
context.Message.RootItem,
"communication-establishment request");
+ GemCommunicationEstablishmentHandler? handler;
+ lock (_gate)
+ {
+ ThrowIfDisposed();
+ handler = _communicationEstablishmentHandler?.Handler;
+ }
+
+ var accepted = handler is null ||
+ await handler(identity, cancellationToken).ConfigureAwait(false);
await ReplyAsync(
context,
Profile.EstablishCommunication,
GemMessageCodec.EncodeCommunicationReply(
- Profile.AcceptedAcknowledgement,
- Identity),
+ accepted
+ ? Profile.AcceptedAcknowledgement
+ : Profile.FailedAcknowledgement,
+ accepted ? Identity : null),
cancellationToken).ConfigureAwait(false);
- SetCommunicating(identity);
+ if (accepted)
+ SetCommunicating(identity);
return null;
}
@@ -241,6 +287,20 @@ private void ResetState()
Volatile.Write(ref _onlineState, (int)GemOnlineState.Offline);
}
+ private void UnregisterCommunicationEstablishmentHandler(
+ GemCommunicationEstablishmentRegistration registration)
+ {
+ lock (_gate)
+ {
+ if (ReferenceEquals(
+ _communicationEstablishmentHandler,
+ registration))
+ {
+ _communicationEstablishmentHandler = null;
+ }
+ }
+ }
+
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
diff --git a/src/SecsFrame.Gem/GemEquipmentServices.cs b/src/SecsFrame.Gem/GemEquipmentServices.cs
index a20c0c3..9a9f300 100644
--- a/src/SecsFrame.Gem/GemEquipmentServices.cs
+++ b/src/SecsFrame.Gem/GemEquipmentServices.cs
@@ -68,6 +68,15 @@ public Task EstablishCommunicationAsync(
CancellationToken cancellationToken = default)
=> _services.EstablishCommunicationAsync(cancellationToken);
+ ///
+ /// Registers the single application policy for peer-requested communication
+ /// establishment.
+ ///
+ public GemCommunicationEstablishmentRegistration
+ RegisterCommunicationEstablishmentHandler(
+ GemCommunicationEstablishmentHandler handler)
+ => _services.RegisterCommunicationEstablishmentHandler(handler);
+
/// Queries the Host identity.
public Task AreYouOnlineAsync(
CancellationToken cancellationToken = default)
diff --git a/src/SecsFrame.Gem/GemHostServices.cs b/src/SecsFrame.Gem/GemHostServices.cs
index 5a56a76..d1fdcae 100644
--- a/src/SecsFrame.Gem/GemHostServices.cs
+++ b/src/SecsFrame.Gem/GemHostServices.cs
@@ -62,6 +62,15 @@ public Task EstablishCommunicationAsync(
CancellationToken cancellationToken = default)
=> _services.EstablishCommunicationAsync(cancellationToken);
+ ///
+ /// Registers the single application policy for peer-requested communication
+ /// establishment.
+ ///
+ public GemCommunicationEstablishmentRegistration
+ RegisterCommunicationEstablishmentHandler(
+ GemCommunicationEstablishmentHandler handler)
+ => _services.RegisterCommunicationEstablishmentHandler(handler);
+
/// Queries the Equipment identity.
public Task AreYouOnlineAsync(
CancellationToken cancellationToken = default)
diff --git a/src/SecsFrame.Gem/GemMessageCodec.cs b/src/SecsFrame.Gem/GemMessageCodec.cs
index 36deae7..a2afeff 100644
--- a/src/SecsFrame.Gem/GemMessageCodec.cs
+++ b/src/SecsFrame.Gem/GemMessageCodec.cs
@@ -17,10 +17,12 @@ internal static GemIdentity DecodeIdentity(SecsItem? root, string operation)
internal static SecsItem EncodeCommunicationReply(
byte acknowledgement,
- GemIdentity identity)
+ GemIdentity? identity)
=> SecsItem.List(
EncodeAcknowledgement(acknowledgement),
- EncodeIdentity(identity));
+ identity is null
+ ? SecsItem.List()
+ : EncodeIdentity(identity));
internal static (byte Acknowledgement, GemIdentity? Identity)
DecodeCommunicationReply(SecsItem? root)
diff --git a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs
index 973bb3a..a262dba 100644
--- a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs
+++ b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs
@@ -245,6 +245,33 @@ public async Task Online_state_transition_handler_is_exact_disposable_and_replac
services.RegisterOnlineStateTransitionHandler(handler));
}
+ [Fact]
+ public async Task Communication_establishment_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));
+ GemCommunicationEstablishmentHandler handler = static (_, _) =>
+ new ValueTask(true);
+
+ Assert.Throws(() =>
+ services.RegisterCommunicationEstablishmentHandler(null!));
+ var first =
+ services.RegisterCommunicationEstablishmentHandler(handler);
+ Assert.Throws(() =>
+ services.RegisterCommunicationEstablishmentHandler(handler));
+ first.Dispose();
+ first.Dispose();
+ using var replacement =
+ services.RegisterCommunicationEstablishmentHandler(handler);
+ services.Dispose();
+ Assert.Throws(() =>
+ services.RegisterCommunicationEstablishmentHandler(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 045a1a0..cf35479 100644
--- a/test/SecsFrame.Tests/GemFoundationTcpTests.cs
+++ b/test/SecsFrame.Tests/GemFoundationTcpTests.cs
@@ -20,10 +20,8 @@ public async Task Host_and_equipment_complete_foundational_dialogues_over_tcp()
await AssertAlarmNotificationsAsync(context).ConfigureAwait(true);
await AssertRemoteCommandsAsync(context).ConfigureAwait(true);
await AssertClockAsync(context).ConfigureAwait(true);
- Assert.Equal(
- context.HostServices.Identity,
- await context.EquipmentServices.EstablishCommunicationAsync(
- context.Token).ConfigureAwait(true));
+ await AssertCommunicationReestablishmentPolicyAsync(context)
+ .ConfigureAwait(true);
await context.HostServices.RequestOfflineAsync(context.Token)
.ConfigureAwait(true);
await WaitUntilAsync(
@@ -37,8 +35,9 @@ await WaitUntilAsync(
private static async Task AssertCommunicationAndOnlineAsync(
GemTcpContext context)
{
- var equipmentIdentity = await context.HostServices
- .EstablishCommunicationAsync(context.Token).ConfigureAwait(true);
+ var equipmentIdentity =
+ await AssertInitialCommunicationEstablishmentPolicyAsync(context)
+ .ConfigureAwait(true);
await WaitUntilAsync(
() => context.EquipmentServices.CommunicationState ==
GemCommunicationState.Communicating,
@@ -67,6 +66,107 @@ await WaitUntilAsync(
Assert.Equal(GemOnlineState.Online, context.EquipmentServices.OnlineState);
}
+ private static async Task
+ AssertInitialCommunicationEstablishmentPolicyAsync(GemTcpContext context)
+ {
+ var acceptEstablishment = false;
+ var observed = new List();
+ GemIdentity equipmentIdentity;
+ using (context.EquipmentServices.RegisterCommunicationEstablishmentHandler(
+ (peerIdentity, cancellationToken) =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ observed.Add(peerIdentity);
+ return new ValueTask(acceptEstablishment);
+ }))
+ {
+ var rejected = await Assert.ThrowsAsync(
+ () => context.HostServices.EstablishCommunicationAsync(
+ context.Token)).ConfigureAwait(true);
+ Assert.Equal(GemOperation.EstablishCommunication, rejected.Operation);
+ Assert.Equal((byte)1, rejected.Acknowledgement);
+ Assert.Equal(
+ GemCommunicationState.NotCommunicating,
+ context.HostServices.CommunicationState);
+ Assert.Equal(
+ GemCommunicationState.NotCommunicating,
+ context.EquipmentServices.CommunicationState);
+ Assert.Null(context.HostServices.PeerIdentity);
+ Assert.Null(context.EquipmentServices.PeerIdentity);
+
+ acceptEstablishment = true;
+ equipmentIdentity = await context.HostServices
+ .EstablishCommunicationAsync(context.Token).ConfigureAwait(true);
+ }
+
+ Assert.Equal(
+ new[]
+ {
+ context.HostServices.Identity,
+ context.HostServices.Identity,
+ },
+ observed);
+ return equipmentIdentity;
+ }
+
+ private static async Task AssertCommunicationReestablishmentPolicyAsync(
+ GemTcpContext context)
+ {
+ var hostPeerIdentity = context.HostServices.PeerIdentity;
+ var equipmentPeerIdentity = context.EquipmentServices.PeerIdentity;
+ Assert.NotNull(hostPeerIdentity);
+ Assert.NotNull(equipmentPeerIdentity);
+
+ var acceptEstablishment = false;
+ var observed = new List();
+ using (context.HostServices.RegisterCommunicationEstablishmentHandler(
+ (peerIdentity, cancellationToken) =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ observed.Add(peerIdentity);
+ return new ValueTask(acceptEstablishment);
+ }))
+ {
+ var rejected = await Assert.ThrowsAsync(
+ () => context.EquipmentServices.EstablishCommunicationAsync(
+ context.Token)).ConfigureAwait(true);
+ Assert.Equal(GemOperation.EstablishCommunication, rejected.Operation);
+ Assert.Equal((byte)1, rejected.Acknowledgement);
+ Assert.Equal(
+ GemCommunicationState.Communicating,
+ context.HostServices.CommunicationState);
+ Assert.Equal(
+ GemCommunicationState.Communicating,
+ context.EquipmentServices.CommunicationState);
+ Assert.Equal(hostPeerIdentity, context.HostServices.PeerIdentity);
+ Assert.Equal(
+ equipmentPeerIdentity,
+ context.EquipmentServices.PeerIdentity);
+
+ acceptEstablishment = true;
+ Assert.Equal(
+ context.HostServices.Identity,
+ await context.EquipmentServices.EstablishCommunicationAsync(
+ context.Token).ConfigureAwait(true));
+ }
+
+ await WaitUntilAsync(
+ () => context.EquipmentServices.Identity.Equals(
+ context.HostServices.PeerIdentity),
+ context.Token).ConfigureAwait(true);
+ Assert.Equal(
+ new[]
+ {
+ context.EquipmentServices.Identity,
+ context.EquipmentServices.Identity,
+ },
+ observed);
+ Assert.Equal(
+ context.HostServices.Identity,
+ await context.EquipmentServices.EstablishCommunicationAsync(
+ context.Token).ConfigureAwait(true));
+ }
+
private static async Task AssertOnlineTransitionPolicyAsync(
GemTcpContext context)
{
diff --git a/test/SecsFrame.Tests/GemHostServicesTests.cs b/test/SecsFrame.Tests/GemHostServicesTests.cs
index d0ab3a9..ca3ba25 100644
--- a/test/SecsFrame.Tests/GemHostServicesTests.cs
+++ b/test/SecsFrame.Tests/GemHostServicesTests.cs
@@ -57,6 +57,31 @@ public async Task Collection_event_handler_is_exact_disposable_and_replaceable()
services.RegisterCollectionEventHandler(handler));
}
+ [Fact]
+ public async Task Communication_establishment_handler_is_exact_disposable_and_replaceable()
+ {
+ await using var endpoint = new SecsHost(CreateOptions());
+ using var services = new GemHostServices(
+ endpoint,
+ new GemIdentity("HOST-01", "1.0"));
+ GemCommunicationEstablishmentHandler handler = static (_, _) =>
+ new ValueTask(true);
+
+ Assert.Throws(() =>
+ services.RegisterCommunicationEstablishmentHandler(null!));
+ var first =
+ services.RegisterCommunicationEstablishmentHandler(handler);
+ Assert.Throws(() =>
+ services.RegisterCommunicationEstablishmentHandler(handler));
+ first.Dispose();
+ first.Dispose();
+ using var replacement =
+ services.RegisterCommunicationEstablishmentHandler(handler);
+ services.Dispose();
+ Assert.Throws(() =>
+ services.RegisterCommunicationEstablishmentHandler(handler));
+ }
+
[Fact]
public async Task Alarm_notification_vectors_require_w_bit_and_strict_fields()
{