diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs new file mode 100644 index 0000000..f961e9c --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using Xunit; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; + +/// +/// Unit tests for : prove that the subtag +/// fallback advises the observable subtags, synthesizes degraded +/// transitions with stable synthetic GUIDs, routes acknowledgments to the +/// ack-comment subtag, and stamps snapshots. A +/// stands in for the live MXAccess subtag source so this needs no AVEVA +/// install. +/// +public sealed class SubtagAlarmConsumerTests +{ + private const string Reference = "Galaxy!TestArea.Tank01.Level.HiHi"; + private const string ActiveSubtag = "Tank01.Level.HiHi.InAlarm"; + private const string AckedSubtag = "Tank01.Level.HiHi.Acked"; + private const string AckCommentSubtag = "Tank01.Level.HiHi.AckComment"; + private const string PrioritySubtag = "Tank01.Level.HiHi.Priority"; + + private static AlarmSubtagTarget BuildTarget() + { + return new AlarmSubtagTarget + { + AlarmFullReference = Reference, + SourceObjectReference = "Tank01", + ActiveSubtag = ActiveSubtag, + AckedSubtag = AckedSubtag, + AckCommentSubtag = AckCommentSubtag, + PrioritySubtag = PrioritySubtag, + }; + } + + private static SubtagAlarmConsumer BuildConsumer(FakeSource source) + { + return new SubtagAlarmConsumer(source, new[] { BuildTarget() }); + } + + /// Verifies Subscribe advises the active, acked, and priority subtags but not the ack-comment subtag. + [Fact] + public void Subscribe_AdvisesActiveAndAckedSubtags() + { + FakeSource source = new FakeSource(); + using SubtagAlarmConsumer consumer = BuildConsumer(source); + + consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); + + Assert.Contains(ActiveSubtag, source.Advised); + Assert.Contains(AckedSubtag, source.Advised); + Assert.Contains(PrioritySubtag, source.Advised); + Assert.DoesNotContain(AckCommentSubtag, source.Advised); + } + + /// Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition. + [Fact] + public void ValueChange_RaisesDegradedSynthesizedTransition() + { + FakeSource source = new FakeSource(); + using SubtagAlarmConsumer consumer = BuildConsumer(source); + consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); + + MxAlarmTransitionEvent? emitted = null; + consumer.AlarmTransitionEmitted += (_, e) => emitted = e; + + source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc)); + + Assert.NotNull(emitted); + Assert.Equal(MxAlarmStateKind.UnackAlm, emitted!.Record.State); + Assert.True(emitted.Record.Degraded); + Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid); + } + + /// Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success. + [Fact] + public void AcknowledgeByName_WritesCommentToAckCommentSubtag() + { + FakeSource source = new FakeSource(); + using SubtagAlarmConsumer consumer = BuildConsumer(source); + consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); + + int rc = consumer.AcknowledgeByName( + alarmName: "Tank01.Level.HiHi", + providerName: "Galaxy", + groupName: "TestArea", + ackComment: "operator ack", + ackOperatorName: "alice", + ackOperatorNode: "WS01", + ackOperatorDomain: "CORP", + ackOperatorFullName: "Alice Smith"); + + Assert.Equal(0, rc); + Assert.NotNull(source.LastWrite); + Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address); + Assert.Equal("operator ack", source.LastWrite!.Value.Value); + } + + /// Verifies AcknowledgeByName returns non-zero when no target matches the supplied name. + [Fact] + public void AcknowledgeByName_UnknownAlarm_ReturnsNonZero() + { + FakeSource source = new FakeSource(); + using SubtagAlarmConsumer consumer = BuildConsumer(source); + consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); + + int rc = consumer.AcknowledgeByName( + alarmName: "DoesNotExist.NoSuchAlarm", + providerName: "Galaxy", + groupName: "TestArea", + ackComment: "operator ack", + ackOperatorName: "alice", + ackOperatorNode: "WS01", + ackOperatorDomain: "CORP", + ackOperatorFullName: "Alice Smith"); + + Assert.NotEqual(0, rc); + Assert.Null(source.LastWrite); + } + + /// Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID. + [Fact] + public void SnapshotActiveAlarms_StampsDegradedAndGuid() + { + FakeSource source = new FakeSource(); + using SubtagAlarmConsumer consumer = BuildConsumer(source); + consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); + + source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc)); + + IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(); + + Assert.Single(snapshot); + Assert.True(snapshot[0].Degraded); + Assert.NotEqual(Guid.Empty, snapshot[0].AlarmGuid); + } + + /// Verifies the synthetic GUID on the emitted transition equals the GUID in the snapshot for the same alarm. + [Fact] + public void SameReference_SyntheticGuidStableAcrossTransitionAndSnapshot() + { + FakeSource source = new FakeSource(); + using SubtagAlarmConsumer consumer = BuildConsumer(source); + consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); + + MxAlarmTransitionEvent? emitted = null; + consumer.AlarmTransitionEmitted += (_, e) => emitted = e; + + source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc)); + + IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(); + + Assert.NotNull(emitted); + Assert.Single(snapshot); + Assert.Equal(emitted!.Record.AlarmGuid, snapshot[0].AlarmGuid); + Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid); + } + + private sealed class FakeSource : ISubtagAlarmSource + { + /// Raised when an advised subtag reports a new value. + public event EventHandler? ValueChanged; + + /// Gets the subtag addresses passed to . + public List Advised { get; } = new List(); + + /// Gets the most recent (address, value) pair passed to . + public (string Address, object? Value)? LastWrite { get; private set; } + + /// Records the advised subtag addresses. + /// The subtag references to advise. + public void Advise(IReadOnlyCollection itemAddresses) + { + Advised.AddRange(itemAddresses); + } + + /// Records the most recent write. + /// The subtag reference to write. + /// The value to write. + public void Write(string itemAddress, object? value) + { + LastWrite = (itemAddress, value); + } + + /// Raises a for the given subtag. + /// The subtag address whose value changed. + /// The new value. + /// The UTC timestamp of the change. + public void Raise(string address, object? value, DateTime timestampUtc) + { + ValueChanged?.Invoke(this, new SubtagValueChange + { + ItemAddress = address, + Value = value, + TimestampUtc = timestampUtc, + }); + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index 7a9bce9..8a71c9b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -191,7 +191,8 @@ public sealed class AlarmDispatcher : IDisposable operatorUser: record.OperatorName, operatorComment: record.AlarmComment, category: record.Group, - description: string.Empty); + description: string.Empty, + degraded: record.Degraded); } private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record) diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs new file mode 100644 index 0000000..5b22518 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Subtag-fallback implementation of . +/// Where polls the native alarmmgr +/// (wnwrap) COM stream, this consumer advises a configured set of MXAccess +/// alarm subtags via an and synthesizes +/// alarm transitions through a . Every +/// emitted record is flagged +/// and assigned a deterministic derived +/// from the alarm's full reference, since the subtag path has no +/// alarmmgr-supplied GUID. +/// +/// +/// +/// Threading: like , this consumer is +/// driven on the worker's STA thread. binds the +/// source's ValueChanged event; the source raises that event on +/// the same STA in production, so +/// fires on the STA and subscribers must marshal off it themselves if +/// they need another thread. +/// +/// +/// The subtag path is event-driven, so is a +/// no-op — value changes arrive through the source's advise stream +/// rather than an explicit poll. Acknowledgment in subtag mode writes +/// the comment to the target's writable ack-comment subtag rather than +/// calling a native AlarmAckByGUID / AlarmAckByName. +/// +/// +public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer +{ + private readonly ISubtagAlarmSource source; + private readonly SubtagAlarmStateMachine stateMachine; + private readonly Dictionary targetsByReference; + private readonly Dictionary referencesBySyntheticGuid; + private readonly EventHandler valueChangedHandler; + private bool subscribed; + private bool disposed; + + /// Fires once per synthesized alarm-state transition. + public event EventHandler? AlarmTransitionEmitted; + + /// + /// Initializes the consumer over a subtag source and a watch list of + /// alarm targets. + /// + /// The subtag value source to advise and write through. + /// The alarm subtag targets to observe. + /// + /// Thrown when or + /// is . + /// + public SubtagAlarmConsumer(ISubtagAlarmSource source, IReadOnlyList watchList) + { + this.source = source ?? throw new ArgumentNullException(nameof(source)); + if (watchList is null) + { + throw new ArgumentNullException(nameof(watchList)); + } + + this.stateMachine = new SubtagAlarmStateMachine(watchList); + this.targetsByReference = new Dictionary(StringComparer.OrdinalIgnoreCase); + this.referencesBySyntheticGuid = new Dictionary(); + + foreach (AlarmSubtagTarget target in watchList) + { + string reference = target.AlarmFullReference ?? string.Empty; + this.targetsByReference[reference] = target; + this.referencesBySyntheticGuid[SyntheticAlarmGuid.ForReference(reference)] = reference; + } + + this.valueChangedHandler = OnValueChanged; + } + + /// + /// Advises every observable alarm subtag (active / acked / priority — + /// the ack-comment subtag is a write-only target and is not advised) + /// and begins listening for value changes. The + /// expression is ignored: the subtag set is fixed by the watch list. + /// + /// The subscription expression (unused in subtag mode). + public void Subscribe(string subscription) + { + if (disposed) + { + throw new ObjectDisposedException(nameof(SubtagAlarmConsumer)); + } + + List addresses = new List(); + foreach (AlarmSubtagTarget target in targetsByReference.Values) + { + AddIfNotEmpty(addresses, target.ActiveSubtag); + AddIfNotEmpty(addresses, target.AckedSubtag); + AddIfNotEmpty(addresses, target.PrioritySubtag); + } + + source.Advise(addresses); + + if (!subscribed) + { + source.ValueChanged += valueChangedHandler; + subscribed = true; + } + } + + /// + /// Acknowledges an alarm by its synthetic GUID. Resolves the GUID back + /// to its alarm full reference and delegates to the by-name write path. + /// + /// The synthetic alarm GUID. + /// The acknowledgment comment. + /// The operator name (unused in subtag mode). + /// The operator node (unused in subtag mode). + /// The operator domain (unused in subtag mode). + /// The operator full name (unused in subtag mode). + /// 0 on success; non-zero when the GUID or ack-comment subtag is unknown. + public int AcknowledgeByGuid( + Guid alarmGuid, + string ackComment, + string ackOperatorName, + string ackOperatorNode, + string ackOperatorDomain, + string ackOperatorFullName) + { + if (disposed) + { + throw new ObjectDisposedException(nameof(SubtagAlarmConsumer)); + } + + if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) || + !targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target)) + { + return -1; + } + + return WriteAckComment(target, ackComment); + } + + /// + /// Acknowledges an alarm by its (name, provider, group) tuple. In subtag + /// mode the comment is written to the target's writable ack-comment + /// subtag; the operator-identity arguments are not surfaced through the + /// subtag write. + /// + /// The alarm name (object-rooted tag name or any reference fragment). + /// The provider name (unused for matching). + /// The group name (unused for matching). + /// The acknowledgment comment. + /// The operator name (unused in subtag mode). + /// The operator node (unused in subtag mode). + /// The operator domain (unused in subtag mode). + /// The operator full name (unused in subtag mode). + /// 0 on success; non-zero when no target matches or it lacks an ack-comment subtag. + public int AcknowledgeByName( + string alarmName, + string providerName, + string groupName, + string ackComment, + string ackOperatorName, + string ackOperatorNode, + string ackOperatorDomain, + string ackOperatorFullName) + { + if (disposed) + { + throw new ObjectDisposedException(nameof(SubtagAlarmConsumer)); + } + + AlarmSubtagTarget? target = ResolveTargetByName(alarmName); + if (target is null) + { + return -1; + } + + return WriteAckComment(target, ackComment); + } + + /// + /// Returns the state machine's currently-active alarm snapshot, with + /// each record stamped and + /// assigned its synthetic GUID. + /// + /// The active alarm snapshot records. + public IReadOnlyList SnapshotActiveAlarms() + { + IReadOnlyList records = stateMachine.SnapshotActive(); + foreach (MxAlarmSnapshotRecord record in records) + { + StampSynthetic(record); + } + + return records; + } + + /// + /// No-op: the subtag path is event-driven and owns no poll cadence. + /// + public void PollOnce() + { + // Subtag mode is event-driven; value changes arrive via the source's + // advise stream, so there is nothing to poll. + } + + /// + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + + if (subscribed) + { + try + { + source.ValueChanged -= valueChangedHandler; + } + catch + { + // swallow — best-effort detach during dispose + } + + subscribed = false; + } + + try + { + source.Dispose(); + } + catch + { + // swallow — best-effort source dispose + } + } + + private void OnValueChanged(object? sender, SubtagValueChange change) + { + if (disposed || change is null) + { + return; + } + + IReadOnlyList transitions = + stateMachine.Apply(change.ItemAddress, change.Value, change.TimestampUtc); + if (transitions.Count == 0) + { + return; + } + + EventHandler? handler = AlarmTransitionEmitted; + foreach (MxAlarmTransitionEvent transition in transitions) + { + StampSynthetic(transition.Record); + handler?.Invoke(this, transition); + } + } + + /// + /// Stamps the degraded flag and synthetic GUID onto a synthesized + /// record. The state machine sets only the provider / group / tag-name + /// parts, so the alarm reference is recomposed exactly as + /// composes it — keying the synthetic GUID + /// off that recomposed reference keeps it stable across transitions and + /// snapshots for the same alarm. + /// + private static void StampSynthetic(MxAlarmSnapshotRecord record) + { + record.Degraded = true; + string reference = AlarmRecordTransitionMapper.ComposeFullReference( + record.ProviderName, record.Group, record.TagName); + record.AlarmGuid = SyntheticAlarmGuid.ForReference(reference); + } + + private AlarmSubtagTarget? ResolveTargetByName(string? alarmName) + { + if (string.IsNullOrEmpty(alarmName)) + { + return null; + } + + // Match a target whose full reference contains the supplied name. The + // dispatcher derives (name, provider, group) from the composed + // reference, so the object-rooted tag name is always a substring of the + // target's AlarmFullReference. + foreach (AlarmSubtagTarget target in targetsByReference.Values) + { + string reference = target.AlarmFullReference ?? string.Empty; + if (reference.IndexOf(alarmName!, StringComparison.OrdinalIgnoreCase) >= 0) + { + return target; + } + } + + return null; + } + + private int WriteAckComment(AlarmSubtagTarget target, string ackComment) + { + string ackCommentSubtag = target.AckCommentSubtag ?? string.Empty; + if (string.IsNullOrEmpty(ackCommentSubtag)) + { + return -1; + } + + source.Write(ackCommentSubtag, ackComment); + return 0; + } + + private static void AddIfNotEmpty(List addresses, string? address) + { + if (!string.IsNullOrEmpty(address)) + { + addresses.Add(address!); + } + } +}