From c87a5c8264f22c7f240bb684aa82fcfe65e130aa Mon Sep 17 00:00:00 2001 From: Marci Date: Sat, 29 Aug 2026 09:57:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20GEM=20=E6=8A=A5?= =?UTF-8?q?=E8=AD=A6=E5=8F=91=E9=80=81=E5=90=AF=E5=81=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++ README.md | 3 +- docs/GEM-FOUNDATION.md | 54 ++++++++++----- docs/ROADMAP.md | 6 +- docs/SECS-ENDPOINT-ROLES.md | 5 +- docs/STANDARDS.md | 5 +- src/SecsFrame.Gem/GemAlarmControlCodec.cs | 43 ++++++++++++ src/SecsFrame.Gem/GemAlarmRegistration.cs | 7 ++ src/SecsFrame.Gem/GemEquipmentServices.cs | 43 ++++++++++++ src/SecsFrame.Gem/GemHostServices.cs | 21 ++++++ src/SecsFrame.Gem/GemMessageCodec.cs | 28 ++++++++ src/SecsFrame.Gem/GemMessageProfile.cs | 67 ++++++++++++++++++- src/SecsFrame.Gem/GemOperation.cs | 3 + .../GemEquipmentServicesTests.cs | 47 +++++++++++++ test/SecsFrame.Tests/GemFoundationTcpTests.cs | 61 +++++++++++++++++ .../SecsFrame.Tests/GemMessageProfileTests.cs | 39 +++++++++++ 16 files changed, 408 insertions(+), 28 deletions(-) create mode 100644 src/SecsFrame.Gem/GemAlarmControlCodec.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c09f269..1f118ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,10 @@ 和 Host 全量/按动态标识查询;全量目录保持注册顺序,未知标识被忽略。 - 报警目录严格校验 W-Bit、请求 List、标识唯一性、三字段定义、单字节 Binary 代码与 ASCII 文本,并增加注册释放快照和真实 TCP 查询测试。 +- 增加可替换的单报警发送启停消息对与控制码 codec;Host 可控制已注册报警, + Equipment 原子更新注册状态,未知标识使用失败应答拒绝。 +- 已注册报警默认允许发送;禁用后通知发送入口按调用时快照阻断该标识,目录 + 保持可见,释放并重新注册会恢复默认状态。增加严格协议向量和真实 TCP 测试。 - 增加定义、链接、S6F11 动态 Item 向量和真实 TCP 垂直测试,覆盖嵌套值、 空报告、配置拒绝、触发前快照与 Host 拒绝。 diff --git a/README.md b/README.md index 985f786..dc8691b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ 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 57ed4ca..0eeb072 100644 --- a/docs/GEM-FOUNDATION.md +++ b/docs/GEM-FOUNDATION.md @@ -5,8 +5,8 @@ SecsFrame.Gem 是只依赖 SecsFrame 的独立程序集。 当前切片提供 Host/Equipment 两侧的通讯建立、在线/离线、状态变量读取、 设备常量读取、应用托管时钟、报告定义、事件链接和 Collection Event, -以及报警目录查询、最小报警通知与远程命令链路,不把 GEM 状态或消息目录 -放入 HSMS 状态机。 +以及报警目录查询、单报警发送启停、最小报警通知与远程命令链路,不把 +GEM 状态或消息目录放入 HSMS 状态机。 GemHostServices 依赖 SecsHost; GemEquipmentServices 依赖 SecsEquipment。服务不拥有 @@ -15,8 +15,8 @@ ## 工程基线配置 GemMessageProfile 显式配置每个 Primary/Secondary 对、成功和 -失败应答值以及时钟文本 codec。CreateEngineeringBaseline() -当前返回以下常见工程配置: +失败应答值、时钟文本 codec 以及报警发送启停控制码 codec。 +CreateEngineeringBaseline() 当前返回以下常见工程配置: | 操作 | Primary | Secondary | |---|---:|---:| @@ -32,13 +32,16 @@ | 事件链接 | S2F35 | S2F36 | | Collection Event | S6F11 | S6F12 | | 报警通知 | S5F1 | S5F2 | +| 报警发送启停 | S5F3 | S5F4 | | 报警目录查询 | S5F5 | S5F6 | | 远程命令 | S2F41 | S2F42 | 这张表描述仓库自己的默认配置,不是复制的 SEMI 规范表,也不表示已经 核对 E30 的必选能力、状态条件、数据项定义、报告生命周期或应答枚举。 -默认成功应答为 Binary 0,失败应答为 Binary 1;时钟使用 UTC 的 -yyyyMMddHHmmssff ASCII 工程格式。现场差异应使用独立 profile +默认成功应答为 Binary 0,失败应答为 Binary 1;报警发送允许/禁止码分别 +为 Binary 0x80/0x00;时钟使用 UTC 的 +yyyyMMddHHmmssff ASCII 工程格式。这些值是非规范性工程 +基线,仍需使用授权 E30-0526 与目标设备核对。现场差异应使用独立 profile 和 codec,不应修改严格基线或根据 Function 奇偶推断消息含义。 ## 事件循环与状态 @@ -113,22 +116,33 @@ Equipment 使用 GemAlarmNotification 提供一个精确 Binary 代 Host 使用 RegisterAlarmNotificationHandler 注册单一可释放 处理器。处理器返回 true 发送成功应答,返回 false 或没有处理器时发送失败应答。注册快照、释放、异常和 -取消语义与 Collection Event 处理器一致。本切片不包含报警启停/屏蔽、 -历史、持久化或基于 GEM 状态的发送门控。 +取消语义与 Collection Event 处理器一致。本切片不解释报警条件状态,也 +不包含历史、持久化或基于 GEM 在线状态的发送门控。 Equipment 使用 RegisterAlarm 注册不可变 GemAlarmDefinition。报警定义保留原始单字节代码、动态报警 -标识和七位 ASCII 文本;标识必须唯一。注册对象可精确释放并重新注册,全量 -目录按当前注册顺序返回。 +标识和七位 ASCII 文本;标识必须唯一。注册对象默认允许发送,并通过 +IsSendEnabled 暴露当前发送许可。注册对象可精确释放并重新 +注册,全量目录按当前注册顺序返回。 Host 使用 ListAlarmsAsync 查询目录。省略标识或传入空列表会 取得全量快照;传入唯一标识列表时按请求顺序返回已注册的匹配项,未知标识 被忽略。查询回复不携带额外失败应答,因此未知标识不会被转换为 T3 超时。 请求和回复都是一次事务快照;并发注册或释放只影响后续查询。 -报警目录与 SendAlarmNotificationAsync 当前保持解耦。目录注册 -不会自动限制原始报警通知发送,也没有启用状态。启停/屏蔽及其对通知发送的 -影响需要在授权标准与现场行为核对后单独定义。 +Host 使用 SetAlarmSendEnabledAsync 控制一个已注册报警。 +请求是控制码和动态报警标识组成的二元素 List;控制码由 +GemAlarmControlCodec 严格映射。Equipment 在构造成功应答前 +原子更新注册状态,未知标识使用 profile 的失败应答拒绝。 + +禁用只阻断当前注册 ID 后续进入 +SendAlarmNotificationAsync 的调用,不修改报警定义、目录或 +通知代码位,也不改变报警条件。发送许可在调用入口取得一次快照;已经通过 +检查的并发发送会继续完成。未注册 ID 的原始通知保持既有发送行为。释放并 +重新注册会恢复默认允许状态,HSMS 会话变化不会重置注册状态。 + +当前 API 故意不提供“全部报警”启停、批量控制、报警条件屏蔽或历史。 +这些语义以及控制码含义仍需依据授权 E30-0526 和目标设备互操作结果核对。 ## 远程命令 @@ -164,6 +178,8 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码 ASCII,报警标识保持为任意非 null 动态 Item; - 报警目录请求是唯一动态标识组成的 List,空 List 表示全量查询;回复是 报警定义 List,每项必须包含单字节 Binary 代码、唯一标识和 ASCII 文本; +- 报警发送启停请求是二元素 List,控制码必须是恰好一个 Binary 字节且为 + profile 配置的允许或禁止码;未知报警标识使用失败应答拒绝; - 远程命令请求是命令标识和参数列表组成的二元素 List,每个参数是名称与 值组成的二元素 List;回复是整体单字节代码和参数结果列表组成的二元素 List,每个参数结果必须包含唯一名称和单字节 Binary 代码; @@ -174,7 +190,7 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码 畸形输入和提供器失败会作为异常返回应用事件循环;语义有效但引用未知变量 或报告的配置使用失败应答拒绝。本切片尚未实现自动 S9Fx、错误 Secondary、 -通讯建立策略拒绝、状态切换策略、报警启停/历史、 +通讯建立策略拒绝、状态切换策略、报警历史/批量控制、 命令目录/权限/调度或完整 GEM 错误恢复,这些行为不能从当前 API 推断为 标准合规。 @@ -183,13 +199,15 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码 公开 SEMI 目录当前标识 GEM 基线为 E30-0526。仍需使用团队合法获得的 副本核对消息条件、COMMACK/ONLACK/OFLACK/TIACK 语义、MDLN 条件结构、 空 SVID/ECID 列表行为、报告定义与链接生命周期、Collection Event 数据 -结构和应答、报警通知/目录结构与代码位语义、时间格式选择、状态转换和 -错误响应,以及远程命令结构、整体/参数结果代码语义。不得把标准正文、 +结构和应答、报警通知/目录/发送启停结构、代码位与控制码语义、时间格式 +选择、状态转换和错误响应,以及远程命令结构、整体/参数结果代码语义。 +不得把标准正文、 消息表、状态图或 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 e1370e1..9aa58d3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -41,13 +41,15 @@ - [x] 报警通知的动态标识、代码透传、Host 处理器和 Equipment 发送链路。 - [x] 远程命令的动态参数/结果、Host 发送和 Equipment 处理链路。 - [x] Equipment 运行期报警目录注册与 Host 全量/按标识查询。 -- [ ] 报警启停/历史、命令目录/权限/调度和更完整的状态/错误恢复。 +- [x] 单个已注册报警的发送启停、未知标识拒绝和通知发送门控。 +- [ ] 报警历史/批量控制、命令目录/权限/调度和更完整的状态/错误恢复。 - [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。 当前报告、报警通知和远程命令切片使用可替换工程 profile、动态 SecsItem 标识,并保留报警与命令结果原始代码,不宣称已经 核对 E30 的生命周期、代码语义或应答枚举。报警目录查询同样是非规范性 -工程基线;下一优先级是报警启停,再扩展更完整的 GEM 状态/错误恢复。 +工程基线;单报警发送启停也使用显式可替换控制码。下一优先级是更完整的 +GEM 状态/错误恢复,再评估报警历史与批量控制。 ## 阶段 3:元数据与工具 diff --git a/docs/SECS-ENDPOINT-ROLES.md b/docs/SECS-ENDPOINT-ROLES.md index 7b5c374..1daf819 100644 --- a/docs/SECS-ENDPOINT-ROLES.md +++ b/docs/SECS-ENDPOINT-ROLES.md @@ -63,6 +63,7 @@ await equipment.WaitUntilSelectedAsync(cancellationToken); 当前 GEM 切片已实现工程配置下的通讯建立、在线状态、变量、设备常量、 时钟、报告定义、事件链接、Collection Event,以及最小报警通知和远程命令; -报警目录查询也支持运行期注册。报警启停/历史、命令目录/权限/调度仍未 -实现。相关行为、事件循环要求和未核对边界见 +报警目录查询支持运行期注册,Host 也可启停单个已注册报警的通知发送。 +报警历史/批量控制、命令目录/权限/调度仍未实现。相关行为、事件循环要求和 +未核对边界见 [GEM-FOUNDATION.md](GEM-FOUNDATION.md),项目不声明 GEM 合规。 diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md index ebf8e52..0df1100 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 | 通讯建立、上下线、动态变量/常量、时钟、报告定义、事件链接和 Collection Event 已有可替换 profile 的工程实现;规范性条件待核对 | +| GEM | SEMI E30-0526 | 通讯建立、上下线、动态变量/常量、时钟、报告/事件、报警通知/目录/发送启停和远程命令已有可替换 profile 的工程实现;规范性条件待核对 | | SEDD | SEMI E172-0225 | 规划中 | | SMN | SEMI E173-0721 | 规划中 | @@ -38,7 +38,8 @@ - E30:通讯建立、在线/离线、变量、事件报告、报警、远程命令和时钟能力 的必选/可选边界;还需核对基础消息条件、COMMACK/ONLACK/OFLACK/TIACK 取值、MDLN 条件结构、空 SVID/ECID 列表、报告定义与链接生命周期、 - Collection Event 数据结构和应答值、时间格式、状态转换与错误响应。 + Collection Event 数据结构和应答值、报警通知/目录/发送启停的数据结构与 + 控制码、时间格式、状态转换与错误响应。 - E172/E173:允许分发的 Schema/表示形式、版本标识、扩展点和验证规则。 ## 版权与合规边界 diff --git a/src/SecsFrame.Gem/GemAlarmControlCodec.cs b/src/SecsFrame.Gem/GemAlarmControlCodec.cs new file mode 100644 index 0000000..ff273ea --- /dev/null +++ b/src/SecsFrame.Gem/GemAlarmControlCodec.cs @@ -0,0 +1,43 @@ +namespace SecsFrame.Gem; + +/// Maps alarm-send control states to exact protocol bytes. +/// +/// The mapping is an interoperability configuration and does not interpret +/// alarm notification code bits. +/// +public sealed class GemAlarmControlCodec +{ + /// Creates an unambiguous alarm-send control mapping. + public GemAlarmControlCodec(byte enabledCode, byte disabledCode) + { + if (enabledCode == disabledCode) + { + throw new ArgumentException( + "Enabled and disabled alarm-send codes must differ.", + nameof(disabledCode)); + } + + EnabledCode = enabledCode; + DisabledCode = disabledCode; + } + + /// Gets the exact code used to enable alarm sending. + public byte EnabledCode { get; } + + /// Gets the exact code used to disable alarm sending. + public byte DisabledCode { get; } + + internal byte Encode(bool enabled) + => enabled ? EnabledCode : DisabledCode; + + internal bool Decode(byte code) + { + if (code == EnabledCode) + return true; + if (code == DisabledCode) + return false; + + throw new GemProtocolException( + $"The alarm-send control code 0x{code:X2} is not configured."); + } +} diff --git a/src/SecsFrame.Gem/GemAlarmRegistration.cs b/src/SecsFrame.Gem/GemAlarmRegistration.cs index c4bfd23..52835ac 100644 --- a/src/SecsFrame.Gem/GemAlarmRegistration.cs +++ b/src/SecsFrame.Gem/GemAlarmRegistration.cs @@ -4,6 +4,7 @@ namespace SecsFrame.Gem; public sealed class GemAlarmRegistration : IDisposable { private Action? _unregister; + private int _sendEnabled = 1; internal GemAlarmRegistration( GemAlarmDefinition definition, @@ -16,8 +17,14 @@ internal GemAlarmRegistration( /// Gets the exact registered alarm identifier. public SecsItem AlarmId => Definition.AlarmId; + /// Gets whether notifications for this registration may be sent. + public bool IsSendEnabled => Volatile.Read(ref _sendEnabled) != 0; + internal GemAlarmDefinition Definition { get; } + internal void SetSendEnabled(bool enabled) + => Volatile.Write(ref _sendEnabled, enabled ? 1 : 0); + /// Removes this exact registration. Repeated calls are harmless. public void Dispose() => Interlocked.Exchange(ref _unregister, null)?.Invoke(this); diff --git a/src/SecsFrame.Gem/GemEquipmentServices.cs b/src/SecsFrame.Gem/GemEquipmentServices.cs index 37bd888..6dd4e53 100644 --- a/src/SecsFrame.Gem/GemEquipmentServices.cs +++ b/src/SecsFrame.Gem/GemEquipmentServices.cs @@ -193,6 +193,17 @@ public async Task SendAlarmNotificationAsync( if (notification is null) throw new ArgumentNullException(nameof(notification)); + lock (_gate) + { + ThrowIfDisposed(); + if (_alarms.TryGetValue(notification.AlarmId, out var registration) && + !registration.IsSendEnabled) + { + throw new InvalidOperationException( + $"Alarm sending is disabled for {notification.AlarmId}."); + } + } + var response = await _services.SendRequestAsync( Profile.AlarmNotification, GemOperation.AlarmNotification, @@ -244,6 +255,7 @@ private void RegisterEquipmentRoutes() _services.AddRoute(Profile.LinkEventReports, HandleLinkEventReportsAsync); _services.AddRoute(Profile.RemoteCommand, HandleRemoteCommandAsync); _services.AddRoute(Profile.ListAlarms, HandleListAlarmsAsync); + _services.AddRoute(Profile.AlarmSendControl, HandleAlarmSendControlAsync); } private ValueTask HandleOnlineAsync( @@ -456,6 +468,37 @@ links is null GemMessageCodec.EncodeAlarmDefinitions(definitions))); } + private ValueTask HandleAlarmSendControlAsync( + HsmsPrimaryContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + GemMessageCodec.RequireReplyExpected( + context, + "alarm-send control request"); + var control = GemMessageCodec.DecodeAlarmSendControl( + context.Message.RootItem, + Profile.AlarmControlCodec); + var accepted = false; + lock (_gate) + { + ThrowIfDisposed(); + if (_alarms.TryGetValue(control.AlarmId, out var registration)) + { + registration.SetSendEnabled(control.Enabled); + accepted = true; + } + } + + return new ValueTask( + GemEndpointServices.CreateSecondary( + Profile.AlarmSendControl, + GemMessageCodec.EncodeAcknowledgement( + accepted + ? Profile.AcceptedAcknowledgement + : Profile.FailedAcknowledgement))); + } + private GemValueRegistration RegisterValue( Dictionary registrations, SecsItem identifier, diff --git a/src/SecsFrame.Gem/GemHostServices.cs b/src/SecsFrame.Gem/GemHostServices.cs index b06ef0b..5a56a76 100644 --- a/src/SecsFrame.Gem/GemHostServices.cs +++ b/src/SecsFrame.Gem/GemHostServices.cs @@ -203,6 +203,27 @@ public async Task> ListAlarmsAsync( response.Message.RootItem); } + /// Enables or disables sending for one registered Equipment alarm. + public async Task SetAlarmSendEnabledAsync( + SecsItem alarmId, + bool enabled, + CancellationToken cancellationToken = default) + { + var response = await _services.SendRequestAsync( + Profile.AlarmSendControl, + GemOperation.SetAlarmSendEnabled, + GemMessageCodec.EncodeAlarmSendControl( + alarmId, + enabled, + Profile.AlarmControlCodec), + cancellationToken).ConfigureAwait(false); + _services.RequireAccepted( + GemOperation.SetAlarmSendEnabled, + GemMessageCodec.DecodeAcknowledgement( + response.Message.RootItem, + "alarm-send control reply")); + } + /// 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 9778e70..36deae7 100644 --- a/src/SecsFrame.Gem/GemMessageCodec.cs +++ b/src/SecsFrame.Gem/GemMessageCodec.cs @@ -322,6 +322,34 @@ internal static IReadOnlyList DecodeAlarmDefinitions( return Array.AsReadOnly(definitions); } + internal static SecsItem EncodeAlarmSendControl( + SecsItem alarmId, + bool enabled, + GemAlarmControlCodec codec) + { + if (alarmId is null) + throw new ArgumentNullException(nameof(alarmId)); + if (codec is null) + throw new ArgumentNullException(nameof(codec)); + + return SecsItem.List( + SecsItem.Binary(codec.Encode(enabled)), + alarmId); + } + + internal static (SecsItem AlarmId, bool Enabled) DecodeAlarmSendControl( + SecsItem? root, + GemAlarmControlCodec codec) + { + if (codec is null) + throw new ArgumentNullException(nameof(codec)); + + const string operation = "alarm-send control request"; + var fields = RequireList(root, 2, operation); + var code = RequireBinaryByte(fields[0], "alarm-send control code"); + return (fields[1], codec.Decode(code)); + } + internal static SecsItem EncodeRemoteCommand(GemRemoteCommand command) { if (command is null) diff --git a/src/SecsFrame.Gem/GemMessageProfile.cs b/src/SecsFrame.Gem/GemMessageProfile.cs index 38f7a33..19f92a7 100644 --- a/src/SecsFrame.Gem/GemMessageProfile.cs +++ b/src/SecsFrame.Gem/GemMessageProfile.cs @@ -3,8 +3,8 @@ namespace SecsFrame.Gem; /// -/// Configures the message pairs and acknowledgement values used by the -/// foundational GEM services. +/// Configures the message pairs, codecs, and acknowledgement values used by +/// the foundational GEM services. /// /// /// A profile is an interoperability configuration, not a declaration of @@ -18,6 +18,10 @@ public sealed class GemMessageProfile new(2, 41, 42); private static readonly GemMessagePair BaselineListAlarms = new(5, 5, 6); + private static readonly GemMessagePair BaselineAlarmSendControl = + new(5, 3, 4); + private static readonly GemAlarmControlCodec BaselineAlarmControlCodec = + new(enabledCode: 0x80, disabledCode: 0x00); /// /// Creates a foundational profile using the engineering-baseline alarm and @@ -139,6 +143,51 @@ public GemMessageProfile( { } + /// + /// Creates a foundational profile using the engineering-baseline + /// alarm-send control pair and codes. + /// + 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, + GemMessagePair remoteCommand, + GemMessagePair listAlarms, + byte acceptedAcknowledgement, + byte failedAcknowledgement, + GemClockCodec clockCodec) + : this( + establishCommunication, + areYouOnline, + requestOnline, + requestOffline, + readStatusVariables, + readEquipmentConstants, + getClock, + setClock, + defineReports, + linkEventReports, + collectionEvent, + alarmNotification, + remoteCommand, + listAlarms, + BaselineAlarmSendControl, + BaselineAlarmControlCodec, + acceptedAcknowledgement, + failedAcknowledgement, + clockCodec) + { + } + /// Creates an explicit foundational message profile. public GemMessageProfile( GemMessagePair establishCommunication, @@ -155,6 +204,8 @@ public GemMessageProfile( GemMessagePair alarmNotification, GemMessagePair remoteCommand, GemMessagePair listAlarms, + GemMessagePair alarmSendControl, + GemAlarmControlCodec alarmControlCodec, byte acceptedAcknowledgement, byte failedAcknowledgement, GemClockCodec clockCodec) @@ -180,7 +231,8 @@ public GemMessageProfile( collectionEvent, alarmNotification, remoteCommand, - listAlarms); + listAlarms, + alarmSendControl); EstablishCommunication = establishCommunication; AreYouOnline = areYouOnline; RequestOnline = requestOnline; @@ -195,6 +247,9 @@ public GemMessageProfile( AlarmNotification = alarmNotification; RemoteCommand = remoteCommand; ListAlarms = listAlarms; + AlarmSendControl = alarmSendControl; + AlarmControlCodec = alarmControlCodec ?? + throw new ArgumentNullException(nameof(alarmControlCodec)); AcceptedAcknowledgement = acceptedAcknowledgement; FailedAcknowledgement = failedAcknowledgement; ClockCodec = clockCodec ?? throw new ArgumentNullException(nameof(clockCodec)); @@ -242,6 +297,12 @@ public GemMessageProfile( /// Gets the alarm-list request/reply message pair. public GemMessagePair ListAlarms { get; } + /// Gets the alarm-send control request/reply message pair. + public GemMessagePair AlarmSendControl { get; } + + /// Gets the explicit alarm-send control-code mapping. + public GemAlarmControlCodec AlarmControlCodec { get; } + /// Gets the configured successful acknowledgement byte. public byte AcceptedAcknowledgement { get; } diff --git a/src/SecsFrame.Gem/GemOperation.cs b/src/SecsFrame.Gem/GemOperation.cs index 7698439..1bd20b9 100644 --- a/src/SecsFrame.Gem/GemOperation.cs +++ b/src/SecsFrame.Gem/GemOperation.cs @@ -44,4 +44,7 @@ public enum GemOperation /// List registered alarm definitions. ListAlarms, + + /// Enable or disable sending for one registered alarm. + SetAlarmSendEnabled, } diff --git a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs index 347c877..dd65b4b 100644 --- a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs +++ b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs @@ -148,6 +148,51 @@ await Assert.ThrowsAsync(() => DispatchAsync( .ConfigureAwait(true); } + [Fact] + public async Task Alarm_send_control_vectors_require_exact_configured_shape() + { + 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 alarmId = SecsItem.U2(3001); + var body = SecsItem.List(SecsItem.Binary(0x80), alarmId); + + await Assert.ThrowsAsync(() => DispatchAsync( + services, + new SecsMessage(5, 3, rootItem: body))).ConfigureAwait(true); + await Assert.ThrowsAsync(() => DispatchAsync( + services, + new SecsMessage(5, 3, true, SecsItem.List()))) + .ConfigureAwait(true); + await Assert.ThrowsAsync(() => DispatchAsync( + services, + new SecsMessage( + 5, + 3, + true, + SecsItem.List(SecsItem.U1(0x80), alarmId)))) + .ConfigureAwait(true); + await Assert.ThrowsAsync(() => DispatchAsync( + services, + new SecsMessage( + 5, + 3, + true, + SecsItem.List(SecsItem.Binary(0x80, 0x00), alarmId)))) + .ConfigureAwait(true); + await Assert.ThrowsAsync(() => DispatchAsync( + services, + new SecsMessage( + 5, + 3, + true, + SecsItem.List(SecsItem.Binary(0x7F), alarmId)))) + .ConfigureAwait(true); + } + [Fact] public async Task Dynamic_registration_is_exact_disposable_and_replaceable() { @@ -243,11 +288,13 @@ public async Task Alarm_registration_is_exact_disposable_and_replaceable() var first = services.RegisterAlarm(definition); Assert.Equal(definition.AlarmId, first.AlarmId); + Assert.True(first.IsSendEnabled); Assert.Throws(() => services.RegisterAlarm(definition)); first.Dispose(); first.Dispose(); using var replacement = services.RegisterAlarm(definition); + Assert.True(replacement.IsSendEnabled); services.Dispose(); Assert.Throws(() => services.RegisterAlarm(new GemAlarmDefinition( diff --git a/test/SecsFrame.Tests/GemFoundationTcpTests.cs b/test/SecsFrame.Tests/GemFoundationTcpTests.cs index 1cb8dca..935b86e 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 AssertAlarmCatalogAsync(context).ConfigureAwait(true); + await AssertAlarmSendControlAsync(context).ConfigureAwait(true); await AssertAlarmNotificationsAsync(context).ConfigureAwait(true); await AssertRemoteCommandsAsync(context).ConfigureAwait(true); await AssertClockAsync(context).ConfigureAwait(true); @@ -309,6 +310,66 @@ private static async Task AssertAlarmCatalogAsync(GemTcpContext context) Assert.Equal(SecsItem.U2(3002), Assert.Single(remaining).AlarmId); } + private static async Task AssertAlarmSendControlAsync(GemTcpContext context) + { + var alarmId = SecsItem.Ascii("CONTROLLED-01"); + using var alarm = context.EquipmentServices.RegisterAlarm( + new GemAlarmDefinition(0x81, alarmId, "CONTROLLED ALARM")); + + Assert.True(alarm.IsSendEnabled); + await context.HostServices.SetAlarmSendEnabledAsync( + alarmId, + enabled: false, + context.Token).ConfigureAwait(true); + Assert.False(alarm.IsSendEnabled); + Assert.Equal( + alarmId, + Assert.Single(await context.HostServices.ListAlarmsAsync( + cancellationToken: context.Token).ConfigureAwait(true)).AlarmId); + await Assert.ThrowsAsync( + () => context.EquipmentServices.SendAlarmNotificationAsync( + new GemAlarmNotification(0x81, alarmId, "CONTROLLED ALARM"), + context.Token)).ConfigureAwait(true); + + await context.HostServices.SetAlarmSendEnabledAsync( + alarmId, + enabled: true, + context.Token).ConfigureAwait(true); + Assert.True(alarm.IsSendEnabled); + var received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using (context.HostServices.RegisterAlarmNotificationHandler( + (notification, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + received.TrySetResult(notification); + return new ValueTask(true); + })) + { + await context.EquipmentServices.SendAlarmNotificationAsync( + new GemAlarmNotification(0x81, alarmId, "CONTROLLED ALARM"), + context.Token).ConfigureAwait(true); + } + + Assert.Equal(alarmId, (await received.Task.ConfigureAwait(true)).AlarmId); + var rejected = await Assert.ThrowsAsync( + () => context.HostServices.SetAlarmSendEnabledAsync( + SecsItem.U2(404), + enabled: false, + context.Token)).ConfigureAwait(true); + Assert.Equal(GemOperation.SetAlarmSendEnabled, rejected.Operation); + Assert.Equal((byte)1, rejected.Acknowledgement); + + await context.HostServices.SetAlarmSendEnabledAsync( + alarmId, + enabled: false, + context.Token).ConfigureAwait(true); + alarm.Dispose(); + using var replacement = context.EquipmentServices.RegisterAlarm( + new GemAlarmDefinition(0x81, alarmId, "CONTROLLED ALARM")); + Assert.True(replacement.IsSendEnabled); + } + private static async Task AssertRemoteCommandsAsync(GemTcpContext context) { var received = new TaskCompletionSource( diff --git a/test/SecsFrame.Tests/GemMessageProfileTests.cs b/test/SecsFrame.Tests/GemMessageProfileTests.cs index fe3060d..0c7340c 100644 --- a/test/SecsFrame.Tests/GemMessageProfileTests.cs +++ b/test/SecsFrame.Tests/GemMessageProfileTests.cs @@ -31,6 +31,9 @@ public void Engineering_baseline_is_explicit_and_round_trips_utc_clock() Assert.Equal(new GemMessagePair(5, 1, 2), profile.AlarmNotification); Assert.Equal(new GemMessagePair(2, 41, 42), profile.RemoteCommand); Assert.Equal(new GemMessagePair(5, 5, 6), profile.ListAlarms); + Assert.Equal(new GemMessagePair(5, 3, 4), profile.AlarmSendControl); + Assert.Equal((byte)0x80, profile.AlarmControlCodec.EnabledCode); + Assert.Equal((byte)0x00, profile.AlarmControlCodec.DisabledCode); Assert.Equal( new GemMessagePair(5, 1, 2), CreateLegacyCompatibleProfile(profile).AlarmNotification); @@ -43,6 +46,9 @@ public void Engineering_baseline_is_explicit_and_round_trips_utc_clock() Assert.Equal( new GemMessagePair(5, 5, 6), CreateRemoteCompatibleProfile(profile).ListAlarms); + Assert.Equal( + new GemMessagePair(5, 3, 4), + CreateProfile(profile).AlarmSendControl); Assert.Equal((byte)0, profile.AcceptedAcknowledgement); Assert.Equal((byte)1, profile.FailedAcknowledgement); Assert.Equal("2026082801101112", profile.ClockCodec.Encode(value)); @@ -70,9 +76,15 @@ public void Profile_rejects_ambiguous_routes_and_acknowledgements() Assert.Throws(() => CreateProfile( baseline, listAlarms: baseline.EstablishCommunication)); + Assert.Throws(() => CreateExplicitProfile( + baseline, + alarmSendControl: baseline.EstablishCommunication)); Assert.Throws(() => CreateProfile( baseline, failedAcknowledgement: baseline.AcceptedAcknowledgement)); + Assert.Throws(() => CreateExplicitProfile( + baseline, + useNullAlarmControlCodec: true)); Assert.Throws(() => CreateProfile( baseline, useNullClockCodec: true)); @@ -122,6 +134,8 @@ public void Public_values_reject_invalid_protocol_boundaries() Assert.Equal("DOOR OPEN", alarmDefinition.Text); Assert.Throws(() => new GemAlarmDefinition(0, SecsItem.U2(3001), "报警")); + Assert.Throws(() => + new GemAlarmControlCodec(0x80, 0x80)); } [Fact] @@ -208,6 +222,31 @@ private static GemMessageProfile CreateLegacyCompatibleProfile( baseline.FailedAcknowledgement, baseline.ClockCodec); + private static GemMessageProfile CreateExplicitProfile( + GemMessageProfile baseline, + GemMessagePair? alarmSendControl = null, + bool useNullAlarmControlCodec = false) + => 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.RemoteCommand, + baseline.ListAlarms, + alarmSendControl ?? baseline.AlarmSendControl, + useNullAlarmControlCodec ? null! : baseline.AlarmControlCodec, + baseline.AcceptedAcknowledgement, + baseline.FailedAcknowledgement, + baseline.ClockCodec); + private static GemMessageProfile CreateAlarmCompatibleProfile( GemMessageProfile baseline) => new(