From 5a83ab47655a8c277f9b93f316163a8606f9b2c5 Mon Sep 17 00:00:00 2001 From: Marci Date: Sat, 29 Aug 2026 01:43:50 +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=E9=80=9A=E7=9F=A5=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++ README.md | 2 +- docs/GEM-FOUNDATION.md | 34 ++++++--- docs/ROADMAP.md | 10 +-- docs/SECS-ENDPOINT-ROLES.md | 4 +- src/SecsFrame.Gem/GemAlarmNotification.cs | 26 +++++++ .../GemAlarmNotificationHandler.cs | 7 ++ .../GemAlarmNotificationRegistration.cs | 21 ++++++ src/SecsFrame.Gem/GemEquipmentServices.cs | 20 ++++++ src/SecsFrame.Gem/GemHostServices.cs | 63 ++++++++++++++++ src/SecsFrame.Gem/GemMessageCodec.cs | 38 ++++++++-- src/SecsFrame.Gem/GemMessageProfile.cs | 49 ++++++++++++- src/SecsFrame.Gem/GemOperation.cs | 3 + test/SecsFrame.Tests/GemFoundationTcpTests.cs | 37 ++++++++++ test/SecsFrame.Tests/GemHostServicesTests.cs | 71 +++++++++++++++++++ .../SecsFrame.Tests/GemMessageProfileTests.cs | 38 ++++++++++ 16 files changed, 406 insertions(+), 21 deletions(-) create mode 100644 src/SecsFrame.Gem/GemAlarmNotification.cs create mode 100644 src/SecsFrame.Gem/GemAlarmNotificationHandler.cs create mode 100644 src/SecsFrame.Gem/GemAlarmNotificationRegistration.cs 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); }