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(