diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index 18af656..0aca018 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -64,6 +64,41 @@ public sealed class AlarmDispatcherTests Assert.Equal(ts, body.TransitionTimestamp.ToDateTime()); } + /// + /// Verifies that a transition enqueued via the subtag fallback + /// (degraded: true) is marked + /// with , while the default path + /// stays on the alarmmgr parity contract. + /// + [Fact] + public void EnqueueTransition_WhenDegraded_MarksDegradedAndSubtagProvider() + { + MxAccessEventQueue queue = new MxAccessEventQueue(); + MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); + sink.Attach(new object(), SessionId); + + DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc); + sink.EnqueueTransition( + alarmFullReference: "Galaxy!TestArea.TestMachine_001.TestAlarm001", + sourceObjectReference: "TestMachine_001.TestAlarm001", + alarmTypeName: "DSC", + transitionKind: AlarmTransitionKind.Raise, + severity: 500, + originalRaiseTimestampUtc: null, + transitionTimestampUtc: ts, + operatorUser: string.Empty, + operatorComment: string.Empty, + category: "TestArea", + description: string.Empty, + degraded: true); + + Assert.Equal(1, queue.Count); + Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent)); + OnAlarmTransitionEvent body = workerEvent!.Event.OnAlarmTransition; + Assert.True(body.Degraded); + Assert.Equal(AlarmProviderMode.Subtag, body.SourceProvider); + } + /// Verifies that unchanged alarm states do not emit transitions. [Fact] public void OnTransition_WithConsecutiveUnchangedState_DoesNotEmitTransition() diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SyntheticAlarmGuidTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SyntheticAlarmGuidTests.cs new file mode 100644 index 0000000..6877bf3 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SyntheticAlarmGuidTests.cs @@ -0,0 +1,27 @@ +using System; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; + +/// +/// Unit tests for : the subtag-fallback +/// path derives a deterministic GUID from the alarm reference, so identical +/// references must collide and distinct references must not. +/// +public sealed class SyntheticAlarmGuidTests +{ + /// Verifies the same reference yields the same GUID. + [Fact] + public void SameReference_SameGuid() => + Assert.Equal(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.C")); + + /// Verifies distinct references yield distinct GUIDs. + [Fact] + public void DifferentReference_DifferentGuid() => + Assert.NotEqual(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.D")); + + /// Verifies a reference produces a non-empty GUID. + [Fact] + public void Reference_ProducesNonEmptyGuid() => + Assert.NotEqual(Guid.Empty, SyntheticAlarmGuid.ForReference("A.B.C")); +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs index 8948396..f732952 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs @@ -86,6 +86,12 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink /// The operator's comment, if any. /// The alarm category. /// The alarm description. + /// + /// when the transition was synthesized by the + /// subtag-provider fallback rather than the native alarmmgr path. + /// Defaults to so existing alarmmgr callers + /// compile unchanged and stay on the parity (alarmmgr) path. + /// internal void EnqueueTransition( string alarmFullReference, string sourceObjectReference, @@ -97,10 +103,16 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink string operatorUser, string operatorComment, string category, - string description) + string description, + bool degraded = false) { try { + // Degraded transitions come from the subtag fallback; the native + // alarmmgr (wnwrap) path stays degraded=false / ALARMMGR for parity. + AlarmProviderMode sourceProvider = degraded + ? AlarmProviderMode.Subtag + : AlarmProviderMode.Alarmmgr; MxEvent mxEvent = eventMapper.CreateOnAlarmTransition( sessionId, alarmFullReference, @@ -114,7 +126,9 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink operatorComment, category, description, - statuses: null); + statuses: null, + degraded: degraded, + sourceProvider: sourceProvider); eventQueue.Enqueue(mxEvent); } catch (Exception exception) diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs index 3f3f40b..08f844f 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs @@ -124,6 +124,15 @@ public sealed class MxAccessEventMapper /// Alarm taxonomy bucket from the Galaxy template. /// Human-readable alarm description. /// Array of MxStatusProxy values from MXAccess. + /// + /// when this transition was synthesized by the + /// subtag-provider fallback rather than the native alarmmgr path. + /// Defaults to to preserve alarmmgr parity. + /// + /// + /// The alarm provider that sourced this transition. Defaults to + /// for the native path. + /// public MxEvent CreateOnAlarmTransition( string sessionId, string alarmFullReference, @@ -137,7 +146,9 @@ public sealed class MxAccessEventMapper string operatorComment, string category, string description, - Array? statuses) + Array? statuses, + bool degraded = false, + AlarmProviderMode sourceProvider = AlarmProviderMode.Alarmmgr) { MxEvent mxEvent = CreateBaseEvent( MxEventFamily.OnAlarmTransition, @@ -159,6 +170,8 @@ public sealed class MxAccessEventMapper OperatorComment = operatorComment ?? string.Empty, Category = category ?? string.Empty, Description = description ?? string.Empty, + Degraded = degraded, + SourceProvider = sourceProvider, }; if (originalRaiseTimestampUtc is { } orts) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs index 529f089..9bcee70 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs @@ -37,4 +37,12 @@ public sealed class MxAlarmSnapshotRecord public string OperatorName { get; set; } = string.Empty; /// Gets or sets the alarm comment. public string AlarmComment { get; set; } = string.Empty; + /// + /// Gets or sets a value indicating whether this record was synthesized + /// by the subtag-provider fallback rather than emitted by the native + /// alarmmgr (wnwrap) path. Default preserves + /// parity for the alarmmgr path; the subtag fallback sets it to + /// . + /// + public bool Degraded { get; set; } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs new file mode 100644 index 0000000..729428d --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs @@ -0,0 +1,42 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Derives a deterministic synthetic from an alarm +/// reference for the subtag-provider fallback path, which has no native +/// MxAccess alarm GUID. Hashing the reference yields a stable identity so +/// repeated transitions for the same alarm reference correlate downstream +/// (acknowledge, snapshot, OPC UA mapping) without an alarmmgr-supplied GUID. +/// +public static class SyntheticAlarmGuid +{ + /// + /// Produces a stable for the given alarm reference. + /// The same reference always maps to the same GUID; distinct references + /// map to distinct GUIDs with overwhelming probability. + /// + /// + /// The fully-qualified alarm reference (for example + /// "Galaxy!Area.Tag.HiHi"). Treated as UTF-8 bytes. + /// + /// A deterministic, non-empty GUID derived from the reference. + /// + /// Thrown when is . + /// + public static Guid ForReference(string reference) + { + if (reference is null) throw new ArgumentNullException(nameof(reference)); + + byte[] bytes = Encoding.UTF8.GetBytes(reference); + + // MD5 is used purely for a stable, non-cryptographic identity mapping + // (reference -> 16-byte GUID), never for security. Its 128-bit output + // fits a GUID exactly, which is why it is preferred here. + using MD5 md5 = MD5.Create(); + byte[] hash = md5.ComputeHash(bytes); + return new Guid(hash); + } +}