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!; + } +}