worker(alarms): SubtagAlarmConsumer synthesizing degraded transitions; dispatcher propagates Degraded
This commit is contained in:
@@ -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,
|
operatorUser: record.OperatorName,
|
||||||
operatorComment: record.AlarmComment,
|
operatorComment: record.AlarmComment,
|
||||||
category: record.Group,
|
category: record.Group,
|
||||||
description: string.Empty);
|
description: string.Empty,
|
||||||
|
degraded: record.Degraded);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record)
|
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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user