diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e5a77..af84ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,10 @@ 和可释放的 Host 单处理器注册;报警代码按原始字节透传,不推断位语义。 - 报警通知严格校验 W-Bit、三字段正文、单字节 Binary 代码、ASCII 文本、 Secondary 和应答值,并增加协议向量及真实 TCP 接受/拒绝测试。 +- 增加可替换的远程命令消息对、不可变动态命令/参数/结果模型、Host 发送 + API 和可释放的 Equipment 单处理器注册;整体与参数结果码原样返回。 +- 远程命令严格校验 W-Bit、二字段请求/回复、参数形状、名称唯一性和 + Binary 结果码,并增加动态嵌套 Item、无处理器拒绝及真实 TCP 往返测试。 - 增加定义、链接、S6F11 动态 Item 向量和真实 TCP 垂直测试,覆盖嵌套值、 空报告、配置拒绝、触发前快照与 Host 拒绝。 diff --git a/README.md b/README.md index 6f63a58..0392e98 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor 配置、动态消息收发、状态等待和单消费者事件流。 独立 SecsFrame.Gem 已提供可配置 GEM 通用行为,包含通讯建立、 上下线、动态变量/常量、应用托管时钟、报告定义、事件链接和 Collection -Event,以及最小报警通知链路。Item 用法与边界见 +Event,以及最小报警通知和远程命令链路。Item 用法与边界见 [docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见 [docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见 [docs/STREAMFRAME-ADAPTER.md](docs/STREAMFRAME-ADAPTER.md),状态机边界见 diff --git a/docs/GEM-FOUNDATION.md b/docs/GEM-FOUNDATION.md index ae1792b..5d7d4c2 100644 --- a/docs/GEM-FOUNDATION.md +++ b/docs/GEM-FOUNDATION.md @@ -5,7 +5,8 @@ SecsFrame.Gem 是只依赖 SecsFrame 的独立程序集。 当前切片提供 Host/Equipment 两侧的通讯建立、在线/离线、状态变量读取、 设备常量读取、应用托管时钟、报告定义、事件链接和 Collection Event, -以及最小报警通知链路,不把 GEM 状态或消息目录放入 HSMS 状态机。 +以及最小报警通知与远程命令链路,不把 GEM 状态或消息目录放入 HSMS +状态机。 GemHostServices 依赖 SecsHost; GemEquipmentServices 依赖 SecsEquipment。服务不拥有 @@ -31,6 +32,7 @@ | 事件链接 | S2F35 | S2F36 | | Collection Event | S6F11 | S6F12 | | 报警通知 | S5F1 | S5F2 | +| 远程命令 | S2F41 | S2F42 | 这张表描述仓库自己的默认配置,不是复制的 SEMI 规范表,也不表示已经 核对 E30 的必选能力、状态条件、数据项定义、报告生命周期或应答枚举。 @@ -113,6 +115,24 @@ Host 使用 RegisterAlarmNotificationHandler 注册单一可释放 取消语义与 Collection Event 处理器一致。本切片不包含报警目录查询、 启停/屏蔽、历史、持久化或基于 GEM 状态的发送门控。 +## 远程命令 + +Host 使用 GemRemoteCommand 携带动态命令标识和有序参数,再 +通过 ExecuteRemoteCommandAsync 发起请求。命令、参数名称和 +参数值均保持为任意非 null SecsItem;参数名称必须唯一,值可 +包含嵌套 List。库不把现场命令限制为预生成枚举或 ASCII 字符串。 + +Equipment 使用 RegisterRemoteCommandHandler 注册单一可释放 +处理器。处理器返回 GemRemoteCommandResult,其中包含一个原始 +整体完成码和有序参数结果;每个参数结果保留动态名称与原始单字节代码。 +Host 返回完整结果,不把非零代码折叠成异常,也不解释代码枚举。没有处理器 +时 Equipment 使用 profile 的失败应答值和空参数结果立即回复。 + +处理器在锁外执行,注册快照、释放、异常和取消语义与报警处理器一致。当前 +切片只要求请求参数名和结果参数名各自唯一,不推断结果必须覆盖哪些请求 +参数。本切片不包含命令目录、参数 Schema、权限、排队、重试、幂等策略、 +执行历史或基于 GEM 状态的命令门控。 + ## 严格失败边界 当前实现严格要求: @@ -127,13 +147,19 @@ Host 使用 RegisterAlarmNotificationHandler 注册单一可释放 的标识和值列表形状必须完整且标识不能产生歧义; - 报警通知是三元素 List,代码必须是恰好一个 Binary 字节,文本必须为 ASCII,报警标识保持为任意非 null 动态 Item; +- 远程命令请求是命令标识和参数列表组成的二元素 List,每个参数是名称与 + 值组成的二元素 List;回复是整体单字节代码和参数结果列表组成的二元素 + List,每个参数结果必须包含唯一名称和单字节 Binary 代码; - 报告配置请求和 Collection Event 必须设置 W-Bit,Secondary 的消息对和 - 应答字节必须与 profile 匹配;报警通知遵循相同要求。 + 应答字节必须与 profile 匹配;报警通知遵循相同要求; +- 远程命令必须设置 W-Bit 且 Secondary 消息对完全匹配;整体和参数结果码 + 保持原始字节,不限制为 profile 的成功/失败两个值。 畸形输入和提供器失败会作为异常返回应用事件循环;语义有效但引用未知变量 或报告的配置使用失败应答拒绝。本切片尚未实现自动 S9Fx、错误 Secondary、 -通讯建立策略拒绝、状态切换策略、报警目录/启停/历史、远程命令或完整 -GEM 错误恢复,这些行为不能从当前 API 推断为标准合规。 +通讯建立策略拒绝、状态切换策略、报警目录/启停/历史、 +命令目录/权限/调度或完整 GEM 错误恢复,这些行为不能从当前 API 推断为 +标准合规。 ## 标准与验证边界 @@ -141,11 +167,12 @@ GEM 错误恢复,这些行为不能从当前 API 推断为标准合规。 副本核对消息条件、COMMACK/ONLACK/OFLACK/TIACK 语义、MDLN 条件结构、 空 SVID/ECID 列表行为、报告定义与链接生命周期、Collection Event 数据 结构和应答、报警通知结构与代码位语义、时间格式选择、状态转换和错误 -响应。不得把标准正文、消息表、状态图或 Schema 提交仓库。完整版本和版权 -边界见 +响应,以及远程命令结构、整体/参数结果代码语义。不得把标准正文、消息表、 +状态图或 Schema 提交仓库。完整版本和版权边界见 [STANDARDS.md](STANDARDS.md)。 当前证据包括三目标框架的严格输入测试,以及 Host Active / Equipment Passive 真实 TCP 下的双向通讯建立、上下线、异构动态标识、报告定义、 事件链接、Collection Event 嵌套值与空报告、报警通知接受/拒绝、配置/事件 -拒绝、时钟读写和 Linktest。它们是独立工程验证,不是 SEMI 一致性认证。 +拒绝、远程命令动态参数与结果、无处理器拒绝、时钟读写和 Linktest。它们 +是独立工程验证,不是 SEMI 一致性认证。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 435c850..08c929f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -39,13 +39,14 @@ - [x] 面向 Host 与 Equipment 的独立基础能力 API 和真实 TCP 垂直测试。 - [x] 报告定义、事件链接和 Collection Event。 - [x] 报警通知的动态标识、代码透传、Host 处理器和 Equipment 发送链路。 -- [ ] 报警目录/启停/历史、远程命令和更完整的 GEM 状态/错误恢复。 +- [x] 远程命令的动态参数/结果、Host 发送和 Equipment 处理链路。 +- [ ] 报警目录/启停/历史、命令目录/权限/调度和更完整的状态/错误恢复。 - [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。 -当前报告和报警通知切片使用可替换工程 profile、动态 -SecsItem 标识,并保留报警代码原始字节,不宣称已经核对 -E30 的报告生命周期、报警代码位语义或应答枚举。下一优先级是远程命令, -再扩展报警管理以及更完整的 GEM 状态和错误恢复。 +当前报告、报警通知和远程命令切片使用可替换工程 profile、动态 +SecsItem 标识,并保留报警与命令结果原始代码,不宣称已经 +核对 E30 的生命周期、代码语义或应答枚举。下一优先级是报警管理和更完整 +的 GEM 状态/错误恢复。 ## 阶段 3:元数据与工具 diff --git a/docs/SECS-ENDPOINT-ROLES.md b/docs/SECS-ENDPOINT-ROLES.md index 274c7ac..105fe56 100644 --- a/docs/SECS-ENDPOINT-ROLES.md +++ b/docs/SECS-ENDPOINT-ROLES.md @@ -62,6 +62,7 @@ await equipment.WaitUntilSelectedAsync(cancellationToken); 而不是修改 HSMS 状态机或根据 Active/Passive 猜测角色。 当前 GEM 切片已实现工程配置下的通讯建立、在线状态、变量、设备常量、 -时钟、报告定义、事件链接、Collection Event 和最小报警通知;报警目录、 -启停/历史与远程命令仍未实现。相关行为、事件循环要求和未核对边界见 +时钟、报告定义、事件链接、Collection Event,以及最小报警通知和远程命令; +报警目录、启停/历史、命令目录/权限/调度仍未实现。相关行为、事件循环要求 +和未核对边界见 [GEM-FOUNDATION.md](GEM-FOUNDATION.md),项目不声明 GEM 合规。 diff --git a/src/SecsFrame.Gem/GemEquipmentServices.cs b/src/SecsFrame.Gem/GemEquipmentServices.cs index e07f1d3..056081a 100644 --- a/src/SecsFrame.Gem/GemEquipmentServices.cs +++ b/src/SecsFrame.Gem/GemEquipmentServices.cs @@ -16,6 +16,7 @@ public sealed class GemEquipmentServices : IDisposable private readonly Dictionary _equipmentConstants = new(); private readonly Dictionary _reportDefinitions = new(); private readonly Dictionary _eventReportLinks = new(); + private GemRemoteCommandRegistration? _remoteCommandHandler; private int _disposed; /// Creates Equipment GEM services without taking ownership of the endpoint. @@ -89,6 +90,30 @@ public GemValueRegistration RegisterEquipmentConstant( provider, "equipment constant"); + /// Registers the single application remote-command handler. + public GemRemoteCommandRegistration RegisterRemoteCommandHandler( + GemRemoteCommandHandler handler) + { + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + + lock (_gate) + { + ThrowIfDisposed(); + if (_remoteCommandHandler is not null) + { + throw new InvalidOperationException( + "A remote-command handler is already registered."); + } + + var registration = new GemRemoteCommandRegistration( + handler, + UnregisterRemoteCommandHandler); + _remoteCommandHandler = registration; + return registration; + } + } + /// Collects and sends one linked Collection Event to the Host. public async Task SendCollectionEventAsync( SecsItem dataId, @@ -174,6 +199,7 @@ public void Dispose() _equipmentConstants.Clear(); _reportDefinitions.Clear(); _eventReportLinks.Clear(); + _remoteCommandHandler = null; } _services.Dispose(); } @@ -188,6 +214,7 @@ private void RegisterEquipmentRoutes() _services.AddRoute(Profile.SetClock, HandleSetClockAsync); _services.AddRoute(Profile.DefineReports, HandleDefineReportsAsync); _services.AddRoute(Profile.LinkEventReports, HandleLinkEventReportsAsync); + _services.AddRoute(Profile.RemoteCommand, HandleRemoteCommandAsync); } private ValueTask HandleOnlineAsync( @@ -340,6 +367,30 @@ links is null return null; } + private async ValueTask HandleRemoteCommandAsync( + HsmsPrimaryContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + GemMessageCodec.RequireReplyExpected(context, "remote-command request"); + var command = GemMessageCodec.DecodeRemoteCommand( + context.Message.RootItem); + GemRemoteCommandHandler? handler; + lock (_gate) + handler = _remoteCommandHandler?.Handler; + + var result = handler is null + ? new GemRemoteCommandResult( + Profile.FailedAcknowledgement, + Array.Empty()) + : await handler(command, cancellationToken).ConfigureAwait(false) ?? + throw new GemProtocolException( + "The remote-command handler returned null."); + return GemEndpointServices.CreateSecondary( + Profile.RemoteCommand, + GemMessageCodec.EncodeRemoteCommandResult(result)); + } + private GemValueRegistration RegisterValue( Dictionary registrations, SecsItem identifier, @@ -562,6 +613,16 @@ private void ThrowIfDisposed() throw new ObjectDisposedException(nameof(GemEquipmentServices)); } + private void UnregisterRemoteCommandHandler( + GemRemoteCommandRegistration registration) + { + lock (_gate) + { + if (ReferenceEquals(_remoteCommandHandler, registration)) + _remoteCommandHandler = null; + } + } + private sealed class ReportExecution { internal ReportExecution( diff --git a/src/SecsFrame.Gem/GemHostServices.cs b/src/SecsFrame.Gem/GemHostServices.cs index 1e02dc4..b76b6b8 100644 --- a/src/SecsFrame.Gem/GemHostServices.cs +++ b/src/SecsFrame.Gem/GemHostServices.cs @@ -171,6 +171,23 @@ public async Task LinkEventReportsAsync( "event-report-link reply")); } + /// Sends a remote command and returns its uninterpreted result. + public async Task ExecuteRemoteCommandAsync( + GemRemoteCommand command, + CancellationToken cancellationToken = default) + { + if (command is null) + throw new ArgumentNullException(nameof(command)); + + var response = await _services.SendRequestAsync( + Profile.RemoteCommand, + GemOperation.RemoteCommand, + GemMessageCodec.EncodeRemoteCommand(command), + cancellationToken).ConfigureAwait(false); + return GemMessageCodec.DecodeRemoteCommandResult( + response.Message.RootItem); + } + /// Registers the single application Collection Event handler. public GemCollectionEventRegistration RegisterCollectionEventHandler( GemCollectionEventHandler handler) diff --git a/src/SecsFrame.Gem/GemMessageCodec.cs b/src/SecsFrame.Gem/GemMessageCodec.cs index c54db9c..6df0a0f 100644 --- a/src/SecsFrame.Gem/GemMessageCodec.cs +++ b/src/SecsFrame.Gem/GemMessageCodec.cs @@ -248,6 +248,95 @@ internal static GemAlarmNotification DecodeAlarmNotification(SecsItem? root) RequireAscii(body[2], operation, "text")); } + internal static SecsItem EncodeRemoteCommand(GemRemoteCommand command) + { + if (command is null) + throw new ArgumentNullException(nameof(command)); + + var parameters = new SecsItem[command.Parameters.Count]; + for (var index = 0; index < command.Parameters.Count; index++) + { + var parameter = command.Parameters[index]; + parameters[index] = SecsItem.List(parameter.Name, parameter.Value); + } + + return SecsItem.List(command.Command, SecsItem.List(parameters)); + } + + internal static GemRemoteCommand DecodeRemoteCommand(SecsItem? root) + { + const string operation = "remote-command request"; + var body = RequireList(root, 2, operation); + var encoded = DecodeList(body[1], operation); + var parameters = new GemRemoteCommandParameter[encoded.Count]; + var names = new HashSet(); + for (var index = 0; index < encoded.Count; index++) + { + var fields = RequireList(encoded[index], 2, operation); + if (!names.Add(fields[0])) + { + throw new GemProtocolException( + $"The {operation} contains duplicate parameter name at index {index}."); + } + + parameters[index] = new GemRemoteCommandParameter( + fields[0], + fields[1]); + } + + return new GemRemoteCommand(body[0], parameters); + } + + internal static SecsItem EncodeRemoteCommandResult( + GemRemoteCommandResult result) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + + var parameters = new SecsItem[result.ParameterResults.Count]; + for (var index = 0; index < result.ParameterResults.Count; index++) + { + var parameter = result.ParameterResults[index]; + parameters[index] = SecsItem.List( + parameter.Name, + SecsItem.Binary(parameter.Acknowledgement)); + } + + return SecsItem.List( + SecsItem.Binary(result.Acknowledgement), + SecsItem.List(parameters)); + } + + internal static GemRemoteCommandResult DecodeRemoteCommandResult( + SecsItem? root) + { + const string operation = "remote-command reply"; + var body = RequireList(root, 2, operation); + var encoded = DecodeList(body[1], operation); + var parameters = new GemRemoteCommandParameterResult[encoded.Count]; + var names = new HashSet(); + for (var index = 0; index < encoded.Count; index++) + { + var fields = RequireList(encoded[index], 2, operation); + if (!names.Add(fields[0])) + { + throw new GemProtocolException( + $"The {operation} contains duplicate parameter-result name " + + $"at index {index}."); + } + + parameters[index] = new GemRemoteCommandParameterResult( + fields[0], + RequireBinaryByte( + fields[1], + $"remote-command parameter result at index {index}")); + } + + return new GemRemoteCommandResult( + RequireBinaryByte(body[0], "remote-command acknowledgement"), + parameters); + } + internal static SecsItem EncodeClock(DateTimeOffset value, GemClockCodec codec) => SecsItem.Ascii(codec.Encode(value)); diff --git a/src/SecsFrame.Gem/GemMessageProfile.cs b/src/SecsFrame.Gem/GemMessageProfile.cs index 8d73b31..a86543c 100644 --- a/src/SecsFrame.Gem/GemMessageProfile.cs +++ b/src/SecsFrame.Gem/GemMessageProfile.cs @@ -14,9 +14,12 @@ public sealed class GemMessageProfile { private static readonly GemMessagePair BaselineAlarmNotification = new(5, 1, 2); + private static readonly GemMessagePair BaselineRemoteCommand = + new(2, 41, 42); /// - /// Creates a foundational profile using the engineering-baseline alarm pair. + /// Creates a foundational profile using the engineering-baseline alarm and + /// remote-command pairs. /// public GemMessageProfile( GemMessagePair establishCommunication, @@ -52,6 +55,46 @@ public GemMessageProfile( { } + /// + /// Creates a foundational profile using the engineering-baseline + /// remote-command pair. + /// + public GemMessageProfile( + GemMessagePair establishCommunication, + GemMessagePair areYouOnline, + GemMessagePair requestOnline, + GemMessagePair requestOffline, + GemMessagePair readStatusVariables, + GemMessagePair readEquipmentConstants, + GemMessagePair getClock, + GemMessagePair setClock, + GemMessagePair defineReports, + GemMessagePair linkEventReports, + GemMessagePair collectionEvent, + GemMessagePair alarmNotification, + byte acceptedAcknowledgement, + byte failedAcknowledgement, + GemClockCodec clockCodec) + : this( + establishCommunication, + areYouOnline, + requestOnline, + requestOffline, + readStatusVariables, + readEquipmentConstants, + getClock, + setClock, + defineReports, + linkEventReports, + collectionEvent, + alarmNotification, + BaselineRemoteCommand, + acceptedAcknowledgement, + failedAcknowledgement, + clockCodec) + { + } + /// Creates an explicit foundational message profile. public GemMessageProfile( GemMessagePair establishCommunication, @@ -66,6 +109,7 @@ public GemMessageProfile( GemMessagePair linkEventReports, GemMessagePair collectionEvent, GemMessagePair alarmNotification, + GemMessagePair remoteCommand, byte acceptedAcknowledgement, byte failedAcknowledgement, GemClockCodec clockCodec) @@ -89,7 +133,8 @@ public GemMessageProfile( defineReports, linkEventReports, collectionEvent, - alarmNotification); + alarmNotification, + remoteCommand); EstablishCommunication = establishCommunication; AreYouOnline = areYouOnline; RequestOnline = requestOnline; @@ -102,6 +147,7 @@ public GemMessageProfile( LinkEventReports = linkEventReports; CollectionEvent = collectionEvent; AlarmNotification = alarmNotification; + RemoteCommand = remoteCommand; AcceptedAcknowledgement = acceptedAcknowledgement; FailedAcknowledgement = failedAcknowledgement; ClockCodec = clockCodec ?? throw new ArgumentNullException(nameof(clockCodec)); @@ -143,6 +189,9 @@ public GemMessageProfile( /// Gets the alarm-notification message pair. public GemMessagePair AlarmNotification { get; } + /// Gets the remote-command message pair. + public GemMessagePair RemoteCommand { get; } + /// Gets the configured successful acknowledgement byte. public byte AcceptedAcknowledgement { get; } @@ -169,6 +218,7 @@ public static GemMessageProfile CreateEngineeringBaseline() new GemMessagePair(2, 35, 36), new GemMessagePair(6, 11, 12), BaselineAlarmNotification, + BaselineRemoteCommand, acceptedAcknowledgement: 0, failedAcknowledgement: 1, new GemClockCodec(EncodeBaselineTime, DecodeBaselineTime)); diff --git a/src/SecsFrame.Gem/GemOperation.cs b/src/SecsFrame.Gem/GemOperation.cs index b9e43f6..c2248df 100644 --- a/src/SecsFrame.Gem/GemOperation.cs +++ b/src/SecsFrame.Gem/GemOperation.cs @@ -38,4 +38,7 @@ public enum GemOperation /// Send or handle an alarm notification. AlarmNotification, + + /// Send or handle a remote command. + RemoteCommand, } diff --git a/src/SecsFrame.Gem/GemRemoteCommand.cs b/src/SecsFrame.Gem/GemRemoteCommand.cs new file mode 100644 index 0000000..32e90c3 --- /dev/null +++ b/src/SecsFrame.Gem/GemRemoteCommand.cs @@ -0,0 +1,30 @@ +namespace SecsFrame.Gem; + +/// Contains one dynamic remote-command request. +public sealed class GemRemoteCommand +{ + /// Creates a remote-command request. + public GemRemoteCommand( + SecsItem command, + IEnumerable parameters) + { + Command = command ?? throw new ArgumentNullException(nameof(command)); + Parameters = GemCollection.Copy(parameters, nameof(parameters)); + var names = new HashSet(); + for (var index = 0; index < Parameters.Count; index++) + { + if (!names.Add(Parameters[index].Name)) + { + throw new ArgumentException( + $"Parameter name at index {index} is duplicated.", + nameof(parameters)); + } + } + } + + /// Gets the exact dynamic command identifier. + public SecsItem Command { get; } + + /// Gets parameters in request order. + public IReadOnlyList Parameters { get; } +} diff --git a/src/SecsFrame.Gem/GemRemoteCommandHandler.cs b/src/SecsFrame.Gem/GemRemoteCommandHandler.cs new file mode 100644 index 0000000..94b4586 --- /dev/null +++ b/src/SecsFrame.Gem/GemRemoteCommandHandler.cs @@ -0,0 +1,6 @@ +namespace SecsFrame.Gem; + +/// Executes a decoded remote command and returns its raw result. +public delegate ValueTask GemRemoteCommandHandler( + GemRemoteCommand command, + CancellationToken cancellationToken); diff --git a/src/SecsFrame.Gem/GemRemoteCommandParameter.cs b/src/SecsFrame.Gem/GemRemoteCommandParameter.cs new file mode 100644 index 0000000..4c11c71 --- /dev/null +++ b/src/SecsFrame.Gem/GemRemoteCommandParameter.cs @@ -0,0 +1,18 @@ +namespace SecsFrame.Gem; + +/// Contains one dynamic remote-command parameter. +public sealed class GemRemoteCommandParameter +{ + /// Creates a remote-command parameter. + public GemRemoteCommandParameter(SecsItem name, SecsItem value) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Value = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// Gets the exact dynamic parameter name. + public SecsItem Name { get; } + + /// Gets the exact dynamic parameter value. + public SecsItem Value { get; } +} diff --git a/src/SecsFrame.Gem/GemRemoteCommandParameterResult.cs b/src/SecsFrame.Gem/GemRemoteCommandParameterResult.cs new file mode 100644 index 0000000..e2f9398 --- /dev/null +++ b/src/SecsFrame.Gem/GemRemoteCommandParameterResult.cs @@ -0,0 +1,20 @@ +namespace SecsFrame.Gem; + +/// Contains one raw remote-command parameter result. +public sealed class GemRemoteCommandParameterResult +{ + /// Creates a parameter result without interpreting its code. + public GemRemoteCommandParameterResult( + SecsItem name, + byte acknowledgement) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Acknowledgement = acknowledgement; + } + + /// Gets the exact dynamic parameter name. + public SecsItem Name { get; } + + /// Gets the exact acknowledgement byte. + public byte Acknowledgement { get; } +} diff --git a/src/SecsFrame.Gem/GemRemoteCommandRegistration.cs b/src/SecsFrame.Gem/GemRemoteCommandRegistration.cs new file mode 100644 index 0000000..72b73f2 --- /dev/null +++ b/src/SecsFrame.Gem/GemRemoteCommandRegistration.cs @@ -0,0 +1,21 @@ +namespace SecsFrame.Gem; + +/// Owns one Equipment remote-command handler registration. +public sealed class GemRemoteCommandRegistration : IDisposable +{ + private Action? _unregister; + + internal GemRemoteCommandRegistration( + GemRemoteCommandHandler handler, + Action unregister) + { + Handler = handler; + _unregister = unregister; + } + + internal GemRemoteCommandHandler 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/GemRemoteCommandResult.cs b/src/SecsFrame.Gem/GemRemoteCommandResult.cs new file mode 100644 index 0000000..5d8a20d --- /dev/null +++ b/src/SecsFrame.Gem/GemRemoteCommandResult.cs @@ -0,0 +1,32 @@ +namespace SecsFrame.Gem; + +/// Contains one raw remote-command completion result. +public sealed class GemRemoteCommandResult +{ + /// Creates a result without interpreting acknowledgement codes. + public GemRemoteCommandResult( + byte acknowledgement, + IEnumerable parameterResults) + { + Acknowledgement = acknowledgement; + ParameterResults = GemCollection.Copy( + parameterResults, + nameof(parameterResults)); + var names = new HashSet(); + for (var index = 0; index < ParameterResults.Count; index++) + { + if (!names.Add(ParameterResults[index].Name)) + { + throw new ArgumentException( + $"Parameter-result name at index {index} is duplicated.", + nameof(parameterResults)); + } + } + } + + /// Gets the exact command acknowledgement byte. + public byte Acknowledgement { get; } + + /// Gets parameter results in reply order. + public IReadOnlyList ParameterResults { get; } +} diff --git a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs index c358ceb..82e28a9 100644 --- a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs +++ b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs @@ -68,6 +68,58 @@ await Assert.ThrowsAsync(() => DispatchAsync( Assert.Equal(GemOnlineState.Offline, services.OnlineState); } + [Fact] + public async Task Remote_command_vectors_require_w_bit_and_unique_parameters() + { + 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)); + var body = SecsItem.List( + SecsItem.Ascii("START"), + SecsItem.List( + SecsItem.List( + SecsItem.Ascii("SPEED"), + SecsItem.U2(10)))); + + await Assert.ThrowsAsync(() => DispatchAsync( + services, + new SecsMessage(2, 41, rootItem: body))).ConfigureAwait(true); + await Assert.ThrowsAsync(() => DispatchAsync( + services, + new SecsMessage(2, 41, true, SecsItem.List()))) + .ConfigureAwait(true); + await Assert.ThrowsAsync(() => DispatchAsync( + services, + new SecsMessage( + 2, + 41, + true, + SecsItem.List( + SecsItem.Ascii("START"), + SecsItem.List( + SecsItem.List(SecsItem.Ascii("SPEED"))))))) + .ConfigureAwait(true); + await Assert.ThrowsAsync(() => DispatchAsync( + services, + new SecsMessage( + 2, + 41, + true, + SecsItem.List( + SecsItem.Ascii("START"), + SecsItem.List( + SecsItem.List( + SecsItem.Ascii("SPEED"), + SecsItem.U2(10)), + SecsItem.List( + SecsItem.Ascii("SPEED"), + SecsItem.U2(20))))))) + .ConfigureAwait(true); + } + [Fact] public async Task Dynamic_registration_is_exact_disposable_and_replaceable() { @@ -121,6 +173,32 @@ await Assert.ThrowsAsync(() => DispatchAsync( .ConfigureAwait(true); } + [Fact] + public async Task Remote_command_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)); + GemRemoteCommandHandler handler = static (_, _) => + new ValueTask( + new GemRemoteCommandResult( + 0, + Array.Empty())); + + var first = services.RegisterRemoteCommandHandler(handler); + Assert.Throws(() => + services.RegisterRemoteCommandHandler(handler)); + first.Dispose(); + first.Dispose(); + using var replacement = services.RegisterRemoteCommandHandler(handler); + services.Dispose(); + Assert.Throws(() => + services.RegisterRemoteCommandHandler(handler)); + } + private static Task DispatchAsync( GemEquipmentServices services, SecsMessage message) diff --git a/test/SecsFrame.Tests/GemFoundationTcpTests.cs b/test/SecsFrame.Tests/GemFoundationTcpTests.cs index e63157f..c5cf47d 100644 --- a/test/SecsFrame.Tests/GemFoundationTcpTests.cs +++ b/test/SecsFrame.Tests/GemFoundationTcpTests.cs @@ -15,6 +15,7 @@ public async Task Host_and_equipment_complete_foundational_dialogues_over_tcp() await AssertDynamicDataAsync(context).ConfigureAwait(true); await AssertCollectionEventsAsync(context).ConfigureAwait(true); await AssertAlarmNotificationsAsync(context).ConfigureAwait(true); + await AssertRemoteCommandsAsync(context).ConfigureAwait(true); await AssertClockAsync(context).ConfigureAwait(true); Assert.Equal( context.HostServices.Identity, @@ -275,6 +276,74 @@ await context.EquipmentServices.SendAlarmNotificationAsync( Assert.Equal((byte)1, rejected.Acknowledgement); } + private static async Task AssertRemoteCommandsAsync(GemTcpContext context) + { + var received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using (context.EquipmentServices.RegisterRemoteCommandHandler( + (command, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + received.TrySetResult(command); + return new ValueTask( + new GemRemoteCommandResult( + 0, + new[] + { + new GemRemoteCommandParameterResult( + SecsItem.Ascii("SPEED"), + 0), + new GemRemoteCommandParameterResult( + SecsItem.U1(2), + 7), + })); + })) + { + var result = await context.HostServices.ExecuteRemoteCommandAsync( + CreateRemoteCommand(), + context.Token).ConfigureAwait(true); + Assert.Equal((byte)0, result.Acknowledgement); + Assert.Equal(2, result.ParameterResults.Count); + Assert.Equal( + SecsItem.Ascii("SPEED"), + result.ParameterResults[0].Name); + Assert.Equal((byte)0, result.ParameterResults[0].Acknowledgement); + Assert.Equal(SecsItem.U1(2), result.ParameterResults[1].Name); + Assert.Equal((byte)7, result.ParameterResults[1].Acknowledgement); + } + + var command = await received.Task.ConfigureAwait(true); + Assert.Equal(SecsItem.U4(7001), command.Command); + Assert.Equal(2, command.Parameters.Count); + Assert.Equal(SecsItem.Ascii("SPEED"), command.Parameters[0].Name); + Assert.Equal(SecsItem.U2(25), command.Parameters[0].Value); + Assert.Equal(SecsItem.U1(2), command.Parameters[1].Name); + Assert.Equal( + SecsItem.List(SecsItem.Boolean(true), SecsItem.F8(1.5)), + command.Parameters[1].Value); + + var unavailable = await context.HostServices.ExecuteRemoteCommandAsync( + CreateRemoteCommand(), + context.Token).ConfigureAwait(true); + Assert.Equal((byte)1, unavailable.Acknowledgement); + Assert.Empty(unavailable.ParameterResults); + } + + private static GemRemoteCommand CreateRemoteCommand() + => new( + SecsItem.U4(7001), + new[] + { + new GemRemoteCommandParameter( + SecsItem.Ascii("SPEED"), + SecsItem.U2(25)), + new GemRemoteCommandParameter( + SecsItem.U1(2), + SecsItem.List( + SecsItem.Boolean(true), + SecsItem.F8(1.5))), + }); + private static async Task PumpAsync( SecsEndpoint endpoint, Func> dispatch, diff --git a/test/SecsFrame.Tests/GemMessageProfileTests.cs b/test/SecsFrame.Tests/GemMessageProfileTests.cs index 49243e3..3ad5f71 100644 --- a/test/SecsFrame.Tests/GemMessageProfileTests.cs +++ b/test/SecsFrame.Tests/GemMessageProfileTests.cs @@ -29,9 +29,16 @@ public void Engineering_baseline_is_explicit_and_round_trips_utc_clock() Assert.Equal(new GemMessagePair(2, 35, 36), profile.LinkEventReports); Assert.Equal(new GemMessagePair(6, 11, 12), profile.CollectionEvent); Assert.Equal(new GemMessagePair(5, 1, 2), profile.AlarmNotification); + Assert.Equal(new GemMessagePair(2, 41, 42), profile.RemoteCommand); Assert.Equal( new GemMessagePair(5, 1, 2), CreateLegacyCompatibleProfile(profile).AlarmNotification); + Assert.Equal( + new GemMessagePair(2, 41, 42), + CreateLegacyCompatibleProfile(profile).RemoteCommand); + Assert.Equal( + new GemMessagePair(2, 41, 42), + CreateAlarmCompatibleProfile(profile).RemoteCommand); Assert.Equal((byte)0, profile.AcceptedAcknowledgement); Assert.Equal((byte)1, profile.FailedAcknowledgement); Assert.Equal("2026082801101112", profile.ClockCodec.Encode(value)); @@ -53,6 +60,9 @@ public void Profile_rejects_ambiguous_routes_and_acknowledgements() Assert.Throws(() => CreateProfile( baseline, alarmNotification: baseline.EstablishCommunication)); + Assert.Throws(() => CreateProfile( + baseline, + remoteCommand: baseline.EstablishCommunication)); Assert.Throws(() => CreateProfile( baseline, failedAcknowledgement: baseline.AcceptedAcknowledgement)); @@ -98,11 +108,49 @@ public void Public_values_reject_invalid_protocol_boundaries() new GemAlarmNotification(0, SecsItem.U2(3001), "报警")); } + [Fact] + public void Remote_command_values_are_immutable_and_unambiguous() + { + var parameters = new[] + { + new GemRemoteCommandParameter( + SecsItem.Ascii("SPEED"), + SecsItem.U2(10)), + }; + var command = new GemRemoteCommand(SecsItem.U4(7), parameters); + parameters[0] = new GemRemoteCommandParameter( + SecsItem.Ascii("MODE"), + SecsItem.Ascii("AUTO")); + + Assert.Equal(SecsItem.Ascii("SPEED"), Assert.Single(command.Parameters).Name); + Assert.Throws(() => new GemRemoteCommand( + SecsItem.Ascii("START"), + new[] + { + new GemRemoteCommandParameter( + SecsItem.Ascii("SPEED"), + SecsItem.U2(10)), + new GemRemoteCommandParameter( + SecsItem.Ascii("SPEED"), + SecsItem.U2(20)), + })); + Assert.Throws(() => + new GemRemoteCommandParameter(null!, SecsItem.U1(1))); + Assert.Throws(() => new GemRemoteCommandResult( + 1, + new[] + { + new GemRemoteCommandParameterResult(SecsItem.U1(1), 2), + new GemRemoteCommandParameterResult(SecsItem.U1(1), 3), + })); + } + private static GemMessageProfile CreateProfile( GemMessageProfile baseline, GemMessagePair? areYouOnline = null, GemMessagePair? collectionEvent = null, GemMessagePair? alarmNotification = null, + GemMessagePair? remoteCommand = null, byte? failedAcknowledgement = null, GemClockCodec? clockCodec = default, bool useNullClockCodec = false) @@ -119,6 +167,7 @@ private static GemMessageProfile CreateProfile( baseline.LinkEventReports, collectionEvent ?? baseline.CollectionEvent, alarmNotification ?? baseline.AlarmNotification, + remoteCommand ?? baseline.RemoteCommand, baseline.AcceptedAcknowledgement, failedAcknowledgement ?? baseline.FailedAcknowledgement, useNullClockCodec ? null! : clockCodec ?? baseline.ClockCodec); @@ -140,4 +189,23 @@ private static GemMessageProfile CreateLegacyCompatibleProfile( baseline.AcceptedAcknowledgement, baseline.FailedAcknowledgement, baseline.ClockCodec); + + private static GemMessageProfile CreateAlarmCompatibleProfile( + GemMessageProfile baseline) + => new( + baseline.EstablishCommunication, + baseline.AreYouOnline, + baseline.RequestOnline, + baseline.RequestOffline, + baseline.ReadStatusVariables, + baseline.ReadEquipmentConstants, + baseline.GetClock, + baseline.SetClock, + baseline.DefineReports, + baseline.LinkEventReports, + baseline.CollectionEvent, + baseline.AlarmNotification, + baseline.AcceptedAcknowledgement, + baseline.FailedAcknowledgement, + baseline.ClockCodec); }