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);
}