worker(alarms): SubtagAlarmConsumer synthesizing degraded transitions; dispatcher propagates Degraded

This commit is contained in:
Joseph Doherty
2026-06-13 09:35:49 -04:00
parent 1e2ed6d1ea
commit 4bd757a136
3 changed files with 531 additions and 1 deletions
@@ -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;
/// <summary>
/// Unit tests for <see cref="SubtagAlarmConsumer"/>: 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 <see cref="FakeSource"/>
/// stands in for the live MXAccess subtag source so this needs no AVEVA
/// install.
/// </summary>
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() });
}
/// <summary>Verifies Subscribe advises the active, acked, and priority subtags but not the ack-comment subtag.</summary>
[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);
}
/// <summary>Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition.</summary>
[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);
}
/// <summary>Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success.</summary>
[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);
}
/// <summary>Verifies AcknowledgeByName returns non-zero when no target matches the supplied name.</summary>
[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);
}
/// <summary>Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID.</summary>
[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<MxAlarmSnapshotRecord> snapshot = consumer.SnapshotActiveAlarms();
Assert.Single(snapshot);
Assert.True(snapshot[0].Degraded);
Assert.NotEqual(Guid.Empty, snapshot[0].AlarmGuid);
}
/// <summary>Verifies the synthetic GUID on the emitted transition equals the GUID in the snapshot for the same alarm.</summary>
[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<MxAlarmSnapshotRecord> 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
{
/// <summary>Raised when an advised subtag reports a new value.</summary>
public event EventHandler<SubtagValueChange>? ValueChanged;
/// <summary>Gets the subtag addresses passed to <see cref="Advise"/>.</summary>
public List<string> Advised { get; } = new List<string>();
/// <summary>Gets the most recent (address, value) pair passed to <see cref="Write"/>.</summary>
public (string Address, object? Value)? LastWrite { get; private set; }
/// <summary>Records the advised subtag addresses.</summary>
/// <param name="itemAddresses">The subtag references to advise.</param>
public void Advise(IReadOnlyCollection<string> itemAddresses)
{
Advised.AddRange(itemAddresses);
}
/// <summary>Records the most recent write.</summary>
/// <param name="itemAddress">The subtag reference to write.</param>
/// <param name="value">The value to write.</param>
public void Write(string itemAddress, object? value)
{
LastWrite = (itemAddress, value);
}
/// <summary>Raises a <see cref="SubtagValueChange"/> for the given subtag.</summary>
/// <param name="address">The subtag address whose value changed.</param>
/// <param name="value">The new value.</param>
/// <param name="timestampUtc">The UTC timestamp of the change.</param>
public void Raise(string address, object? value, DateTime timestampUtc)
{
ValueChanged?.Invoke(this, new SubtagValueChange
{
ItemAddress = address,
Value = value,
TimestampUtc = timestampUtc,
});
}
/// <inheritdoc />
public void Dispose()
{
}
}
}
@@ -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)
@@ -0,0 +1,323 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Subtag-fallback implementation of <see cref="IMxAccessAlarmConsumer"/>.
/// Where <see cref="WnWrapAlarmConsumer"/> polls the native alarmmgr
/// (wnwrap) COM stream, this consumer advises a configured set of MXAccess
/// alarm subtags via an <see cref="ISubtagAlarmSource"/> and synthesizes
/// alarm transitions through a <see cref="SubtagAlarmStateMachine"/>. Every
/// emitted record is flagged <see cref="MxAlarmSnapshotRecord.Degraded"/>
/// and assigned a deterministic <see cref="SyntheticAlarmGuid"/> derived
/// from the alarm's full reference, since the subtag path has no
/// alarmmgr-supplied GUID.
/// </summary>
/// <remarks>
/// <para>
/// Threading: like <see cref="WnWrapAlarmConsumer"/>, this consumer is
/// driven on the worker's STA thread. <see cref="Subscribe"/> binds the
/// source's <c>ValueChanged</c> event; the source raises that event on
/// the same STA in production, so <see cref="AlarmTransitionEmitted"/>
/// fires on the STA and subscribers must marshal off it themselves if
/// they need another thread.
/// </para>
/// <para>
/// The subtag path is event-driven, so <see cref="PollOnce"/> 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 <c>AlarmAckByGUID</c> / <c>AlarmAckByName</c>.
/// </para>
/// </remarks>
public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
{
private readonly ISubtagAlarmSource source;
private readonly SubtagAlarmStateMachine stateMachine;
private readonly Dictionary<string, AlarmSubtagTarget> targetsByReference;
private readonly Dictionary<Guid, string> referencesBySyntheticGuid;
private readonly EventHandler<SubtagValueChange> valueChangedHandler;
private bool subscribed;
private bool disposed;
/// <summary>Fires once per synthesized alarm-state transition.</summary>
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
/// <summary>
/// Initializes the consumer over a subtag source and a watch list of
/// alarm targets.
/// </summary>
/// <param name="source">The subtag value source to advise and write through.</param>
/// <param name="watchList">The alarm subtag targets to observe.</param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="source"/> or <paramref name="watchList"/>
/// is <see langword="null"/>.
/// </exception>
public SubtagAlarmConsumer(ISubtagAlarmSource source, IReadOnlyList<AlarmSubtagTarget> 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<string, AlarmSubtagTarget>(StringComparer.OrdinalIgnoreCase);
this.referencesBySyntheticGuid = new Dictionary<Guid, string>();
foreach (AlarmSubtagTarget target in watchList)
{
string reference = target.AlarmFullReference ?? string.Empty;
this.targetsByReference[reference] = target;
this.referencesBySyntheticGuid[SyntheticAlarmGuid.ForReference(reference)] = reference;
}
this.valueChangedHandler = OnValueChanged;
}
/// <summary>
/// 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 <paramref name="subscription"/>
/// expression is ignored: the subtag set is fixed by the watch list.
/// </summary>
/// <param name="subscription">The subscription expression (unused in subtag mode).</param>
public void Subscribe(string subscription)
{
if (disposed)
{
throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
}
List<string> addresses = new List<string>();
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;
}
}
/// <summary>
/// Acknowledges an alarm by its synthetic GUID. Resolves the GUID back
/// to its alarm full reference and delegates to the by-name write path.
/// </summary>
/// <param name="alarmGuid">The synthetic alarm GUID.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name (unused in subtag mode).</param>
/// <param name="ackOperatorNode">The operator node (unused in subtag mode).</param>
/// <param name="ackOperatorDomain">The operator domain (unused in subtag mode).</param>
/// <param name="ackOperatorFullName">The operator full name (unused in subtag mode).</param>
/// <returns>0 on success; non-zero when the GUID or ack-comment subtag is unknown.</returns>
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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="alarmName">The alarm name (object-rooted tag name or any reference fragment).</param>
/// <param name="providerName">The provider name (unused for matching).</param>
/// <param name="groupName">The group name (unused for matching).</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name (unused in subtag mode).</param>
/// <param name="ackOperatorNode">The operator node (unused in subtag mode).</param>
/// <param name="ackOperatorDomain">The operator domain (unused in subtag mode).</param>
/// <param name="ackOperatorFullName">The operator full name (unused in subtag mode).</param>
/// <returns>0 on success; non-zero when no target matches or it lacks an ack-comment subtag.</returns>
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);
}
/// <summary>
/// Returns the state machine's currently-active alarm snapshot, with
/// each record stamped <see cref="MxAlarmSnapshotRecord.Degraded"/> and
/// assigned its synthetic GUID.
/// </summary>
/// <returns>The active alarm snapshot records.</returns>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
{
IReadOnlyList<MxAlarmSnapshotRecord> records = stateMachine.SnapshotActive();
foreach (MxAlarmSnapshotRecord record in records)
{
StampSynthetic(record);
}
return records;
}
/// <summary>
/// No-op: the subtag path is event-driven and owns no poll cadence.
/// </summary>
public void PollOnce()
{
// Subtag mode is event-driven; value changes arrive via the source's
// advise stream, so there is nothing to poll.
}
/// <inheritdoc />
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<MxAlarmTransitionEvent> transitions =
stateMachine.Apply(change.ItemAddress, change.Value, change.TimestampUtc);
if (transitions.Count == 0)
{
return;
}
EventHandler<MxAlarmTransitionEvent>? handler = AlarmTransitionEmitted;
foreach (MxAlarmTransitionEvent transition in transitions)
{
StampSynthetic(transition.Record);
handler?.Invoke(this, transition);
}
}
/// <summary>
/// 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
/// <see cref="AlarmDispatcher"/> composes it — keying the synthetic GUID
/// off that recomposed reference keeps it stable across transitions and
/// snapshots for the same alarm.
/// </summary>
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<string> addresses, string? address)
{
if (!string.IsNullOrEmpty(address))
{
addresses.Add(address!);
}
}
}