diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26c4254..1d33435 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -59,5 +59,9 @@
处理器并在原事务上自动回复,无需预生成消息类型或完整消息目录。
- 未匹配连接事件继续由应用处理;处理器注册可释放替换,异常、取消和
W-Bit 契约错误明确传播。
+- 增加拥有连接与动态路由生命周期的 SecsHost 和
+ SecsEquipment 公共端点,角色与 Active/Passive 保持正交。
+- 增加 Host Active/Equipment Passive 及反向拓扑的双向 Primary/Secondary
+ 真实 TCP 回环测试。
[Unreleased]: https://github.com/CSJ608/SecsFrame/compare/main...HEAD
diff --git a/README.md b/README.md
index 9f894cb..9d75cfd 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,8 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor
[docs/HSMS-DIAGNOSTICS.md](docs/HSMS-DIAGNOSTICS.md),
运行期消息处理见
[docs/HSMS-PRIMARY-ROUTING.md](docs/HSMS-PRIMARY-ROUTING.md),
+Host/Equipment 端点见
+[docs/SECS-ENDPOINT-ROLES.md](docs/SECS-ENDPOINT-ROLES.md),
官方 secs4net 跨实现证据见
[docs/SECS4NET-INTEROP.md](docs/SECS4NET-INTEROP.md),
整体路线图见
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0fb31c2..5ae5267 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -74,6 +74,11 @@ API。#38 已合入上游但尚未发布,#39 正在实现;正式包可用后
修改。Active/Passive 共享状态模型,Host/Equipment 角色不参与连接模式
判断。
+公共 SecsHost 与 SecsEquipment 在连接和动态路由
+之上建立角色组合边界。两者都拥有连接生命周期、事件流、动态发送和
+Primary 处理能力;角色固定但 Active/Passive 仍由独立选项决定。后续 GEM
+能力依赖具体角色类型,不反向污染 HSMS 状态机。
+
~~~text
TCP SessionOpened
|
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 337d9e3..aed5a1f 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -23,11 +23,12 @@
- [x] 公共结构化诊断模型,覆盖运输、协议、会话、事务和解码故障。
- [x] 运行期 Primary 路由与可释放处理器注册,不依赖预生成消息类型。
- [ ] 使用授权 E37/E37.1 核对 T5/T8 默认值与精确启停边界。
-- [ ] Host、Equipment 双角色能力及对应回环集成测试。
+- [x] Host、Equipment 双角色端点及两种 Active/Passive 拓扑的双向回环。
-阶段 1 的下一最小切片是建立 Host/Equipment 角色能力及双角色回环,
-同时继续跟踪 StreamFrame #38/#39 的正式包发布并准备等价迁移。任何标准
-默认值和完整合规结论都依赖团队合法获得的 E37/E37.1 副本与一致性测试。
+阶段 1 的工程能力已经形成完整基础链路。下一切片进入 GEM 通用行为,同时
+继续跟踪 StreamFrame #38/#39 的正式包发布并准备等价迁移。任何标准默认值
+和完整合规结论都依赖团队合法获得的 E37/E37.1 及相关 GEM 标准副本与
+一致性测试。
## 阶段 2:GEM 通用能力
diff --git a/docs/SECS-ENDPOINT-ROLES.md b/docs/SECS-ENDPOINT-ROLES.md
new file mode 100644
index 0000000..805aa2f
--- /dev/null
+++ b/docs/SECS-ENDPOINT-ROLES.md
@@ -0,0 +1,66 @@
+# Host 与 Equipment 端点
+
+## 角色与连接模式正交
+
+SecsHost 和 SecsEquipment 表达 SECS 应用角色;
+HsmsConnectionMode.Active 与 Passive 只表达哪一端建立
+TCP 连接。库不从其中一个推断另一个。
+
+当前真实 TCP 回环持续验证以下两种常见拓扑:
+
+| Host | Equipment | 已验证方向 |
+|---|---|---|
+| Active | Passive | Host Primary/Equipment Secondary;Equipment Primary/Host Secondary |
+| Passive | Active | Host Primary/Equipment Secondary;Equipment Primary/Host Secondary |
+
+这两种连接方向具有相同的动态消息和事务能力。角色不会改变 Select、T3、
+T5、T6、T7、T8、Linktest 或运输重连语义。
+
+## 端点所有权
+
+每个角色端点拥有一个 HsmsConnection 和一个
+HsmsPrimaryRouter,并公开以下组合能力:
+
+- Start、等待 Selected、读取状态和单消费者事件流;
+- 发送动态 Primary、手工回复未匹配 Primary;
+- 运行期注册与分派 Primary 处理器;
+- Linktest、Deselect 和 Separate;
+- 异步释放整个端点。
+
+释放角色端点会释放其连接。角色在构造后固定,连接方向、地址、协议
+Session ID 和计时器仍通过显式 HsmsConnectionOptions 提供。
+
+~~~csharp
+await using var equipment = new SecsEquipment(
+ new HsmsConnectionOptions(
+ IPAddress.Any,
+ port: 5000,
+ HsmsConnectionMode.Passive,
+ sessionId: 0,
+ t3,
+ t5,
+ t6,
+ t7,
+ t8));
+
+using var route = equipment.RegisterPrimaryHandler(
+ stream: 1,
+ function: 1,
+ HandleAreYouOnlineAsync);
+
+equipment.Start();
+await equipment.WaitUntilSelectedAsync(cancellationToken);
+~~~
+
+以上只展示 API 组合,消息含义和计时器值不是标准推荐或默认值。
+
+## 能力边界
+
+基础角色端点故意保持对称:Host 和 Equipment 都可能发起 Primary,也都
+可能处理对端 Primary。后续 GEM 层会通过依赖具体的
+SecsHost 或 SecsEquipment 类型提供不同业务能力,
+而不是修改 HSMS 状态机或根据 Active/Passive 猜测角色。
+
+当前切片没有实现 E30 的在线状态、变量、设备常量、报告、事件、报警或
+远程命令语义,因此不声明 GEM 合规。相关行为必须继续记录标准版本、测试
+证据与未核对边界。
diff --git a/src/SecsFrame/Roles/SecsEndpoint.cs b/src/SecsFrame/Roles/SecsEndpoint.cs
new file mode 100644
index 0000000..095af09
--- /dev/null
+++ b/src/SecsFrame/Roles/SecsEndpoint.cs
@@ -0,0 +1,89 @@
+namespace SecsFrame;
+
+///
+/// Owns one role-specific SECS endpoint composed from an HSMS connection and
+/// runtime Primary router.
+///
+public abstract class SecsEndpoint : IAsyncDisposable
+{
+ private readonly HsmsConnection _connection;
+ private readonly HsmsPrimaryRouter _router;
+
+ private protected SecsEndpoint(
+ SecsEndpointRole role,
+ HsmsConnectionOptions options)
+ {
+ Role = role;
+ _connection = new HsmsConnection(
+ options ?? throw new ArgumentNullException(nameof(options)));
+ _router = new HsmsPrimaryRouter(_connection);
+ }
+
+ /// Gets the fixed SECS application role.
+ public SecsEndpointRole Role { get; }
+
+ /// Gets the immutable HSMS connection settings.
+ public HsmsConnectionOptions Options => _connection.Options;
+
+ /// Gets the latest observed HSMS session state.
+ public HsmsSessionState State => _connection.State;
+
+ /// Starts the owned connection.
+ public void Start()
+ => _connection.Start();
+
+ /// Waits until the owned connection next reaches Selected.
+ public Task WaitUntilSelectedAsync(
+ CancellationToken cancellationToken = default)
+ => _connection.WaitUntilSelectedAsync(cancellationToken);
+
+ /// Gets the owned connection's single-consumer event stream.
+ public IAsyncEnumerable GetEventsAsync(
+ CancellationToken cancellationToken = default)
+ => _connection.GetEventsAsync(cancellationToken);
+
+ /// Sends a dynamic Primary through the owned connection.
+ public Task SendAsync(
+ SecsMessage primary,
+ CancellationToken cancellationToken = default)
+ => _connection.SendAsync(primary, cancellationToken);
+
+ /// Replies once to an unhandled incoming Primary.
+ public Task ReplyAsync(
+ HsmsIncomingDataMessage incoming,
+ SecsMessage secondary,
+ CancellationToken cancellationToken = default)
+ => _connection.ReplyAsync(
+ incoming,
+ secondary,
+ cancellationToken);
+
+ /// Registers one runtime Primary handler for this endpoint.
+ public HsmsPrimaryRouteRegistration RegisterPrimaryHandler(
+ byte stream,
+ byte function,
+ HsmsPrimaryHandler handler)
+ => _router.Register(stream, function, handler);
+
+ /// Attempts to dispatch a connection event to a registered handler.
+ public ValueTask TryDispatchAsync(
+ HsmsConnectionEvent connectionEvent,
+ CancellationToken cancellationToken = default)
+ => _router.TryDispatchAsync(connectionEvent, cancellationToken);
+
+ /// Sends Linktest Request and waits for its matching response.
+ public Task LinktestAsync(CancellationToken cancellationToken = default)
+ => _connection.LinktestAsync(cancellationToken);
+
+ /// Sends Deselect Request and waits for its matching response.
+ public Task DeselectAsync(CancellationToken cancellationToken = default)
+ => _connection.DeselectAsync(cancellationToken);
+
+ /// Sends Separate Request and closes the selected TCP session.
+ public Task SeparateAsync(CancellationToken cancellationToken = default)
+ => _connection.SeparateAsync(cancellationToken);
+
+ /// Stops and disposes the owned connection.
+ public ValueTask DisposeAsync()
+ => _connection.DisposeAsync();
+}
diff --git a/src/SecsFrame/Roles/SecsEndpointRole.cs b/src/SecsFrame/Roles/SecsEndpointRole.cs
new file mode 100644
index 0000000..95b4f4b
--- /dev/null
+++ b/src/SecsFrame/Roles/SecsEndpointRole.cs
@@ -0,0 +1,11 @@
+namespace SecsFrame;
+
+/// Identifies the SECS application role of an endpoint.
+public enum SecsEndpointRole
+{
+ /// The endpoint represents a factory host.
+ Host,
+
+ /// The endpoint represents manufacturing equipment.
+ Equipment,
+}
diff --git a/src/SecsFrame/Roles/SecsEquipment.cs b/src/SecsFrame/Roles/SecsEquipment.cs
new file mode 100644
index 0000000..7d56426
--- /dev/null
+++ b/src/SecsFrame/Roles/SecsEquipment.cs
@@ -0,0 +1,11 @@
+namespace SecsFrame;
+
+/// An Equipment-role SECS endpoint independent of HSMS connection mode.
+public sealed class SecsEquipment : SecsEndpoint
+{
+ /// Creates an Equipment endpoint that owns a new HSMS connection.
+ public SecsEquipment(HsmsConnectionOptions options)
+ : base(SecsEndpointRole.Equipment, options)
+ {
+ }
+}
diff --git a/src/SecsFrame/Roles/SecsHost.cs b/src/SecsFrame/Roles/SecsHost.cs
new file mode 100644
index 0000000..09f9218
--- /dev/null
+++ b/src/SecsFrame/Roles/SecsHost.cs
@@ -0,0 +1,11 @@
+namespace SecsFrame;
+
+/// A Host-role SECS endpoint independent of HSMS connection mode.
+public sealed class SecsHost : SecsEndpoint
+{
+ /// Creates a Host endpoint that owns a new HSMS connection.
+ public SecsHost(HsmsConnectionOptions options)
+ : base(SecsEndpointRole.Host, options)
+ {
+ }
+}
diff --git a/test/SecsFrame.Tests/SecsEndpointRoleTests.cs b/test/SecsFrame.Tests/SecsEndpointRoleTests.cs
new file mode 100644
index 0000000..dabd5c5
--- /dev/null
+++ b/test/SecsFrame.Tests/SecsEndpointRoleTests.cs
@@ -0,0 +1,206 @@
+using System.Net;
+
+namespace SecsFrame.Tests;
+
+public sealed class SecsEndpointRoleTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Host_and_equipment_exchange_primaries_in_both_connection_topologies(
+ bool hostIsActive)
+ {
+ var port = GetFreePort();
+ await using var host = new SecsHost(
+ CreateOptions(
+ port,
+ hostIsActive
+ ? HsmsConnectionMode.Active
+ : HsmsConnectionMode.Passive));
+ await using var equipment = new SecsEquipment(
+ CreateOptions(
+ port,
+ hostIsActive
+ ? HsmsConnectionMode.Passive
+ : HsmsConnectionMode.Active));
+ using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ using var equipmentRequest = equipment.RegisterPrimaryHandler(
+ 1,
+ 1,
+ HandleEquipmentRequest);
+ using var hostEvent = host.RegisterPrimaryHandler(
+ 6,
+ 11,
+ HandleHostEvent);
+
+ if (hostIsActive)
+ {
+ equipment.Start();
+ host.Start();
+ }
+ else
+ {
+ host.Start();
+ equipment.Start();
+ }
+
+ await using var hostEvents = host
+ .GetEventsAsync(lifetime.Token)
+ .GetAsyncEnumerator();
+ await using var equipmentEvents = equipment
+ .GetEventsAsync(lifetime.Token)
+ .GetAsyncEnumerator();
+ await Task.WhenAll(
+ host.WaitUntilSelectedAsync(lifetime.Token),
+ equipment.WaitUntilSelectedAsync(lifetime.Token)).ConfigureAwait(true);
+
+ AssertTopology(host, equipment, hostIsActive);
+ await AssertHostRequestAsync(
+ host,
+ equipment,
+ equipmentEvents,
+ lifetime.Token).ConfigureAwait(true);
+ await AssertEquipmentEventAsync(
+ host,
+ equipment,
+ hostEvents,
+ lifetime.Token).ConfigureAwait(true);
+
+ await host.LinktestAsync(lifetime.Token).ConfigureAwait(true);
+ Assert.Equal(HsmsSessionState.Selected, host.State);
+ Assert.Equal(HsmsSessionState.Selected, equipment.State);
+ }
+
+ [Fact]
+ public void Role_constructors_require_explicit_connection_options()
+ {
+ Assert.Throws(() => new SecsHost(null!));
+ Assert.Throws(() => new SecsEquipment(null!));
+ }
+
+ private static ValueTask HandleEquipmentRequest(
+ HsmsPrimaryContext context,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Assert.True(context.ReplyExpected);
+ Assert.Null(context.Message.RootItem);
+ return new ValueTask(
+ new SecsMessage(
+ 1,
+ 2,
+ rootItem: SecsItem.Ascii("EQUIPMENT-01")));
+ }
+
+ private static ValueTask HandleHostEvent(
+ HsmsPrimaryContext context,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Assert.Equal(SecsItem.U4(1001), context.Message.RootItem);
+ return new ValueTask(
+ new SecsMessage(
+ 6,
+ 12,
+ rootItem: SecsItem.Boolean(true)));
+ }
+
+ private static void AssertTopology(
+ SecsHost host,
+ SecsEquipment equipment,
+ bool hostIsActive)
+ {
+ Assert.Equal(SecsEndpointRole.Host, host.Role);
+ Assert.Equal(SecsEndpointRole.Equipment, equipment.Role);
+ Assert.Equal(
+ hostIsActive
+ ? HsmsConnectionMode.Active
+ : HsmsConnectionMode.Passive,
+ host.Options.ConnectionMode);
+ Assert.Equal(
+ hostIsActive
+ ? HsmsConnectionMode.Passive
+ : HsmsConnectionMode.Active,
+ equipment.Options.ConnectionMode);
+ }
+
+ private static async Task AssertHostRequestAsync(
+ SecsHost host,
+ SecsEquipment equipment,
+ IAsyncEnumerator equipmentEvents,
+ CancellationToken cancellationToken)
+ {
+ var send = host.SendAsync(
+ new SecsMessage(1, 1, true),
+ cancellationToken);
+ var incoming = await NextDataMessageAsync(equipmentEvents)
+ .ConfigureAwait(true);
+ Assert.True(await equipment.TryDispatchAsync(
+ incoming,
+ cancellationToken).ConfigureAwait(true));
+ var reply = await send.ConfigureAwait(true);
+ Assert.NotNull(reply);
+ Assert.Equal(SecsItem.Ascii("EQUIPMENT-01"), reply.Message.RootItem);
+ }
+
+ private static async Task AssertEquipmentEventAsync(
+ SecsHost host,
+ SecsEquipment equipment,
+ IAsyncEnumerator hostEvents,
+ CancellationToken cancellationToken)
+ {
+ var send = equipment.SendAsync(
+ new SecsMessage(
+ 6,
+ 11,
+ true,
+ SecsItem.U4(1001)),
+ cancellationToken);
+ var incoming = await NextDataMessageAsync(hostEvents)
+ .ConfigureAwait(true);
+ Assert.True(await host.TryDispatchAsync(
+ incoming,
+ cancellationToken).ConfigureAwait(true));
+ var reply = await send.ConfigureAwait(true);
+ Assert.NotNull(reply);
+ Assert.Equal(SecsItem.Boolean(true), reply.Message.RootItem);
+ }
+
+ private static HsmsConnectionOptions CreateOptions(
+ int port,
+ HsmsConnectionMode mode)
+ => new(
+ IPAddress.Loopback,
+ port,
+ mode,
+ sessionId: 10,
+ t3: TimeSpan.FromSeconds(5),
+ t5: TimeSpan.FromMilliseconds(10),
+ t6: TimeSpan.FromSeconds(5),
+ t7: TimeSpan.FromSeconds(10),
+ t8: TimeSpan.FromSeconds(5));
+
+ private static int GetFreePort()
+ {
+ var listener = new System.Net.Sockets.TcpListener(
+ IPAddress.Loopback,
+ 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ private static async Task NextDataMessageAsync(
+ IAsyncEnumerator events)
+ {
+ while (await events.MoveNextAsync().ConfigureAwait(true))
+ {
+ if (events.Current.Kind == HsmsConnectionEventKind.DataMessageReceived)
+ return events.Current;
+ }
+
+ Assert.Fail("The expected role endpoint data message was not received.");
+ return null!;
+ }
+}