refactor(worker): alarm consumers resolve the AckMsg item instead of acking

IMxAccessAlarmConsumer.ResolveAckTarget/ResolveAckTargetByName replace AcknowledgeByGuid/AcknowledgeByName. wnwrap maps Galaxy-provider alarms to <tag>.AckMsg and drops its inert parallel ack consumer; subtag resolves to the target's AckCommentSubtag and no longer advises or writes it; ISubtagAlarmSource.Write is removed. Does not build alone: AlarmDispatcher moves to the new API in the next commit.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
Joseph Doherty
2026-10-19 19:35:49 -04:00
parent 8199553734
commit 0d4b2aea2d
11 changed files with 238 additions and 452 deletions
@@ -72,10 +72,10 @@ public sealed class FailoverAlarmConsumerTests
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 11; public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("primary.guid.AckMsg");
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 11; public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("primary.name.AckMsg");
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated) public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
@@ -122,10 +122,10 @@ public sealed class FailoverAlarmConsumerTests
public void PollOnce() { } public void PollOnce() { }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 22; public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("standby.guid.AckMsg");
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 22; public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("standby.name.AckMsg");
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated) public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
@@ -305,9 +305,9 @@ public sealed class FailoverAlarmConsumerTests
Assert.Equal(subscribeCountAfterSetup, primary.SubscribeCount); Assert.Equal(subscribeCountAfterSetup, primary.SubscribeCount);
} }
/// <summary>Proves that acknowledgment calls delegate to whichever child (primary or standby) is currently active.</summary> /// <summary>Proves that ack-target resolution delegates to whichever child (primary or standby) is currently active.</summary>
[Fact] [Fact]
public void Acknowledge_DelegatesToActiveChild() public void ResolveAckTarget_DelegatesToActiveChild()
{ {
FlakyPrimary primary = new FlakyPrimary { ThrowOnPoll = false }; FlakyPrimary primary = new FlakyPrimary { ThrowOnPoll = false };
StubStandby standby = new StubStandby(); StubStandby standby = new StubStandby();
@@ -316,18 +316,18 @@ public sealed class FailoverAlarmConsumerTests
sut.Subscribe(@"\\HOST\Galaxy!Area"); sut.Subscribe(@"\\HOST\Galaxy!Area");
// Active = Primary → primary's sentinel value (11). // Active = Primary → primary's sentinel targets.
Assert.Equal(11, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full")); Assert.Equal("primary.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress);
Assert.Equal(11, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full")); Assert.Equal("primary.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress);
// Force a failover by failing the primary past threshold. // Force a failover by failing the primary past threshold.
primary.ThrowOnPoll = true; primary.ThrowOnPoll = true;
sut.PollOnce(); // threshold=1 → switch to Standby sut.PollOnce(); // threshold=1 → switch to Standby
Assert.Equal(AlarmProviderMode.Subtag, sut.Mode); Assert.Equal(AlarmProviderMode.Subtag, sut.Mode);
// Active = Standby → standby's sentinel value (22). // Active = Standby → standby's sentinel targets.
Assert.Equal(22, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full")); Assert.Equal("standby.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress);
Assert.Equal(22, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full")); Assert.Equal("standby.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress);
} }
/// <summary> /// <summary>
@@ -94,76 +94,28 @@ public sealed class LmxSubtagAlarmSourceTests
} }
/// <summary> /// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> adds the item when /// Verifies <see cref="LmxSubtagAlarmSource.Dispose"/> UnAdvises and
/// it was not previously advised and writes with user id 0. /// removes every advised handle and unregisters the server exactly once.
/// </summary> /// </summary>
[Fact] [Fact]
public void Write_AddsItemWhenUnknownAndWrites() public void Dispose_UnAdvisesAndRemovesAdvisedHandles_AndUnregistersOnce()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Write("Tank1.Alarm.AckComment", "acknowledged");
Assert.Contains("Tank1.Alarm.AckComment", server.AddedItems);
Assert.Single(server.Writes);
RecordingMxAccessServer.WriteRecord write = server.Writes[0];
Assert.Equal(FakeServerHandle, write.ServerHandle);
Assert.Equal("acknowledged", write.Value);
Assert.Equal(0, write.UserId);
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> reuses an existing
/// item handle (no duplicate AddItem) when the address was already
/// advised.
/// </summary>
[Fact]
public void Write_ReusesHandleForAdvisedAddress()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag" });
int adviseAddCount = server.AddedItems.Count;
source.Write("Tank1.Alarm.Subtag", true);
// No second AddItem for the already-advised address.
Assert.Equal(adviseAddCount, server.AddedItems.Count);
Assert.Single(server.Writes);
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Dispose"/> UnAdvises only the
/// handles that were actually advised — a write-only item (added by
/// <see cref="LmxSubtagAlarmSource.Write"/> but never advised) is removed
/// but not unadvised — and unregisters the server exactly once.
/// </summary>
[Fact]
public void Dispose_UnAdvisesOnlyAdvisedHandles_RemovesAll_AndUnregistersOnce()
{ {
var server = new RecordingMxAccessServer(); var server = new RecordingMxAccessServer();
var source = new LmxSubtagAlarmSource(server, FakeServerHandle); var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" }); source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" });
// A write-only subtag: added by Write, never advised.
source.Write("Tank1.Alarm.AckComment", "acknowledged");
int advised1 = server.LastItemHandleFor("Tank1.Alarm.Subtag"); int advised1 = server.LastItemHandleFor("Tank1.Alarm.Subtag");
int advised2 = server.LastItemHandleFor("Tank2.Alarm.Subtag"); int advised2 = server.LastItemHandleFor("Tank2.Alarm.Subtag");
int writeOnly = server.LastItemHandleFor("Tank1.Alarm.AckComment");
source.Dispose(); source.Dispose();
// Only the two advised handles are unadvised — never the write-only one.
Assert.Equal(new[] { advised1, advised2 }, server.UnAdvisedItemHandles); Assert.Equal(new[] { advised1, advised2 }, server.UnAdvisedItemHandles);
Assert.DoesNotContain(writeOnly, server.UnAdvisedItemHandles);
// Every added item (advised + write-only) is removed.
Assert.Equal( Assert.Equal(
new[] { advised1, advised2, writeOnly }.OrderBy(h => h), new[] { advised1, advised2 }.OrderBy(h => h),
server.RemovedItemHandles.OrderBy(h => h)); server.RemovedItemHandles.OrderBy(h => h));
Assert.Equal(1, server.UnregisterCount); Assert.Equal(1, server.UnregisterCount);
Assert.Empty(server.Writes);
} }
/// <summary> /// <summary>
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary> /// <summary>
/// Unit tests for <see cref="SubtagAlarmConsumer"/>: prove that the subtag /// Unit tests for <see cref="SubtagAlarmConsumer"/>: prove that the subtag
/// fallback advises the observable subtags, synthesizes degraded /// fallback advises the observable subtags, synthesizes degraded
/// transitions with stable synthetic GUIDs, routes acknowledgments to the /// transitions with stable synthetic GUIDs, resolves acknowledgments to the
/// ack-comment subtag, and stamps snapshots. A <see cref="FakeSource"/> /// ack-comment subtag, and stamps snapshots. A <see cref="FakeSource"/>
/// stands in for the live MXAccess subtag source so this needs no AVEVA /// stands in for the live MXAccess subtag source so this needs no AVEVA
/// install. /// install.
@@ -40,9 +40,13 @@ public sealed class SubtagAlarmConsumerTests
return new SubtagAlarmConsumer(source, new[] { BuildTarget() }); return new SubtagAlarmConsumer(source, new[] { BuildTarget() });
} }
/// <summary>Verifies Subscribe advises the active, acked, priority, and ack-comment subtags (the ack-comment is advised so it is an active, writable MXAccess item).</summary> /// <summary>
/// Verifies Subscribe advises the observable active, acked and priority
/// subtags but not the ack-comment subtag: the ack is written by
/// <see cref="AlarmAckWriter"/>, which adds and advises it on its own server.
/// </summary>
[Fact] [Fact]
public void Subscribe_AdvisesAllSubtagsIncludingAckComment() public void Subscribe_AdvisesObservableSubtagsButNotAckComment()
{ {
FakeSource source = new FakeSource(); FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source); using SubtagAlarmConsumer consumer = BuildConsumer(source);
@@ -52,7 +56,7 @@ public sealed class SubtagAlarmConsumerTests
Assert.Contains(ActiveSubtag, source.Advised); Assert.Contains(ActiveSubtag, source.Advised);
Assert.Contains(AckedSubtag, source.Advised); Assert.Contains(AckedSubtag, source.Advised);
Assert.Contains(PrioritySubtag, source.Advised); Assert.Contains(PrioritySubtag, source.Advised);
Assert.Contains(AckCommentSubtag, source.Advised); Assert.DoesNotContain(AckCommentSubtag, source.Advised);
} }
/// <summary>Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition.</summary> /// <summary>Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition.</summary>
@@ -74,50 +78,49 @@ public sealed class SubtagAlarmConsumerTests
Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid); Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
} }
/// <summary>Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success.</summary> /// <summary>Verifies ResolveAckTargetByName resolves to the target's ack-comment subtag.</summary>
[Fact] [Fact]
public void AcknowledgeByName_WritesCommentToAckCommentSubtag() public void ResolveAckTargetByName_ReturnsAckCommentSubtag()
{ {
FakeSource source = new FakeSource(); FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source); using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByName( AlarmAckTarget target = consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea");
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.True(target.Found);
Assert.NotNull(source.LastWrite); Assert.Equal(AckCommentSubtag, target.ItemAddress);
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> /// <summary>Verifies ResolveAckTargetByName reports AlarmNotFound when no target matches the supplied name.</summary>
[Fact] [Fact]
public void AcknowledgeByName_UnknownAlarm_ReturnsNonZero() public void ResolveAckTargetByName_UnknownAlarm_IsNotFound()
{ {
FakeSource source = new FakeSource(); FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source); using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByName( AlarmAckTarget target = consumer.ResolveAckTargetByName("DoesNotExist.NoSuchAlarm", "Galaxy", "TestArea");
alarmName: "DoesNotExist.NoSuchAlarm",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "operator ack",
ackOperatorName: "alice",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
Assert.NotEqual(0, rc); Assert.False(target.Found);
Assert.Null(source.LastWrite); Assert.Equal(AlarmAckOutcome.AlarmNotFound, target.Failure);
}
/// <summary>Verifies a target with no ack-comment subtag resolves as NotAckable, by name and by GUID.</summary>
[Fact]
public void ResolveAckTarget_EmptyAckCommentSubtag_IsNotAckable()
{
AlarmSubtagTarget noAck = BuildTarget();
noAck.AckCommentSubtag = string.Empty;
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = new SubtagAlarmConsumer(source, new[] { noAck });
Assert.Equal(
AlarmAckOutcome.NotAckable,
consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea").Failure);
Assert.Equal(
AlarmAckOutcome.NotAckable,
consumer.ResolveAckTarget(SyntheticAlarmGuid.ForReference(Reference)).Failure);
} }
/// <summary>Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID.</summary> /// <summary>Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID.</summary>
@@ -160,11 +163,11 @@ public sealed class SubtagAlarmConsumerTests
/// <summary> /// <summary>
/// Verifies that when two alarm targets share a prefix (e.g. Level.Hi vs Level.HiHi), /// Verifies that when two alarm targets share a prefix (e.g. Level.Hi vs Level.HiHi),
/// AcknowledgeByName routes each ack to its own ack-comment subtag and never /// ResolveAckTargetByName resolves each to its own ack-comment subtag and never
/// conflates the shorter name with the longer one. /// conflates the shorter name with the longer one.
/// </summary> /// </summary>
[Fact] [Fact]
public void AcknowledgeByName_PrefixNameDoesNotFalseMatch() public void ResolveAckTargetByName_PrefixNameDoesNotFalseMatch()
{ {
const string ReferenceHi = "Galaxy!Area.Tank01.Level.Hi"; const string ReferenceHi = "Galaxy!Area.Tank01.Level.Hi";
const string ReferenceHiHi = "Galaxy!Area.Tank01.Level.HiHi"; const string ReferenceHiHi = "Galaxy!Area.Tank01.Level.HiHi";
@@ -195,42 +198,19 @@ public sealed class SubtagAlarmConsumerTests
source, new[] { targetHi, targetHiHi }); source, new[] { targetHi, targetHiHi });
consumer.Subscribe(@"\\HOST\Galaxy!Area"); consumer.Subscribe(@"\\HOST\Galaxy!Area");
// Ack the shorter name — must write to the shorter target's subtag only. Assert.Equal(AckCommentHi, consumer.ResolveAckTargetByName("Tank01.Level.Hi", "Galaxy", "Area").ItemAddress);
int rcHi = consumer.AcknowledgeByName( Assert.Equal(AckCommentHiHi, consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "Area").ItemAddress);
alarmName: "Tank01.Level.Hi", Assert.Equal(
providerName: "Galaxy", AlarmAckOutcome.AlarmNotFound,
groupName: "Area", consumer.ResolveAckTargetByName("Tank01.Level.H", "Galaxy", "Area").Failure);
ackComment: "ack hi",
ackOperatorName: "op", ackOperatorNode: "WS01",
ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
Assert.Equal(0, rcHi);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentHi, source.LastWrite!.Value.Address);
Assert.Equal("ack hi", source.LastWrite.Value.Value);
// Ack the longer name — must write to the longer target's subtag.
int rcHiHi = consumer.AcknowledgeByName(
alarmName: "Tank01.Level.HiHi",
providerName: "Galaxy",
groupName: "Area",
ackComment: "ack hihi",
ackOperatorName: "op", ackOperatorNode: "WS01",
ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
Assert.Equal(0, rcHiHi);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentHiHi, source.LastWrite!.Value.Address);
Assert.Equal("ack hihi", source.LastWrite.Value.Value);
} }
/// <summary> /// <summary>
/// Verifies AcknowledgeByGuid resolves the synthetic GUID (computed from /// Verifies ResolveAckTarget resolves the synthetic GUID (computed from
/// the alarm's full reference) to the correct target and writes the comment /// the alarm's full reference) to that target's ack-comment subtag.
/// to that target's ack-comment subtag.
/// </summary> /// </summary>
[Fact] [Fact]
public void AcknowledgeByGuid_WritesCommentToAckCommentSubtag() public void ResolveAckTarget_SyntheticGuid_ReturnsAckCommentSubtag()
{ {
FakeSource source = new FakeSource(); FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source); using SubtagAlarmConsumer consumer = BuildConsumer(source);
@@ -246,41 +226,21 @@ public sealed class SubtagAlarmConsumerTests
Guid syntheticGuid = emitted!.Record.AlarmGuid; Guid syntheticGuid = emitted!.Record.AlarmGuid;
Assert.NotEqual(Guid.Empty, syntheticGuid); Assert.NotEqual(Guid.Empty, syntheticGuid);
int rc = consumer.AcknowledgeByGuid( AlarmAckTarget target = consumer.ResolveAckTarget(syntheticGuid);
alarmGuid: syntheticGuid,
ackComment: "guid ack",
ackOperatorName: "op",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Operator");
Assert.Equal(0, rc); Assert.True(target.Found);
Assert.NotNull(source.LastWrite); Assert.Equal(AckCommentSubtag, target.ItemAddress);
Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
Assert.Equal("guid ack", source.LastWrite.Value.Value);
} }
/// <summary> /// <summary>Verifies ResolveAckTarget reports AlarmNotFound for a GUID unknown to the consumer.</summary>
/// Verifies AcknowledgeByGuid returns non-zero and performs no write when
/// the supplied GUID is not known to the consumer.
/// </summary>
[Fact] [Fact]
public void AcknowledgeByGuid_UnknownGuid_ReturnsNonZero() public void ResolveAckTarget_UnknownGuid_IsNotFound()
{ {
FakeSource source = new FakeSource(); FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source); using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByGuid( Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
alarmGuid: Guid.NewGuid(),
ackComment: "should not write",
ackOperatorName: "op",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Operator");
Assert.NotEqual(0, rc);
Assert.Null(source.LastWrite);
} }
private sealed class FakeSource : ISubtagAlarmSource private sealed class FakeSource : ISubtagAlarmSource
@@ -291,21 +251,12 @@ public sealed class SubtagAlarmConsumerTests
/// <summary>Gets the subtag addresses passed to <see cref="Advise"/>.</summary> /// <summary>Gets the subtag addresses passed to <see cref="Advise"/>.</summary>
public List<string> Advised { get; } = new List<string>(); 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; }
/// <inheritdoc /> /// <inheritdoc />
public void Advise(IReadOnlyCollection<string> itemAddresses) public void Advise(IReadOnlyCollection<string> itemAddresses)
{ {
Advised.AddRange(itemAddresses); Advised.AddRange(itemAddresses);
} }
/// <inheritdoc />
public void Write(string itemAddress, object? value)
{
LastWrite = (itemAddress, value);
}
/// <summary>Raises a <see cref="SubtagValueChange"/> for the given subtag.</summary> /// <summary>Raises a <see cref="SubtagValueChange"/> for the given subtag.</summary>
/// <param name="address">The subtag address whose value changed.</param> /// <param name="address">The subtag address whose value changed.</param>
/// <param name="value">The new value.</param> /// <param name="value">The new value.</param>
@@ -824,4 +824,58 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal("mgack-authwrite 180714", record.AckComment); Assert.Equal("mgack-authwrite 180714", record.AckComment);
Assert.Equal("Test alarm #1", record.AlarmComment); Assert.Equal("Test alarm #1", record.AlarmComment);
} }
/// <summary>A Galaxy-provider record resolves to its <c>&lt;tag&gt;.AckMsg</c> item; an unknown GUID is not found.</summary>
[Fact]
public void ResolveAckTarget_GalaxyRecord_ReturnsAckMsgItem()
{
Guid guid = Guid.NewGuid();
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
{
AlarmGuid = guid, ProviderName = "Galaxy", Group = "TestArea", TagName = "TestMachine_001.TestAlarm001", State = MxAlarmStateKind.UnackAlm,
});
AlarmAckTarget target = consumer.ResolveAckTarget(guid);
Assert.True(target.Found);
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", target.ItemAddress);
Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
}
/// <summary>A non-Galaxy provider has no MXAccess ack attribute, so it is not ackable.</summary>
[Fact]
public void ResolveAckTarget_NonGalaxyProvider_IsNotAckable()
{
Guid guid = Guid.NewGuid();
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
{
AlarmGuid = guid, ProviderName = "InTouch", Group = "G", TagName = "Tag1", State = MxAlarmStateKind.UnackAlm,
});
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTarget(guid).Failure);
}
/// <summary>By-name resolution needs no snapshot: a Galaxy name maps straight to its AckMsg item.</summary>
[Fact]
public void ResolveAckTargetByName_Galaxy_ReturnsAckMsgItem()
{
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot();
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg",
consumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea").ItemAddress);
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTargetByName("Tag1", "InTouch", "G").Failure);
}
private static WnWrapAlarmConsumer CreateConsumerWithSnapshot(params MxAlarmSnapshotRecord[] records)
{
WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(1024);
Dictionary<Guid, MxAlarmSnapshotRecord> next = new Dictionary<Guid, MxAlarmSnapshotRecord>();
foreach (MxAlarmSnapshotRecord record in records)
{
next[record.AlarmGuid] = record;
}
consumer.FoldFetch(next, truncated: false, out _);
return consumer;
}
} }
@@ -2,7 +2,7 @@
// //
// Validates the subtag-fallback pipeline against a real Galaxy + MXAccess install: // Validates the subtag-fallback pipeline against a real Galaxy + MXAccess install:
// LmxSubtagAlarmSource (own LMXProxyServerClass) -> // LmxSubtagAlarmSource (own LMXProxyServerClass) ->
// SubtagAlarmConsumer (state machine + AcknowledgeByName write) -> // SubtagAlarmConsumer (state machine + ResolveAckTargetByName) + AlarmAckWriter (AckMsg write) ->
// synthesized MxAlarmTransitionEvent (Raise / Clear, Degraded=true, SyntheticGuid) // synthesized MxAlarmTransitionEvent (Raise / Clear, Degraded=true, SyntheticGuid)
// //
// FIELD NAMES CONFIRMED: InAlarm/Acked/AckMsg/Priority are the confirmed AVEVA // FIELD NAMES CONFIRMED: InAlarm/Acked/AckMsg/Priority are the confirmed AVEVA
@@ -49,8 +49,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
/// synthesize <see cref="MxAlarmTransitionEvent"/> records from Galaxy /// synthesize <see cref="MxAlarmTransitionEvent"/> records from Galaxy
/// alarm subtags using the confirmed AVEVA <c>AlarmExtension</c> field /// alarm subtags using the confirmed AVEVA <c>AlarmExtension</c> field
/// names (InAlarm/Acked/AckMsg/Priority), and that /// names (InAlarm/Acked/AckMsg/Priority), and that
/// <see cref="SubtagAlarmConsumer.AcknowledgeByName"/> writes the /// <see cref="SubtagAlarmConsumer.ResolveAckTargetByName"/> resolves the
/// ack-comment subtag (AckMsg) successfully. /// ack-comment subtag (AckMsg) that <see cref="AlarmAckWriter"/> then
/// writes successfully.
/// ///
/// Skip-gated; flip <c>Skip=null</c> on the dev rig with an alarm being /// Skip-gated; flip <c>Skip=null</c> on the dev rig with an alarm being
/// driven (flip script or a manual operator/IDE toggle of the alarm /// driven (flip script or a manual operator/IDE toggle of the alarm
@@ -119,7 +120,7 @@ public sealed class AlarmSubtagLiveSmokeTests
/// real <c>LMXProxyServerClass</c>, collects a Raise then a Clear /// real <c>LMXProxyServerClass</c>, collects a Raise then a Clear
/// transition synthesized by <see cref="SubtagAlarmConsumer"/>, confirms /// transition synthesized by <see cref="SubtagAlarmConsumer"/>, confirms
/// the Degraded flag and synthetic GUID are stamped, then /// the Degraded flag and synthetic GUID are stamped, then
/// AcknowledgeByName and verifies the ack-comment write returns 0. /// acknowledges through the resolved ack-comment subtag and verifies the write succeeds.
/// </summary> /// </summary>
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + an alarm being driven (flip script or manual operator/IDE toggle of the alarm attribute). Subtag fallback path. Field names confirmed (InAlarm/Acked/AckMsg/Priority); live-validate runtime path resolves without intermediate alarm-condition segment.")] [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + an alarm being driven (flip script or manual operator/IDE toggle of the alarm attribute). Subtag fallback path. Field names confirmed (InAlarm/Acked/AckMsg/Priority); live-validate runtime path resolves without intermediate alarm-condition segment.")]
public void SubtagFallback_FullPipelineRoundTrip_SynthesizesRaiseAndAcknowledges() public void SubtagFallback_FullPipelineRoundTrip_SynthesizesRaiseAndAcknowledges()
@@ -257,17 +258,9 @@ public sealed class AlarmSubtagLiveSmokeTests
Log("InAlarm subtag delivered a value: " + inAlarmResolved); Log("InAlarm subtag delivered a value: " + inAlarmResolved);
// Decisive write: AckMsg. Returns 0 only if the address resolves and is writable. // Decisive write: AckMsg. Returns 0 only if the address resolves and is writable.
Log("AcknowledgeByName (writes AckMsg subtag) ..."); Log("Acknowledge (writes AckMsg subtag via AlarmAckWriter) ...");
int rc = consumer.AcknowledgeByName( int rc = AckViaWriter(consumer, "subtag-resolution-probe").NativeStatus;
alarmName: AlarmTagName, Log(string.Format("Ack (AckMsg write) -> rc={0}", rc));
providerName: Provider,
groupName: Group,
ackComment: "subtag-resolution-probe",
ackOperatorName: Environment.UserName,
ackOperatorNode: Environment.MachineName,
ackOperatorDomain: Environment.UserDomainName ?? string.Empty,
ackOperatorFullName: Environment.UserName);
Log(string.Format("AcknowledgeByName(AckMsg write) -> rc={0}", rc));
DateTime settle = DateTime.UtcNow + TimeSpan.FromSeconds(2); DateTime settle = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (DateTime.UtcNow < settle) while (DateTime.UtcNow < settle)
@@ -392,21 +385,13 @@ public sealed class AlarmSubtagLiveSmokeTests
Assert.True(foundInSnapshot, Assert.True(foundInSnapshot,
string.Format("Active alarm snapshot must contain '{0}' after a Raise.", AlarmFullReference)); string.Format("Active alarm snapshot must contain '{0}' after a Raise.", AlarmFullReference));
// 3. AcknowledgeByName — writes the ack-comment subtag. // 3. Resolve the ack-comment subtag and write it as the operator.
// The dispatcher derives (alarmName, provider, group) via the same // The dispatcher derives (alarmName, provider, group) via the same
// TryParseReference logic as AlarmsLiveSmokeTests. // TryParseReference logic as AlarmsLiveSmokeTests.
Log("Calling AcknowledgeByName ..."); Log("Acknowledging via AlarmAckWriter ...");
int rc = consumer.AcknowledgeByName( int rc = AckViaWriter(consumer, "subtag-fallback-smoke ack").NativeStatus;
alarmName: AlarmTagName,
providerName: Provider,
groupName: Group,
ackComment: "subtag-fallback-smoke ack",
ackOperatorName: Environment.UserName,
ackOperatorNode: Environment.MachineName,
ackOperatorDomain: Environment.UserDomainName ?? string.Empty,
ackOperatorFullName: Environment.UserName);
Log(string.Format("AcknowledgeByName -> rc={0}", rc)); Log(string.Format("Ack -> rc={0}", rc));
Assert.Equal(0, rc); Assert.Equal(0, rc);
// 4. Wait for a Clear or Acknowledge transition to confirm the state // 4. Wait for a Clear or Acknowledge transition to confirm the state
@@ -501,6 +486,29 @@ public sealed class AlarmSubtagLiveSmokeTests
/// Mirrors the pump pattern documented in /// Mirrors the pump pattern documented in
/// <c>docs/MxAccessWorkerInstanceDesign.md</c>. /// <c>docs/MxAccessWorkerInstanceDesign.md</c>.
/// </summary> /// </summary>
/// <summary>
/// Resolves the probe alarm's ack item through the consumer and writes it
/// with <see cref="AlarmAckWriter"/> as the operator named by
/// <c>MXGATEWAY_PROBE_ACK_USER</c>/<c>MXGATEWAY_PROBE_ACK_PASSWORD</c>
/// (default: current user, empty password).
/// </summary>
private static AlarmAckResult AckViaWriter(SubtagAlarmConsumer consumer, string comment)
{
AlarmAckTarget target = consumer.ResolveAckTargetByName(AlarmTagName, Provider, Group);
if (!target.Found)
{
return AlarmAckResult.FromTarget(target);
}
using AlarmAckWriter writer = new AlarmAckWriter(
new MxAccessComObjectFactory(), PumpMessages, TimeSpan.FromSeconds(5), AlarmAckWriter.DefaultClientName);
return writer.Acknowledge(target.ItemAddress, comment, new AlarmAckOperator
{
UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName,
Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty,
});
}
private static void PumpMessages() private static void PumpMessages()
{ {
NativeMethods.MSG msg; NativeMethods.MSG msg;
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <strong>Threading.</strong> Like its children, this type is driven /// <strong>Threading.</strong> Like its children, this type is driven
/// entirely on the worker's STA: <see cref="Subscribe"/>, /// entirely on the worker's STA: <see cref="Subscribe"/>,
/// <see cref="PollOnce"/>, <see cref="ProbeOnce"/>, and the /// <see cref="PollOnce"/>, <see cref="ProbeOnce"/>, and the
/// <c>AcknowledgeBy*</c> calls are all invoked from the apartment that /// <c>ResolveAckTarget*</c> calls are all invoked from the apartment that
/// owns the underlying COM objects. It owns no locks of its own and no /// owns the underlying COM objects. It owns no locks of its own and no
/// internal timer; the worker drives <see cref="PollOnce"/> on a timer. /// internal timer; the worker drives <see cref="PollOnce"/> on a timer.
/// </para> /// </para>
@@ -259,34 +259,17 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid( public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
return ActiveChild.AcknowledgeByGuid( return ActiveChild.ResolveAckTarget(alarmGuid);
alarmGuid, ackComment, ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName);
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
return ActiveChild.AcknowledgeByName( return ActiveChild.ResolveAckTargetByName(alarmName, providerName, groupName);
alarmName, providerName, groupName, ackComment,
ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -49,51 +49,19 @@ public interface IMxAccessAlarmConsumer : IDisposable
void Subscribe(string subscription); void Subscribe(string subscription);
/// <summary> /// <summary>
/// Acknowledges a single alarm with full operator-identity fidelity. /// Resolves the MXAccess item whose write acknowledges the alarm with the
/// Reaches AVEVA's native <c>AlarmAckByGUID</c>; operator /// given GUID (e.g. <c>Obj.Alarm.AckMsg</c>). Does not perform the ack.
/// user / node / domain / full-name and the comment land atomically
/// with the ack transition in the alarm-history log.
/// </summary> /// </summary>
/// <param name="alarmGuid">The alarm GUID.</param> /// <param name="alarmGuid">The alarm GUID as reported by this consumer.</param>
/// <param name="ackComment">The acknowledgment comment.</param> /// <returns>The resolved target or the reason none exists.</returns>
/// <param name="ackOperatorName">The operator name.</param> AlarmAckTarget ResolveAckTarget(Guid alarmGuid);
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName);
/// <summary> /// <summary>Resolves the ack item for an alarm identified by name, provider and group.</summary>
/// Acknowledge a single alarm by its (name, provider, group) tuple. /// <param name="alarmName">The tag/alarm name, e.g. <c>TestMachine_001.TestAlarm001</c>.</param>
/// Reaches AVEVA's <c>AlarmAckByName</c> on /// <param name="providerName">The provider (literal <c>Galaxy</c> for ArchestrA).</param>
/// <c>wwAlarmConsumerClass</c>; same alarm-history outcome as /// <param name="groupName">The area/group.</param>
/// <see cref="AcknowledgeByGuid"/>, used when the caller has the /// <returns>The resolved target or the reason none exists.</returns>
/// human-readable reference but not the canonical GUID. AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName);
/// </summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The group name.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName);
/// <summary> /// <summary>
/// Returns the consumer's most recently parsed snapshot of currently /// Returns the consumer's most recently parsed snapshot of currently
@@ -16,9 +16,4 @@ public interface ISubtagAlarmSource : IDisposable
/// <summary>Begins advising the supplied MXAccess subtag item addresses.</summary> /// <summary>Begins advising the supplied MXAccess subtag item addresses.</summary>
/// <param name="itemAddresses">The subtag references to advise.</param> /// <param name="itemAddresses">The subtag references to advise.</param>
void Advise(IReadOnlyCollection<string> itemAddresses); void Advise(IReadOnlyCollection<string> itemAddresses);
/// <summary>Writes a value to the named MXAccess subtag (e.g. an ack request).</summary>
/// <param name="itemAddress">The subtag reference to write.</param>
/// <param name="value">The value to write.</param>
void Write(string itemAddress, object? value);
} }
@@ -9,8 +9,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// Production <see cref="ISubtagAlarmSource"/> backed by its own MXAccess /// Production <see cref="ISubtagAlarmSource"/> backed by its own MXAccess
/// <c>LMXProxyServerClass</c> COM object. It advises a set of alarm subtag /// <c>LMXProxyServerClass</c> COM object. It advises a set of alarm subtag
/// item addresses, normalizes each <c>OnDataChange</c> COM callback into a /// item addresses, normalizes each <c>OnDataChange</c> COM callback into a
/// <see cref="SubtagValueChange"/>, and supports writing a value (e.g. an /// <see cref="SubtagValueChange"/>. It is observe-only: alarm
/// ack-comment) to a subtag. /// acknowledgement goes through <see cref="AlarmAckWriter"/>, which
/// authenticates the operator on its own server.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -25,7 +26,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <strong>Threading.</strong> The <c>LMXProxyServerClass</c> CLSID is /// <strong>Threading.</strong> The <c>LMXProxyServerClass</c> CLSID is
/// apartment-threaded and delivers <c>OnDataChange</c> via the STA /// apartment-threaded and delivers <c>OnDataChange</c> via the STA
/// message pump. Every method on this type — the lazy <see cref="Advise"/> /// message pump. Every method on this type — the lazy <see cref="Advise"/>
/// creation, <see cref="Write"/>, and <see cref="Dispose"/> — must be /// creation and <see cref="Dispose"/> — must be
/// invoked on the worker's STA that created the source, exactly as /// invoked on the worker's STA that created the source, exactly as
/// <see cref="MxAccessSession"/> and <see cref="WnWrapAlarmConsumer"/> /// <see cref="MxAccessSession"/> and <see cref="WnWrapAlarmConsumer"/>
/// require. The <c>OnDataChange</c> COM event also arrives on that STA. /// require. The <c>OnDataChange</c> COM event also arrives on that STA.
@@ -47,9 +48,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
new Dictionary<int, string>(); new Dictionary<int, string>();
// Handles that were actually Advise()d, tracked separately from the added // Handles that were actually Advise()d, tracked separately from the added
// set so Dispose only UnAdvises advised items. Write() can AddItem a // set so Dispose only UnAdvises items this source advised.
// write-only subtag (e.g. an ack-comment that was never advised); calling
// UnAdvise on such a handle would be an unbalanced teardown.
private readonly HashSet<int> advisedItemHandles = new HashSet<int>(); private readonly HashSet<int> advisedItemHandles = new HashSet<int>();
private object? mxAccessComObject; private object? mxAccessComObject;
@@ -85,7 +84,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
/// <see cref="IMxAccessServer"/> with a pre-registered server handle, /// <see cref="IMxAccessServer"/> with a pre-registered server handle,
/// bypassing the live COM factory. Exposed via /// bypassing the live COM factory. Exposed via
/// <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c> so tests can /// <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c> so tests can
/// exercise <see cref="Advise"/>/<see cref="Write"/> and /// exercise <see cref="Advise"/> and
/// <see cref="HandleDataChange"/> without a live <c>LMXProxyServerClass</c>. /// <see cref="HandleDataChange"/> without a live <c>LMXProxyServerClass</c>.
/// No COM event source is wired in this mode — tests drive /// No COM event source is wired in this mode — tests drive
/// <see cref="HandleDataChange"/> directly to simulate an /// <see cref="HandleDataChange"/> directly to simulate an
@@ -140,29 +139,6 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
} }
} }
/// <inheritdoc />
public void Write(string itemAddress, object? value)
{
// Writes with MXAccess user id 0 — always an unsecured Write, never WriteSecured semantics.
if (itemAddress is null)
{
throw new ArgumentNullException(nameof(itemAddress));
}
ThrowIfDisposed();
EnsureRegistered();
IMxAccessServer mxServer = server!;
if (!itemHandlesByAddress.TryGetValue(itemAddress, out int itemHandle))
{
itemHandle = mxServer.AddItem(serverHandle, itemAddress);
itemHandlesByAddress[itemAddress] = itemHandle;
addressesByItemHandle[itemHandle] = itemAddress;
}
mxServer.Write(serverHandle, itemHandle, value, userId: 0);
}
/// <summary> /// <summary>
/// Normalizes an MXAccess <c>OnDataChange</c> callback into a /// Normalizes an MXAccess <c>OnDataChange</c> callback into a
/// <see cref="SubtagValueChange"/> and raises <see cref="ValueChanged"/> /// <see cref="SubtagValueChange"/> and raises <see cref="ValueChanged"/>
@@ -223,8 +199,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
{ {
foreach (KeyValuePair<int, string> entry in addressesByItemHandle) foreach (KeyValuePair<int, string> entry in addressesByItemHandle)
{ {
// Only UnAdvise handles that were actually advised; a write-only // Only UnAdvise handles that were actually advised.
// item (added by Write but never Advise'd) was never advised.
if (advisedItemHandles.Contains(entry.Key)) if (advisedItemHandles.Contains(entry.Key))
{ {
try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ } try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ }
@@ -27,9 +27,11 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <para> /// <para>
/// The subtag path is event-driven, so <see cref="PollOnce"/> is a /// The subtag path is event-driven, so <see cref="PollOnce"/> is a
/// no-op — value changes arrive through the source's advise stream /// no-op — value changes arrive through the source's advise stream
/// rather than an explicit poll. Acknowledgment in subtag mode writes /// rather than an explicit poll. Acknowledgment in subtag mode resolves
/// the comment to the target's writable ack-comment subtag rather than /// to the target's writable ack-comment subtag
/// calling a native <c>AlarmAckByGUID</c> / <c>AlarmAckByName</c>. /// (<see cref="ResolveAckTarget"/>); <see cref="AlarmAckWriter"/>
/// performs the operator-authenticated write on its own MXAccess
/// server, so this consumer neither advises nor writes that subtag.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
@@ -49,7 +51,7 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
/// Initializes the consumer over a subtag source and a watch list of /// Initializes the consumer over a subtag source and a watch list of
/// alarm targets. /// alarm targets.
/// </summary> /// </summary>
/// <param name="source">The subtag value source to advise and write through.</param> /// <param name="source">The subtag value source to advise.</param>
/// <param name="watchList">The alarm subtag targets to observe.</param> /// <param name="watchList">The alarm subtag targets to observe.</param>
/// <exception cref="ArgumentNullException"> /// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="source"/> or <paramref name="watchList"/> /// Thrown when <paramref name="source"/> or <paramref name="watchList"/>
@@ -91,7 +93,6 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
AddIfNotEmpty(addresses, target.ActiveSubtag); AddIfNotEmpty(addresses, target.ActiveSubtag);
AddIfNotEmpty(addresses, target.AckedSubtag); AddIfNotEmpty(addresses, target.AckedSubtag);
AddIfNotEmpty(addresses, target.PrioritySubtag); AddIfNotEmpty(addresses, target.PrioritySubtag);
AddIfNotEmpty(addresses, target.AckCommentSubtag);
} }
source.Advise(addresses); source.Advise(addresses);
@@ -104,13 +105,7 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid( public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) if (disposed)
{ {
@@ -120,22 +115,14 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) || if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) ||
!targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target)) !targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target))
{ {
return -1; return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not on the subtag watch list.");
} }
return WriteAckComment(target, ackComment); return ToAckTarget(target);
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) if (disposed)
{ {
@@ -143,12 +130,9 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
} }
AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName); AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName);
if (target is null) return target is null
{ ? AlarmAckTarget.NotFound($"Alarm '{providerName}!{groupName}.{alarmName}' is not on the subtag watch list.")
return -1; : ToAckTarget(target);
}
return WriteAckComment(target, ackComment);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -260,16 +244,12 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
return target; return target;
} }
private int WriteAckComment(AlarmSubtagTarget target, string ackComment) private static AlarmAckTarget ToAckTarget(AlarmSubtagTarget target)
{ {
string ackCommentSubtag = target.AckCommentSubtag ?? string.Empty; string ackItem = target.AckCommentSubtag ?? string.Empty;
if (string.IsNullOrEmpty(ackCommentSubtag)) return ackItem.Length == 0
{ ? AlarmAckTarget.NotAckable($"Alarm '{target.AlarmFullReference}' has no ack-comment subtag configured.")
return -1; : AlarmAckTarget.For(ackItem);
}
source.Write(ackCommentSubtag, ackComment);
return 0;
} }
private static void AddIfNotEmpty(List<string> addresses, string? address) private static void AddIfNotEmpty(List<string> addresses, string? address)
@@ -34,7 +34,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// and operated from an STA thread; the worker's /// and operated from an STA thread; the worker's
/// <see cref="MxAccessStaSession"/> runs an STA pump that hosts it. /// <see cref="MxAccessStaSession"/> runs an STA pump that hosts it.
/// The consumer owns <em>no</em> internal timer: every COM call /// The consumer owns <em>no</em> internal timer: every COM call
/// (<c>Subscribe</c>, <c>PollOnce</c>, <c>AcknowledgeBy*</c>) must /// (<c>Subscribe</c>, <c>PollOnce</c>) must
/// be invoked on the STA that created the consumer. Polling cadence /// be invoked on the STA that created the consumer. Polling cadence
/// is driven externally by the worker's STA via /// is driven externally by the worker's STA via
/// <c>StaRuntime.InvokeAsync(() =&gt; consumer.PollOnce())</c>, which /// <c>StaRuntime.InvokeAsync(() =&gt; consumer.PollOnce())</c>, which
@@ -43,6 +43,15 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// off the owning STA and can deadlock on cross-apartment marshaling /// off the owning STA and can deadlock on cross-apartment marshaling
/// when the STA is not pumping messages, so no such timer exists. /// when the STA is not pumping messages, so no such timer exists.
/// </para> /// </para>
/// <para>
/// <strong>Acknowledgement.</strong> This consumer only receives
/// alarms. The wnwrap <c>AlarmAckBy*</c> calls were accepted and then
/// inert on the dev rig (see <c>docs/AlarmProbeFindings.md</c>), so
/// acknowledgement no longer goes through wnwrap: the consumer resolves
/// a Galaxy alarm to its <c>&lt;tag&gt;.AckMsg</c> item
/// (<see cref="ResolveAckTarget"/>) and <see cref="AlarmAckWriter"/>
/// writes that item over MXAccess as the authenticated operator.
/// </para>
/// </remarks> /// </remarks>
public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
{ {
@@ -50,6 +59,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
private const string DefaultApplicationName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker"; private const string DefaultApplicationName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker";
private const string DefaultVersion = "1.0"; private const string DefaultVersion = "1.0";
private const int DefaultMaxAlarmsPerFetch = 1024; private const int DefaultMaxAlarmsPerFetch = 1024;
private const string GalaxyProviderName = "Galaxy";
/// <summary> /// <summary>
/// Floor on the resolved per-fetch cap. Mirrors the gateway-side /// Floor on the resolved per-fetch cap. Mirrors the gateway-side
@@ -93,7 +103,6 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
private long truncatedFetchCount; private long truncatedFetchCount;
private bool lastSnapshotTruncated; private bool lastSnapshotTruncated;
private wwAlarmConsumerClass? client; private wwAlarmConsumerClass? client;
private wwAlarmConsumerClass? ackClient;
private bool subscribed; private bool subscribed;
private bool disposed; private bool disposed;
@@ -200,7 +209,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
// WIN-911-style code uses against the same wnwrap library. // WIN-911-style code uses against the same wnwrap library.
// Note rc=0 means the call was // Note rc=0 means the call was
// accepted, not that an acknowledgement was applied — see // accepted, not that an acknowledgement was applied — see
// AcknowledgeByName below and docs/AlarmProbeFindings.md. // docs/AlarmProbeFindings.md. Acks now go through AlarmAckWriter.
int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName); int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName);
if (init != 0) if (init != 0)
{ {
@@ -241,10 +250,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
// SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md), // SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md),
// calling it is necessary for subsequent GetXmlCurrentAlarms2 // calling it is necessary for subsequent GetXmlCurrentAlarms2
// calls to succeed. Without it, GetXmlCurrentAlarms2 returns // calls to succeed. Without it, GetXmlCurrentAlarms2 returns
// E_FAIL (HRESULT 0x80004005) on the first poll. SetXmlAlarmQuery // E_FAIL (HRESULT 0x80004005) on the first poll.
// also breaks AlarmAckByName on the same consumer (rejects with
// -55), so a separate ack-only consumer is provisioned below
// that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery).
// //
// The wnwrap interop signature is `void SetXmlAlarmQuery(string)` // The wnwrap interop signature is `void SetXmlAlarmQuery(string)`
// — there is no integer return code to gate on like the other v1 // — there is no integer return code to gate on like the other v1
@@ -268,116 +274,49 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
ex); ex);
} }
// Provision a parallel COM consumer for ack calls. It runs the
// v1 lifecycle (Initialize/Register/Subscribe) only; without
// SetXmlAlarmQuery, AlarmAckByName succeeds. State is read-only
// — we never poll this consumer.
ackClient = new wwAlarmConsumerClass();
int ackInit = ackClient.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName + ".ack");
int ackReg = ackClient.IwwAlarmConsumer_RegisterConsumer(
hWnd: 0,
szProductName: DefaultProductName,
szApplicationName: DefaultApplicationName + ".ack",
szVersion: DefaultVersion);
int ackSub = ackClient.IwwAlarmConsumer_Subscribe(
szSubscription: subscription,
wFromPri: 1,
wToPri: 999,
QueryType: eQueryType.qtSummary,
SortFlags: eSortFlags.sfReturnNewestFirst,
FilterMask: eAlarmFilterState.asAlarmActiveNow,
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
if (ackInit != 0 || ackReg != 0 || ackSub != 0)
{
throw new InvalidOperationException(
$"Ack consumer setup returned non-zero status: " +
$"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}.");
}
subscribed = true; subscribed = true;
} }
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid( public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
MxAlarmSnapshotRecord? record;
lock (syncRoot)
{
latestSnapshot.TryGetValue(alarmGuid, out record);
}
wwAlarmConsumerClass com = client if (record is null)
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); {
return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not in the current alarm snapshot.");
}
// VBGUID is wnwrap's GUID interop struct (same memory layout as return ResolveGalaxyAckTarget(record.ProviderName, record.TagName);
// System.Guid: int32 + 2x int16 + 8x byte). Convert via a single
// unmanaged-blittable round-trip.
VBGUID vb = ToVbGuid(alarmGuid);
return com.AlarmAckByGUID(
AlmGUID: vb,
szComment: ackComment ?? string.Empty,
szOprName: ackOperatorName ?? string.Empty,
szNode: ackOperatorNode ?? string.Empty,
szDomainName: ackOperatorDomain ?? string.Empty,
szOprFullName: ackOperatorFullName ?? string.Empty);
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
return ResolveGalaxyAckTarget(providerName, alarmName);
}
// Use the parallel ack-only consumer (no SetXmlAlarmQuery applied) private static AlarmAckTarget ResolveGalaxyAckTarget(string? providerName, string? tagName)
// — see docs/AlarmClientDiscovery.md "Option A — captured" for the {
// empirical justification. if (!string.Equals(providerName, GalaxyProviderName, StringComparison.OrdinalIgnoreCase))
wwAlarmConsumerClass com = ackClient {
?? throw new InvalidOperationException( return AlarmAckTarget.NotAckable(
"Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet."); $"Alarm provider '{providerName}' is not a Galaxy provider; only Galaxy alarms can be acknowledged over MXAccess.");
}
// The original observation (live dev-rig 2026-05-01) was narrower than if (string.IsNullOrWhiteSpace(tagName))
// it read: the IwwAlarmConsumer2 8-arg AlarmAckByName returned -55 {
// "and looked like a stub" while the legacy 6-arg return AlarmAckTarget.NotFound("Alarm record has no tag name.");
// IwwAlarmConsumer.AlarmAckByName returned 0, which is how the 6-arg }
// overload came to be the one called here. The 2026-08-18 probe
// corrected both halves. The -55 tracks the *consumer*, not the return AlarmAckTarget.For(tagName + "." + AlarmAckWriter.AckCommentAttribute);
// overload: both overloads return -55 against a
// SetXmlAlarmQuery-applied consumer and 0 against the ack-only one
// used above. And a zero return is NOT evidence the acknowledgement
// was applied — the probe acked a real, freshly raised alarm six ways
// and watched the snapshot stay UNACK_ALM, OPERATOR_NAME stay empty,
// and the alarm extension's own .Acked attribute stay False for 16 s
// after each call. On that rig the ack is accepted and then inert, so
// whether any wnwrap ack reaches the alarm-history path is
// unverified; see docs/AlarmProbeFindings.md and the 2026-08-18
// correction in docs/AlarmClientDiscovery.md. The 6-arg call site
// below therefore stays as-is for MXAccess parity — the choice is no
// longer justified by the rc, and rc semantics are per the probe.
//
// Operator-domain and operator-full-name fields are accepted by the
// proto contract for forward-compat but are not propagated to AVEVA
// today — wrapped in the 6-arg call so domain/full-name go to the
// alarm-history operator-name field via the szOprName parameter.
// Suppress unused-warning explicitly:
_ = ackOperatorDomain;
_ = ackOperatorFullName;
return com.AlarmAckByName(
szAlarmName: alarmName ?? string.Empty,
szProviderName: providerName ?? string.Empty,
szGroupName: groupName ?? string.Empty,
szComment: ackComment ?? string.Empty,
szOprName: ackOperatorName ?? string.Empty,
szNode: ackOperatorNode ?? string.Empty);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -944,37 +883,18 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
return sb.ToString(); return sb.ToString();
} }
private static VBGUID ToVbGuid(Guid g)
{
byte[] bytes = g.ToByteArray();
// Guid byte layout: int32-LE + int16-LE + int16-LE + 8 bytes (Data4).
VBGUID vb = new VBGUID
{
Data1 = BitConverter.ToInt32(bytes, 0),
Data2 = BitConverter.ToInt16(bytes, 4),
Data3 = BitConverter.ToInt16(bytes, 6),
Data4 = new byte[8],
};
Array.Copy(bytes, 8, vb.Data4, 0, 8);
return vb;
}
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() public void Dispose()
{ {
wwAlarmConsumerClass? clientToDispose; wwAlarmConsumerClass? clientToDispose;
wwAlarmConsumerClass? ackClientToDispose;
lock (syncRoot) lock (syncRoot)
{ {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
clientToDispose = client; clientToDispose = client;
client = null; client = null;
ackClientToDispose = ackClient;
ackClient = null;
} }
ReleaseConsumerCom(clientToDispose); ReleaseConsumerCom(clientToDispose);
ReleaseConsumerCom(ackClientToDispose);
} }
private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer) private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer)