diff --git a/CHANGELOG.md b/CHANGELOG.md
index e23bcdb..58e5a77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -88,6 +88,10 @@
无处理器或应用拒绝返回显式失败应答。
- 报告配置严格拒绝畸形 Item、重复标识、未知变量和未知报告,成功配置在
应答写出前原子可见,替换报告集会移除引用已删除报告的旧事件链接。
+- 增加可替换的报警通知消息对、不可变动态报警模型、Equipment 发送 API
+ 和可释放的 Host 单处理器注册;报警代码按原始字节透传,不推断位语义。
+- 报警通知严格校验 W-Bit、三字段正文、单字节 Binary 代码、ASCII 文本、
+ Secondary 和应答值,并增加协议向量及真实 TCP 接受/拒绝测试。
- 增加定义、链接、S6F11 动态 Item 向量和真实 TCP 垂直测试,覆盖嵌套值、
空报告、配置拒绝、触发前快照与 Host 拒绝。
diff --git a/README.md b/README.md
index bf727bf..6f63a58 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 7da2ce4..ae1792b 100644
--- a/docs/GEM-FOUNDATION.md
+++ b/docs/GEM-FOUNDATION.md
@@ -5,7 +5,7 @@
SecsFrame.Gem 是只依赖 SecsFrame 的独立程序集。
当前切片提供 Host/Equipment 两侧的通讯建立、在线/离线、状态变量读取、
设备常量读取、应用托管时钟、报告定义、事件链接和 Collection Event,
-不把 GEM 状态或消息目录放入 HSMS 状态机。
+以及最小报警通知链路,不把 GEM 状态或消息目录放入 HSMS 状态机。
GemHostServices 依赖 SecsHost;
GemEquipmentServices 依赖 SecsEquipment。服务不拥有
@@ -30,6 +30,7 @@
| 报告定义 | S2F33 | S2F34 |
| 事件链接 | S2F35 | S2F36 |
| Collection Event | S6F11 | S6F12 |
+| 报警通知 | S5F1 | S5F2 |
这张表描述仓库自己的默认配置,不是复制的 SEMI 规范表,也不表示已经
核对 E30 的必选能力、状态条件、数据项定义、报告生命周期或应答枚举。
@@ -98,6 +99,20 @@ Host 使用 RegisterCollectionEventHandler 注册单一可释放处
应答;没有处理器时明确失败。释放注册只影响后续事件,已经取得处理器快照
的分派会完成。应用处理器异常和取消继续传播给事件循环,不被库隐藏。
+## 报警通知
+
+Equipment 使用 GemAlarmNotification 提供一个精确 Binary 代码
+字节、动态 SecsItem 报警标识和七位 ASCII 文本,再通过
+SendAlarmNotificationAsync 发送。库原样保留代码字节,不解释
+其中的 set/clear、类别或厂商位;这些语义在取得授权 E30 副本并完成设备
+互操作核对前由应用负责。
+
+Host 使用 RegisterAlarmNotificationHandler 注册单一可释放
+处理器。处理器返回 true 发送成功应答,返回
+false 或没有处理器时发送失败应答。注册快照、释放、异常和
+取消语义与 Collection Event 处理器一致。本切片不包含报警目录查询、
+启停/屏蔽、历史、持久化或基于 GEM 状态的发送门控。
+
## 严格失败边界
当前实现严格要求:
@@ -110,24 +125,27 @@ Host 使用 RegisterCollectionEventHandler 注册单一可释放处
- 时钟字符串由显式 codec 完整解析;
- 报告/链接请求是二元素 List,Collection Event 是三元素 List,各嵌套项
的标识和值列表形状必须完整且标识不能产生歧义;
+- 报警通知是三元素 List,代码必须是恰好一个 Binary 字节,文本必须为
+ ASCII,报警标识保持为任意非 null 动态 Item;
- 报告配置请求和 Collection Event 必须设置 W-Bit,Secondary 的消息对和
- 应答字节必须与 profile 匹配。
+ 应答字节必须与 profile 匹配;报警通知遵循相同要求。
畸形输入和提供器失败会作为异常返回应用事件循环;语义有效但引用未知变量
或报告的配置使用失败应答拒绝。本切片尚未实现自动 S9Fx、错误 Secondary、
-通讯建立策略拒绝、状态切换策略、报警、远程命令或完整 GEM 错误恢复,
-这些行为不能从当前 API 推断为标准合规。
+通讯建立策略拒绝、状态切换策略、报警目录/启停/历史、远程命令或完整
+GEM 错误恢复,这些行为不能从当前 API 推断为标准合规。
## 标准与验证边界
公开 SEMI 目录当前标识 GEM 基线为 E30-0526。仍需使用团队合法获得的
副本核对消息条件、COMMACK/ONLACK/OFLACK/TIACK 语义、MDLN 条件结构、
空 SVID/ECID 列表行为、报告定义与链接生命周期、Collection Event 数据
-结构和应答、时间格式选择、状态转换和错误响应。不得把标准正文、消息表、
-状态图或 Schema 提交仓库。完整版本和版权边界见
+结构和应答、报警通知结构与代码位语义、时间格式选择、状态转换和错误
+响应。不得把标准正文、消息表、状态图或 Schema 提交仓库。完整版本和版权
+边界见
[STANDARDS.md](STANDARDS.md)。
当前证据包括三目标框架的严格输入测试,以及 Host Active / Equipment
Passive 真实 TCP 下的双向通讯建立、上下线、异构动态标识、报告定义、
-事件链接、Collection Event 嵌套值与空报告、配置/事件拒绝、时钟读写和
-Linktest。它们是独立工程验证,不是 SEMI 一致性认证。
+事件链接、Collection Event 嵌套值与空报告、报警通知接受/拒绝、配置/事件
+拒绝、时钟读写和 Linktest。它们是独立工程验证,不是 SEMI 一致性认证。
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index bb5173c..435c850 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -38,12 +38,14 @@
- [x] 通讯建立、在线/离线状态、动态变量读取、设备常量和应用托管时钟。
- [x] 面向 Host 与 Equipment 的独立基础能力 API 和真实 TCP 垂直测试。
- [x] 报告定义、事件链接和 Collection Event。
-- [ ] 报警、远程命令和更完整的 GEM 状态/错误恢复。
+- [x] 报警通知的动态标识、代码透传、Host 处理器和 Equipment 发送链路。
+- [ ] 报警目录/启停/历史、远程命令和更完整的 GEM 状态/错误恢复。
- [ ] 使用授权 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 c94082d..274c7ac 100644
--- a/docs/SECS-ENDPOINT-ROLES.md
+++ b/docs/SECS-ENDPOINT-ROLES.md
@@ -62,6 +62,6 @@ 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/GemAlarmNotification.cs b/src/SecsFrame.Gem/GemAlarmNotification.cs
new file mode 100644
index 0000000..caea946
--- /dev/null
+++ b/src/SecsFrame.Gem/GemAlarmNotification.cs
@@ -0,0 +1,26 @@
+namespace SecsFrame.Gem;
+
+/// Contains one decoded alarm notification.
+public sealed class GemAlarmNotification
+{
+ /// Creates an alarm notification value.
+ public GemAlarmNotification(byte code, SecsItem alarmId, string text)
+ {
+ Code = code;
+ AlarmId = alarmId ?? throw new ArgumentNullException(nameof(alarmId));
+ Text = text ?? throw new ArgumentNullException(nameof(text));
+
+ _ = SecsItem.Ascii(text);
+ }
+
+ ///
+ /// Gets the exact alarm code byte without interpreting its bit fields.
+ ///
+ public byte Code { get; }
+
+ /// Gets the exact dynamic alarm identifier.
+ public SecsItem AlarmId { get; }
+
+ /// Gets the seven-bit ASCII alarm text.
+ public string Text { get; }
+}
diff --git a/src/SecsFrame.Gem/GemAlarmNotificationHandler.cs b/src/SecsFrame.Gem/GemAlarmNotificationHandler.cs
new file mode 100644
index 0000000..0def427
--- /dev/null
+++ b/src/SecsFrame.Gem/GemAlarmNotificationHandler.cs
@@ -0,0 +1,7 @@
+namespace SecsFrame.Gem;
+
+/// Handles a decoded alarm notification and chooses its acknowledgement.
+/// to accept the notification; otherwise false.
+public delegate ValueTask GemAlarmNotificationHandler(
+ GemAlarmNotification notification,
+ CancellationToken cancellationToken);
diff --git a/src/SecsFrame.Gem/GemAlarmNotificationRegistration.cs b/src/SecsFrame.Gem/GemAlarmNotificationRegistration.cs
new file mode 100644
index 0000000..5e8dcb2
--- /dev/null
+++ b/src/SecsFrame.Gem/GemAlarmNotificationRegistration.cs
@@ -0,0 +1,21 @@
+namespace SecsFrame.Gem;
+
+/// Owns one Host alarm-notification handler registration.
+public sealed class GemAlarmNotificationRegistration : IDisposable
+{
+ private Action? _unregister;
+
+ internal GemAlarmNotificationRegistration(
+ GemAlarmNotificationHandler handler,
+ Action unregister)
+ {
+ Handler = handler;
+ _unregister = unregister;
+ }
+
+ internal GemAlarmNotificationHandler 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/GemEquipmentServices.cs b/src/SecsFrame.Gem/GemEquipmentServices.cs
index 59b9a10..e07f1d3 100644
--- a/src/SecsFrame.Gem/GemEquipmentServices.cs
+++ b/src/SecsFrame.Gem/GemEquipmentServices.cs
@@ -134,6 +134,26 @@ public async Task SendCollectionEventAsync(
"Collection Event reply"));
}
+ /// Sends one alarm notification to the Host.
+ public async Task SendAlarmNotificationAsync(
+ GemAlarmNotification notification,
+ CancellationToken cancellationToken = default)
+ {
+ if (notification is null)
+ throw new ArgumentNullException(nameof(notification));
+
+ var response = await _services.SendRequestAsync(
+ Profile.AlarmNotification,
+ GemOperation.AlarmNotification,
+ GemMessageCodec.EncodeAlarmNotification(notification),
+ cancellationToken).ConfigureAwait(false);
+ _services.RequireAccepted(
+ GemOperation.AlarmNotification,
+ GemMessageCodec.DecodeAcknowledgement(
+ response.Message.RootItem,
+ "alarm-notification reply"));
+ }
+
///
/// Observes session state and dispatches a registered GEM or application route.
///
diff --git a/src/SecsFrame.Gem/GemHostServices.cs b/src/SecsFrame.Gem/GemHostServices.cs
index 87b3176..1e02dc4 100644
--- a/src/SecsFrame.Gem/GemHostServices.cs
+++ b/src/SecsFrame.Gem/GemHostServices.cs
@@ -10,6 +10,7 @@ public sealed class GemHostServices : IDisposable
private readonly object _gate = new();
#endif
private GemCollectionEventRegistration? _collectionEventHandler;
+ private GemAlarmNotificationRegistration? _alarmNotificationHandler;
private int _disposed;
/// Creates Host GEM services without taking ownership of the endpoint.
@@ -30,6 +31,9 @@ public GemHostServices(
_services.AddRoute(
_services.Profile.CollectionEvent,
HandleCollectionEventAsync);
+ _services.AddRoute(
+ _services.Profile.AlarmNotification,
+ HandleAlarmNotificationAsync);
}
catch
{
@@ -191,6 +195,30 @@ public GemCollectionEventRegistration RegisterCollectionEventHandler(
}
}
+ /// Registers the single application alarm-notification handler.
+ public GemAlarmNotificationRegistration RegisterAlarmNotificationHandler(
+ GemAlarmNotificationHandler handler)
+ {
+ if (handler is null)
+ throw new ArgumentNullException(nameof(handler));
+
+ lock (_gate)
+ {
+ ThrowIfDisposed();
+ if (_alarmNotificationHandler is not null)
+ {
+ throw new InvalidOperationException(
+ "An alarm-notification handler is already registered.");
+ }
+
+ var registration = new GemAlarmNotificationRegistration(
+ handler,
+ UnregisterAlarmNotificationHandler);
+ _alarmNotificationHandler = registration;
+ return registration;
+ }
+ }
+
///
/// Observes session state and dispatches a registered GEM or application route.
///
@@ -206,7 +234,10 @@ public void Dispose()
return;
lock (_gate)
+ {
_collectionEventHandler = null;
+ _alarmNotificationHandler = null;
+ }
_services.Dispose();
}
@@ -258,6 +289,38 @@ private void UnregisterCollectionEventHandler(
}
}
+ private async ValueTask HandleAlarmNotificationAsync(
+ HsmsPrimaryContext context,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ GemMessageCodec.RequireReplyExpected(context, "alarm notification");
+ var notification = GemMessageCodec.DecodeAlarmNotification(
+ context.Message.RootItem);
+ GemAlarmNotificationHandler? handler;
+ lock (_gate)
+ handler = _alarmNotificationHandler?.Handler;
+
+ var accepted = handler is not null &&
+ await handler(notification, cancellationToken).ConfigureAwait(false);
+ return GemEndpointServices.CreateSecondary(
+ Profile.AlarmNotification,
+ GemMessageCodec.EncodeAcknowledgement(
+ accepted
+ ? Profile.AcceptedAcknowledgement
+ : Profile.FailedAcknowledgement));
+ }
+
+ private void UnregisterAlarmNotificationHandler(
+ GemAlarmNotificationRegistration registration)
+ {
+ lock (_gate)
+ {
+ if (ReferenceEquals(_alarmNotificationHandler, registration))
+ _alarmNotificationHandler = null;
+ }
+ }
+
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
diff --git a/src/SecsFrame.Gem/GemMessageCodec.cs b/src/SecsFrame.Gem/GemMessageCodec.cs
index ea0d92b..c54db9c 100644
--- a/src/SecsFrame.Gem/GemMessageCodec.cs
+++ b/src/SecsFrame.Gem/GemMessageCodec.cs
@@ -43,14 +43,17 @@ internal static SecsItem EncodeAcknowledgement(byte value)
=> SecsItem.Binary(value);
internal static byte DecodeAcknowledgement(SecsItem? root, string operation)
+ => RequireBinaryByte(root, $"{operation} acknowledgement");
+
+ private static byte RequireBinaryByte(SecsItem? item, string field)
{
- if (root is null || root.Format != SecsItemFormat.Binary || root.Count != 1)
+ if (item is null || item.Format != SecsItemFormat.Binary || item.Count != 1)
{
throw new GemProtocolException(
- $"The {operation} body must be one Binary acknowledgement byte.");
+ $"The {field} must be one Binary byte.");
}
- return root.GetValues()[0];
+ return item.GetValues()[0];
}
internal static SecsItem EncodeIdentifiers(IEnumerable identifiers)
@@ -223,6 +226,28 @@ internal static GemCollectionEvent DecodeCollectionEvent(SecsItem? root)
reports);
}
+ internal static SecsItem EncodeAlarmNotification(
+ GemAlarmNotification notification)
+ {
+ if (notification is null)
+ throw new ArgumentNullException(nameof(notification));
+
+ return SecsItem.List(
+ SecsItem.Binary(notification.Code),
+ notification.AlarmId,
+ SecsItem.Ascii(notification.Text));
+ }
+
+ internal static GemAlarmNotification DecodeAlarmNotification(SecsItem? root)
+ {
+ const string operation = "alarm notification";
+ var body = RequireList(root, 3, operation);
+ return new GemAlarmNotification(
+ RequireBinaryByte(body[0], "alarm-notification code"),
+ body[1],
+ RequireAscii(body[2], operation, "text"));
+ }
+
internal static SecsItem EncodeClock(DateTimeOffset value, GemClockCodec codec)
=> SecsItem.Ascii(codec.Encode(value));
@@ -294,12 +319,15 @@ private static IReadOnlyList RequireList(
return root.Items;
}
- private static string RequireAscii(SecsItem item, string operation)
+ private static string RequireAscii(
+ SecsItem item,
+ string operation,
+ string field = "identity value")
{
if (item.Format != SecsItemFormat.Ascii)
{
throw new GemProtocolException(
- $"The {operation} identity values must be ASCII.");
+ $"The {operation} {field} must be ASCII.");
}
return item.GetString();
diff --git a/src/SecsFrame.Gem/GemMessageProfile.cs b/src/SecsFrame.Gem/GemMessageProfile.cs
index 37927c0..8d73b31 100644
--- a/src/SecsFrame.Gem/GemMessageProfile.cs
+++ b/src/SecsFrame.Gem/GemMessageProfile.cs
@@ -12,6 +12,46 @@ namespace SecsFrame.Gem;
///
public sealed class GemMessageProfile
{
+ private static readonly GemMessagePair BaselineAlarmNotification =
+ new(5, 1, 2);
+
+ ///
+ /// Creates a foundational profile using the engineering-baseline alarm 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,
+ byte acceptedAcknowledgement,
+ byte failedAcknowledgement,
+ GemClockCodec clockCodec)
+ : this(
+ establishCommunication,
+ areYouOnline,
+ requestOnline,
+ requestOffline,
+ readStatusVariables,
+ readEquipmentConstants,
+ getClock,
+ setClock,
+ defineReports,
+ linkEventReports,
+ collectionEvent,
+ BaselineAlarmNotification,
+ acceptedAcknowledgement,
+ failedAcknowledgement,
+ clockCodec)
+ {
+ }
+
/// Creates an explicit foundational message profile.
public GemMessageProfile(
GemMessagePair establishCommunication,
@@ -25,6 +65,7 @@ public GemMessageProfile(
GemMessagePair defineReports,
GemMessagePair linkEventReports,
GemMessagePair collectionEvent,
+ GemMessagePair alarmNotification,
byte acceptedAcknowledgement,
byte failedAcknowledgement,
GemClockCodec clockCodec)
@@ -47,7 +88,8 @@ public GemMessageProfile(
setClock,
defineReports,
linkEventReports,
- collectionEvent);
+ collectionEvent,
+ alarmNotification);
EstablishCommunication = establishCommunication;
AreYouOnline = areYouOnline;
RequestOnline = requestOnline;
@@ -59,6 +101,7 @@ public GemMessageProfile(
DefineReports = defineReports;
LinkEventReports = linkEventReports;
CollectionEvent = collectionEvent;
+ AlarmNotification = alarmNotification;
AcceptedAcknowledgement = acceptedAcknowledgement;
FailedAcknowledgement = failedAcknowledgement;
ClockCodec = clockCodec ?? throw new ArgumentNullException(nameof(clockCodec));
@@ -97,6 +140,9 @@ public GemMessageProfile(
/// Gets the Collection Event message pair.
public GemMessagePair CollectionEvent { get; }
+ /// Gets the alarm-notification message pair.
+ public GemMessagePair AlarmNotification { get; }
+
/// Gets the configured successful acknowledgement byte.
public byte AcceptedAcknowledgement { get; }
@@ -122,6 +168,7 @@ public static GemMessageProfile CreateEngineeringBaseline()
new GemMessagePair(2, 33, 34),
new GemMessagePair(2, 35, 36),
new GemMessagePair(6, 11, 12),
+ BaselineAlarmNotification,
acceptedAcknowledgement: 0,
failedAcknowledgement: 1,
new GemClockCodec(EncodeBaselineTime, DecodeBaselineTime));
diff --git a/src/SecsFrame.Gem/GemOperation.cs b/src/SecsFrame.Gem/GemOperation.cs
index 24cb70a..b9e43f6 100644
--- a/src/SecsFrame.Gem/GemOperation.cs
+++ b/src/SecsFrame.Gem/GemOperation.cs
@@ -35,4 +35,7 @@ public enum GemOperation
/// Send or handle a Collection Event.
CollectionEvent,
+
+ /// Send or handle an alarm notification.
+ AlarmNotification,
}
diff --git a/test/SecsFrame.Tests/GemFoundationTcpTests.cs b/test/SecsFrame.Tests/GemFoundationTcpTests.cs
index b1b774a..e63157f 100644
--- a/test/SecsFrame.Tests/GemFoundationTcpTests.cs
+++ b/test/SecsFrame.Tests/GemFoundationTcpTests.cs
@@ -14,6 +14,7 @@ public async Task Host_and_equipment_complete_foundational_dialogues_over_tcp()
await AssertCommunicationAndOnlineAsync(context).ConfigureAwait(true);
await AssertDynamicDataAsync(context).ConfigureAwait(true);
await AssertCollectionEventsAsync(context).ConfigureAwait(true);
+ await AssertAlarmNotificationsAsync(context).ConfigureAwait(true);
await AssertClockAsync(context).ConfigureAwait(true);
Assert.Equal(
context.HostServices.Identity,
@@ -238,6 +239,42 @@ await context.HostServices.SetClockAsync(replacementTime, context.Token)
Assert.Equal(replacementTime.ToUniversalTime(), context.Clock.Value);
}
+ private static async Task AssertAlarmNotificationsAsync(GemTcpContext context)
+ {
+ 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,
+ SecsItem.Ascii("DOOR-01"),
+ "DOOR OPEN"),
+ context.Token).ConfigureAwait(true);
+ }
+
+ var notification = await received.Task.ConfigureAwait(true);
+ Assert.Equal((byte)0x81, notification.Code);
+ Assert.Equal(SecsItem.Ascii("DOOR-01"), notification.AlarmId);
+ Assert.Equal("DOOR OPEN", notification.Text);
+
+ var rejected = await Assert.ThrowsAsync(
+ () => context.EquipmentServices.SendAlarmNotificationAsync(
+ new GemAlarmNotification(
+ 0x00,
+ SecsItem.U2(3001),
+ "DOOR CLOSED"),
+ context.Token)).ConfigureAwait(true);
+ Assert.Equal(GemOperation.AlarmNotification, rejected.Operation);
+ Assert.Equal((byte)1, rejected.Acknowledgement);
+ }
+
private static async Task PumpAsync(
SecsEndpoint endpoint,
Func> dispatch,
diff --git a/test/SecsFrame.Tests/GemHostServicesTests.cs b/test/SecsFrame.Tests/GemHostServicesTests.cs
index 4008acc..d0ab3a9 100644
--- a/test/SecsFrame.Tests/GemHostServicesTests.cs
+++ b/test/SecsFrame.Tests/GemHostServicesTests.cs
@@ -57,6 +57,77 @@ public async Task Collection_event_handler_is_exact_disposable_and_replaceable()
services.RegisterCollectionEventHandler(handler));
}
+ [Fact]
+ public async Task Alarm_notification_vectors_require_w_bit_and_strict_fields()
+ {
+ await using var endpoint = new SecsHost(CreateOptions());
+ using var services = new GemHostServices(
+ endpoint,
+ new GemIdentity("HOST-01", "1.0"));
+ var validBody = SecsItem.List(
+ SecsItem.Binary(0x81),
+ SecsItem.U2(3001),
+ SecsItem.Ascii("DOOR OPEN"));
+
+ await Assert.ThrowsAsync(() => DispatchAsync(
+ services,
+ new SecsMessage(5, 1, rootItem: validBody))).ConfigureAwait(true);
+ await Assert.ThrowsAsync(() => DispatchAsync(
+ services,
+ new SecsMessage(5, 1, true, SecsItem.List()))).ConfigureAwait(true);
+ await Assert.ThrowsAsync(() => DispatchAsync(
+ services,
+ new SecsMessage(
+ 5,
+ 1,
+ true,
+ SecsItem.List(
+ SecsItem.U1(0x81),
+ SecsItem.U2(3001),
+ SecsItem.Ascii("DOOR OPEN"))))).ConfigureAwait(true);
+ await Assert.ThrowsAsync(() => DispatchAsync(
+ services,
+ new SecsMessage(
+ 5,
+ 1,
+ true,
+ SecsItem.List(
+ SecsItem.Binary(0x81, 0x01),
+ SecsItem.U2(3001),
+ SecsItem.Ascii("DOOR OPEN"))))).ConfigureAwait(true);
+ await Assert.ThrowsAsync(() => DispatchAsync(
+ services,
+ new SecsMessage(
+ 5,
+ 1,
+ true,
+ SecsItem.List(
+ SecsItem.Binary(0x81),
+ SecsItem.U2(3001),
+ SecsItem.U4(42))))).ConfigureAwait(true);
+ }
+
+ [Fact]
+ public async Task Alarm_handler_is_exact_disposable_and_replaceable()
+ {
+ await using var endpoint = new SecsHost(CreateOptions());
+ using var services = new GemHostServices(
+ endpoint,
+ new GemIdentity("HOST-01", "1.0"));
+ GemAlarmNotificationHandler handler = static (_, _) =>
+ new ValueTask(true);
+
+ var first = services.RegisterAlarmNotificationHandler(handler);
+ Assert.Throws(() =>
+ services.RegisterAlarmNotificationHandler(handler));
+ first.Dispose();
+ first.Dispose();
+ using var replacement = services.RegisterAlarmNotificationHandler(handler);
+ services.Dispose();
+ Assert.Throws(() =>
+ services.RegisterAlarmNotificationHandler(handler));
+ }
+
private static Task DispatchAsync(
GemHostServices services,
SecsMessage message)
diff --git a/test/SecsFrame.Tests/GemMessageProfileTests.cs b/test/SecsFrame.Tests/GemMessageProfileTests.cs
index e56ede3..49243e3 100644
--- a/test/SecsFrame.Tests/GemMessageProfileTests.cs
+++ b/test/SecsFrame.Tests/GemMessageProfileTests.cs
@@ -28,6 +28,10 @@ public void Engineering_baseline_is_explicit_and_round_trips_utc_clock()
Assert.Equal(new GemMessagePair(2, 33, 34), profile.DefineReports);
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(5, 1, 2),
+ CreateLegacyCompatibleProfile(profile).AlarmNotification);
Assert.Equal((byte)0, profile.AcceptedAcknowledgement);
Assert.Equal((byte)1, profile.FailedAcknowledgement);
Assert.Equal("2026082801101112", profile.ClockCodec.Encode(value));
@@ -46,6 +50,9 @@ public void Profile_rejects_ambiguous_routes_and_acknowledgements()
Assert.Throws(() => CreateProfile(
baseline,
collectionEvent: baseline.EstablishCommunication));
+ Assert.Throws(() => CreateProfile(
+ baseline,
+ alarmNotification: baseline.EstablishCommunication));
Assert.Throws(() => CreateProfile(
baseline,
failedAcknowledgement: baseline.AcceptedAcknowledgement));
@@ -78,12 +85,24 @@ public void Public_values_reject_invalid_protocol_boundaries()
new GemEventReportLink(
SecsItem.U4(1),
new[] { SecsItem.U4(2), SecsItem.U4(2) }));
+ var alarm = new GemAlarmNotification(
+ 0x81,
+ SecsItem.U2(3001),
+ "DOOR OPEN");
+ Assert.Equal((byte)0x81, alarm.Code);
+ Assert.Equal(SecsItem.U2(3001), alarm.AlarmId);
+ Assert.Equal("DOOR OPEN", alarm.Text);
+ Assert.Throws(() =>
+ new GemAlarmNotification(0, null!, string.Empty));
+ Assert.Throws(() =>
+ new GemAlarmNotification(0, SecsItem.U2(3001), "报警"));
}
private static GemMessageProfile CreateProfile(
GemMessageProfile baseline,
GemMessagePair? areYouOnline = null,
GemMessagePair? collectionEvent = null,
+ GemMessagePair? alarmNotification = null,
byte? failedAcknowledgement = null,
GemClockCodec? clockCodec = default,
bool useNullClockCodec = false)
@@ -99,7 +118,26 @@ private static GemMessageProfile CreateProfile(
baseline.DefineReports,
baseline.LinkEventReports,
collectionEvent ?? baseline.CollectionEvent,
+ alarmNotification ?? baseline.AlarmNotification,
baseline.AcceptedAcknowledgement,
failedAcknowledgement ?? baseline.FailedAcknowledgement,
useNullClockCodec ? null! : clockCodec ?? baseline.ClockCodec);
+
+ private static GemMessageProfile CreateLegacyCompatibleProfile(
+ 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.AcceptedAcknowledgement,
+ baseline.FailedAcknowledgement,
+ baseline.ClockCodec);
}