diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs index f95beca..5bc4e4b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs @@ -72,10 +72,10 @@ public sealed class FailoverAlarmConsumerTests } /// - 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"); /// - 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"); /// public IReadOnlyList SnapshotActiveAlarms(out bool truncated) @@ -122,10 +122,10 @@ public sealed class FailoverAlarmConsumerTests public void PollOnce() { } /// - 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"); /// - 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"); /// public IReadOnlyList SnapshotActiveAlarms(out bool truncated) @@ -305,9 +305,9 @@ public sealed class FailoverAlarmConsumerTests Assert.Equal(subscribeCountAfterSetup, primary.SubscribeCount); } - /// Proves that acknowledgment calls delegate to whichever child (primary or standby) is currently active. + /// Proves that ack-target resolution delegates to whichever child (primary or standby) is currently active. [Fact] - public void Acknowledge_DelegatesToActiveChild() + public void ResolveAckTarget_DelegatesToActiveChild() { FlakyPrimary primary = new FlakyPrimary { ThrowOnPoll = false }; StubStandby standby = new StubStandby(); @@ -316,18 +316,18 @@ public sealed class FailoverAlarmConsumerTests sut.Subscribe(@"\\HOST\Galaxy!Area"); - // Active = Primary → primary's sentinel value (11). - Assert.Equal(11, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full")); - Assert.Equal(11, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full")); + // Active = Primary → primary's sentinel targets. + Assert.Equal("primary.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress); + Assert.Equal("primary.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress); // Force a failover by failing the primary past threshold. primary.ThrowOnPoll = true; sut.PollOnce(); // threshold=1 → switch to Standby Assert.Equal(AlarmProviderMode.Subtag, sut.Mode); - // Active = Standby → standby's sentinel value (22). - Assert.Equal(22, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full")); - Assert.Equal(22, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full")); + // Active = Standby → standby's sentinel targets. + Assert.Equal("standby.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress); + Assert.Equal("standby.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress); } /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs index 2b5b7fb..ab74bd9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs @@ -94,76 +94,28 @@ public sealed class LmxSubtagAlarmSourceTests } /// - /// Verifies adds the item when - /// it was not previously advised and writes with user id 0. + /// Verifies UnAdvises and + /// removes every advised handle and unregisters the server exactly once. /// [Fact] - public void Write_AddsItemWhenUnknownAndWrites() - { - 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); - } - - /// - /// Verifies reuses an existing - /// item handle (no duplicate AddItem) when the address was already - /// advised. - /// - [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); - } - - /// - /// Verifies UnAdvises only the - /// handles that were actually advised — a write-only item (added by - /// but never advised) is removed - /// but not unadvised — and unregisters the server exactly once. - /// - [Fact] - public void Dispose_UnAdvisesOnlyAdvisedHandles_RemovesAll_AndUnregistersOnce() + public void Dispose_UnAdvisesAndRemovesAdvisedHandles_AndUnregistersOnce() { var server = new RecordingMxAccessServer(); var source = new LmxSubtagAlarmSource(server, FakeServerHandle); 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 advised2 = server.LastItemHandleFor("Tank2.Alarm.Subtag"); - int writeOnly = server.LastItemHandleFor("Tank1.Alarm.AckComment"); source.Dispose(); - // Only the two advised handles are unadvised — never the write-only one. Assert.Equal(new[] { advised1, advised2 }, server.UnAdvisedItemHandles); - Assert.DoesNotContain(writeOnly, server.UnAdvisedItemHandles); - // Every added item (advised + write-only) is removed. Assert.Equal( - new[] { advised1, advised2, writeOnly }.OrderBy(h => h), + new[] { advised1, advised2 }.OrderBy(h => h), server.RemovedItemHandles.OrderBy(h => h)); Assert.Equal(1, server.UnregisterCount); + Assert.Empty(server.Writes); } /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs index 093a3b9..76b1be2 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs @@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Unit tests for : prove that the subtag /// fallback advises the observable subtags, synthesizes degraded -/// transitions with stable synthetic GUIDs, routes acknowledgments to the +/// transitions with stable synthetic GUIDs, resolves acknowledgments to the /// ack-comment subtag, and stamps snapshots. A /// stands in for the live MXAccess subtag source so this needs no AVEVA /// install. @@ -40,9 +40,13 @@ public sealed class SubtagAlarmConsumerTests return new SubtagAlarmConsumer(source, new[] { BuildTarget() }); } - /// Verifies Subscribe advises the active, acked, priority, and ack-comment subtags (the ack-comment is advised so it is an active, writable MXAccess item). + /// + /// Verifies Subscribe advises the observable active, acked and priority + /// subtags but not the ack-comment subtag: the ack is written by + /// , which adds and advises it on its own server. + /// [Fact] - public void Subscribe_AdvisesAllSubtagsIncludingAckComment() + public void Subscribe_AdvisesObservableSubtagsButNotAckComment() { FakeSource source = new FakeSource(); using SubtagAlarmConsumer consumer = BuildConsumer(source); @@ -52,7 +56,7 @@ public sealed class SubtagAlarmConsumerTests Assert.Contains(ActiveSubtag, source.Advised); Assert.Contains(AckedSubtag, source.Advised); Assert.Contains(PrioritySubtag, source.Advised); - Assert.Contains(AckCommentSubtag, source.Advised); + Assert.DoesNotContain(AckCommentSubtag, source.Advised); } /// Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition. @@ -74,50 +78,49 @@ public sealed class SubtagAlarmConsumerTests Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid); } - /// Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success. + /// Verifies ResolveAckTargetByName resolves to the target's ack-comment subtag. [Fact] - public void AcknowledgeByName_WritesCommentToAckCommentSubtag() + public void ResolveAckTargetByName_ReturnsAckCommentSubtag() { 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"); + AlarmAckTarget target = consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea"); - Assert.Equal(0, rc); - Assert.NotNull(source.LastWrite); - Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address); - Assert.Equal("operator ack", source.LastWrite!.Value.Value); + Assert.True(target.Found); + Assert.Equal(AckCommentSubtag, target.ItemAddress); } - /// Verifies AcknowledgeByName returns non-zero when no target matches the supplied name. + /// Verifies ResolveAckTargetByName reports AlarmNotFound when no target matches the supplied name. [Fact] - public void AcknowledgeByName_UnknownAlarm_ReturnsNonZero() + public void ResolveAckTargetByName_UnknownAlarm_IsNotFound() { 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"); + AlarmAckTarget target = consumer.ResolveAckTargetByName("DoesNotExist.NoSuchAlarm", "Galaxy", "TestArea"); - Assert.NotEqual(0, rc); - Assert.Null(source.LastWrite); + Assert.False(target.Found); + Assert.Equal(AlarmAckOutcome.AlarmNotFound, target.Failure); + } + + /// Verifies a target with no ack-comment subtag resolves as NotAckable, by name and by GUID. + [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); } /// Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID. @@ -160,11 +163,11 @@ public sealed class SubtagAlarmConsumerTests /// /// 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. /// [Fact] - public void AcknowledgeByName_PrefixNameDoesNotFalseMatch() + public void ResolveAckTargetByName_PrefixNameDoesNotFalseMatch() { const string ReferenceHi = "Galaxy!Area.Tank01.Level.Hi"; const string ReferenceHiHi = "Galaxy!Area.Tank01.Level.HiHi"; @@ -195,42 +198,19 @@ public sealed class SubtagAlarmConsumerTests source, new[] { targetHi, targetHiHi }); consumer.Subscribe(@"\\HOST\Galaxy!Area"); - // Ack the shorter name — must write to the shorter target's subtag only. - int rcHi = consumer.AcknowledgeByName( - alarmName: "Tank01.Level.Hi", - providerName: "Galaxy", - groupName: "Area", - 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); + Assert.Equal(AckCommentHi, consumer.ResolveAckTargetByName("Tank01.Level.Hi", "Galaxy", "Area").ItemAddress); + Assert.Equal(AckCommentHiHi, consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "Area").ItemAddress); + Assert.Equal( + AlarmAckOutcome.AlarmNotFound, + consumer.ResolveAckTargetByName("Tank01.Level.H", "Galaxy", "Area").Failure); } /// - /// Verifies AcknowledgeByGuid resolves the synthetic GUID (computed from - /// the alarm's full reference) to the correct target and writes the comment - /// to that target's ack-comment subtag. + /// Verifies ResolveAckTarget resolves the synthetic GUID (computed from + /// the alarm's full reference) to that target's ack-comment subtag. /// [Fact] - public void AcknowledgeByGuid_WritesCommentToAckCommentSubtag() + public void ResolveAckTarget_SyntheticGuid_ReturnsAckCommentSubtag() { FakeSource source = new FakeSource(); using SubtagAlarmConsumer consumer = BuildConsumer(source); @@ -246,41 +226,21 @@ public sealed class SubtagAlarmConsumerTests Guid syntheticGuid = emitted!.Record.AlarmGuid; Assert.NotEqual(Guid.Empty, syntheticGuid); - int rc = consumer.AcknowledgeByGuid( - alarmGuid: syntheticGuid, - ackComment: "guid ack", - ackOperatorName: "op", - ackOperatorNode: "WS01", - ackOperatorDomain: "CORP", - ackOperatorFullName: "Operator"); + AlarmAckTarget target = consumer.ResolveAckTarget(syntheticGuid); - Assert.Equal(0, rc); - Assert.NotNull(source.LastWrite); - Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address); - Assert.Equal("guid ack", source.LastWrite.Value.Value); + Assert.True(target.Found); + Assert.Equal(AckCommentSubtag, target.ItemAddress); } - /// - /// Verifies AcknowledgeByGuid returns non-zero and performs no write when - /// the supplied GUID is not known to the consumer. - /// + /// Verifies ResolveAckTarget reports AlarmNotFound for a GUID unknown to the consumer. [Fact] - public void AcknowledgeByGuid_UnknownGuid_ReturnsNonZero() + public void ResolveAckTarget_UnknownGuid_IsNotFound() { FakeSource source = new FakeSource(); using SubtagAlarmConsumer consumer = BuildConsumer(source); consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); - int rc = consumer.AcknowledgeByGuid( - alarmGuid: Guid.NewGuid(), - ackComment: "should not write", - ackOperatorName: "op", - ackOperatorNode: "WS01", - ackOperatorDomain: "CORP", - ackOperatorFullName: "Operator"); - - Assert.NotEqual(0, rc); - Assert.Null(source.LastWrite); + Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure); } private sealed class FakeSource : ISubtagAlarmSource @@ -291,21 +251,12 @@ public sealed class SubtagAlarmConsumerTests /// Gets the subtag addresses passed to . public List Advised { get; } = new List(); - /// Gets the most recent (address, value) pair passed to . - public (string Address, object? Value)? LastWrite { get; private set; } - /// public void Advise(IReadOnlyCollection itemAddresses) { Advised.AddRange(itemAddresses); } - /// - public void Write(string itemAddress, object? value) - { - LastWrite = (itemAddress, value); - } - /// Raises a for the given subtag. /// The subtag address whose value changed. /// The new value. diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs index 3941a21..1d51f0e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -824,4 +824,58 @@ public sealed class WnWrapAlarmConsumerXmlTests Assert.Equal("mgack-authwrite 180714", record.AckComment); Assert.Equal("Test alarm #1", record.AlarmComment); } + + /// A Galaxy-provider record resolves to its <tag>.AckMsg item; an unknown GUID is not found. + [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); + } + + /// A non-Galaxy provider has no MXAccess ack attribute, so it is not ackable. + [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); + } + + /// By-name resolution needs no snapshot: a Galaxy name maps straight to its AckMsg item. + [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 next = new Dictionary(); + foreach (MxAlarmSnapshotRecord record in records) + { + next[record.AlarmGuid] = record; + } + + consumer.FoldFetch(next, truncated: false, out _); + return consumer; + } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs index 9fb9254..57ab552 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs @@ -2,7 +2,7 @@ // // Validates the subtag-fallback pipeline against a real Galaxy + MXAccess install: // LmxSubtagAlarmSource (own LMXProxyServerClass) -> -// SubtagAlarmConsumer (state machine + AcknowledgeByName write) -> +// SubtagAlarmConsumer (state machine + ResolveAckTargetByName) + AlarmAckWriter (AckMsg write) -> // synthesized MxAlarmTransitionEvent (Raise / Clear, Degraded=true, SyntheticGuid) // // FIELD NAMES CONFIRMED: InAlarm/Acked/AckMsg/Priority are the confirmed AVEVA @@ -49,8 +49,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; /// synthesize records from Galaxy /// alarm subtags using the confirmed AVEVA AlarmExtension field /// names (InAlarm/Acked/AckMsg/Priority), and that -/// writes the -/// ack-comment subtag (AckMsg) successfully. +/// resolves the +/// ack-comment subtag (AckMsg) that then +/// writes successfully. /// /// Skip-gated; flip Skip=null on the dev rig with an alarm being /// driven (flip script or a manual operator/IDE toggle of the alarm @@ -119,7 +120,7 @@ public sealed class AlarmSubtagLiveSmokeTests /// real LMXProxyServerClass, collects a Raise then a Clear /// transition synthesized by , confirms /// 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. /// [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() @@ -257,17 +258,9 @@ public sealed class AlarmSubtagLiveSmokeTests Log("InAlarm subtag delivered a value: " + inAlarmResolved); // Decisive write: AckMsg. Returns 0 only if the address resolves and is writable. - Log("AcknowledgeByName (writes AckMsg subtag) ..."); - int rc = consumer.AcknowledgeByName( - alarmName: AlarmTagName, - 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)); + Log("Acknowledge (writes AckMsg subtag via AlarmAckWriter) ..."); + int rc = AckViaWriter(consumer, "subtag-resolution-probe").NativeStatus; + Log(string.Format("Ack (AckMsg write) -> rc={0}", rc)); DateTime settle = DateTime.UtcNow + TimeSpan.FromSeconds(2); while (DateTime.UtcNow < settle) @@ -392,21 +385,13 @@ public sealed class AlarmSubtagLiveSmokeTests Assert.True(foundInSnapshot, 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 // TryParseReference logic as AlarmsLiveSmokeTests. - Log("Calling AcknowledgeByName ..."); - int rc = consumer.AcknowledgeByName( - 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("Acknowledging via AlarmAckWriter ..."); + int rc = AckViaWriter(consumer, "subtag-fallback-smoke ack").NativeStatus; - Log(string.Format("AcknowledgeByName -> rc={0}", rc)); + Log(string.Format("Ack -> rc={0}", rc)); Assert.Equal(0, rc); // 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 /// docs/MxAccessWorkerInstanceDesign.md. /// + /// + /// Resolves the probe alarm's ack item through the consumer and writes it + /// with as the operator named by + /// MXGATEWAY_PROBE_ACK_USER/MXGATEWAY_PROBE_ACK_PASSWORD + /// (default: current user, empty password). + /// + 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() { NativeMethods.MSG msg; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs index 0b94d55..1b8f765 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs @@ -41,7 +41,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Threading. Like its children, this type is driven /// entirely on the worker's STA: , /// , , and the -/// AcknowledgeBy* calls are all invoked from the apartment that +/// ResolveAckTarget* calls are all invoked from the apartment that /// owns the underlying COM objects. It owns no locks of its own and no /// internal timer; the worker drives on a timer. /// @@ -259,34 +259,17 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer } /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); - return ActiveChild.AcknowledgeByGuid( - alarmGuid, ackComment, ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName); + return ActiveChild.ResolveAckTarget(alarmGuid); } /// - public int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) { if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); - return ActiveChild.AcknowledgeByName( - alarmName, providerName, groupName, ackComment, - ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName); + return ActiveChild.ResolveAckTargetByName(alarmName, providerName, groupName); } /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs index d5b2b14..703d164 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs @@ -49,51 +49,19 @@ public interface IMxAccessAlarmConsumer : IDisposable void Subscribe(string subscription); /// - /// Acknowledges a single alarm with full operator-identity fidelity. - /// Reaches AVEVA's native AlarmAckByGUID; operator - /// user / node / domain / full-name and the comment land atomically - /// with the ack transition in the alarm-history log. + /// Resolves the MXAccess item whose write acknowledges the alarm with the + /// given GUID (e.g. Obj.Alarm.AckMsg). Does not perform the ack. /// - /// The alarm GUID. - /// The acknowledgment comment. - /// The operator name. - /// The operator node. - /// The operator domain. - /// The operator full name. - /// The AVEVA-native status code. - int AcknowledgeByGuid( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName); + /// The alarm GUID as reported by this consumer. + /// The resolved target or the reason none exists. + AlarmAckTarget ResolveAckTarget(Guid alarmGuid); - /// - /// Acknowledge a single alarm by its (name, provider, group) tuple. - /// Reaches AVEVA's AlarmAckByName on - /// wwAlarmConsumerClass; same alarm-history outcome as - /// , used when the caller has the - /// human-readable reference but not the canonical GUID. - /// - /// The alarm name. - /// The provider name. - /// The group name. - /// The acknowledgment comment. - /// The operator name. - /// The operator node. - /// The operator domain. - /// The operator full name. - /// The AVEVA-native status code. - int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName); + /// Resolves the ack item for an alarm identified by name, provider and group. + /// The tag/alarm name, e.g. TestMachine_001.TestAlarm001. + /// The provider (literal Galaxy for ArchestrA). + /// The area/group. + /// The resolved target or the reason none exists. + AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName); /// /// Returns the consumer's most recently parsed snapshot of currently diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs index 75b4873..ef160c2 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs @@ -16,9 +16,4 @@ public interface ISubtagAlarmSource : IDisposable /// Begins advising the supplied MXAccess subtag item addresses. /// The subtag references to advise. void Advise(IReadOnlyCollection itemAddresses); - - /// Writes a value to the named MXAccess subtag (e.g. an ack request). - /// The subtag reference to write. - /// The value to write. - void Write(string itemAddress, object? value); } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs index 9fa0cd8..64a3fa8 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs @@ -9,8 +9,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Production backed by its own MXAccess /// LMXProxyServerClass COM object. It advises a set of alarm subtag /// item addresses, normalizes each OnDataChange COM callback into a -/// , and supports writing a value (e.g. an -/// ack-comment) to a subtag. +/// . It is observe-only: alarm +/// acknowledgement goes through , which +/// authenticates the operator on its own server. /// /// /// @@ -25,7 +26,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Threading. The LMXProxyServerClass CLSID is /// apartment-threaded and delivers OnDataChange via the STA /// message pump. Every method on this type — the lazy -/// creation, , and — must be +/// creation and — must be /// invoked on the worker's STA that created the source, exactly as /// and /// require. The OnDataChange COM event also arrives on that STA. @@ -47,9 +48,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource new Dictionary(); // Handles that were actually Advise()d, tracked separately from the added - // set so Dispose only UnAdvises advised items. Write() can AddItem a - // write-only subtag (e.g. an ack-comment that was never advised); calling - // UnAdvise on such a handle would be an unbalanced teardown. + // set so Dispose only UnAdvises items this source advised. private readonly HashSet advisedItemHandles = new HashSet(); private object? mxAccessComObject; @@ -85,7 +84,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource /// with a pre-registered server handle, /// bypassing the live COM factory. Exposed via /// InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests") so tests can - /// exercise / and + /// exercise and /// without a live LMXProxyServerClass. /// No COM event source is wired in this mode — tests drive /// directly to simulate an @@ -140,29 +139,6 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource } } - /// - 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); - } - /// /// Normalizes an MXAccess OnDataChange callback into a /// and raises @@ -223,8 +199,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource { foreach (KeyValuePair entry in addressesByItemHandle) { - // Only UnAdvise handles that were actually advised; a write-only - // item (added by Write but never Advise'd) was never advised. + // Only UnAdvise handles that were actually advised. if (advisedItemHandles.Contains(entry.Key)) { try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs index cca921d..4d9257b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs @@ -27,9 +27,11 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// The subtag path is event-driven, so is a /// no-op — value changes arrive through the source's advise stream -/// rather than an explicit poll. Acknowledgment in subtag mode writes -/// the comment to the target's writable ack-comment subtag rather than -/// calling a native AlarmAckByGUID / AlarmAckByName. +/// rather than an explicit poll. Acknowledgment in subtag mode resolves +/// to the target's writable ack-comment subtag +/// (); +/// performs the operator-authenticated write on its own MXAccess +/// server, so this consumer neither advises nor writes that subtag. /// /// 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 /// alarm targets. /// - /// The subtag value source to advise and write through. + /// The subtag value source to advise. /// The alarm subtag targets to observe. /// /// Thrown when or @@ -91,7 +93,6 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer AddIfNotEmpty(addresses, target.ActiveSubtag); AddIfNotEmpty(addresses, target.AckedSubtag); AddIfNotEmpty(addresses, target.PrioritySubtag); - AddIfNotEmpty(addresses, target.AckCommentSubtag); } source.Advise(addresses); @@ -104,13 +105,7 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer } /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { if (disposed) { @@ -120,22 +115,14 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) || !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); } /// - public int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) { if (disposed) { @@ -143,12 +130,9 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer } AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName); - if (target is null) - { - return -1; - } - - return WriteAckComment(target, ackComment); + return target is null + ? AlarmAckTarget.NotFound($"Alarm '{providerName}!{groupName}.{alarmName}' is not on the subtag watch list.") + : ToAckTarget(target); } /// @@ -260,16 +244,12 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer return target; } - private int WriteAckComment(AlarmSubtagTarget target, string ackComment) + private static AlarmAckTarget ToAckTarget(AlarmSubtagTarget target) { - string ackCommentSubtag = target.AckCommentSubtag ?? string.Empty; - if (string.IsNullOrEmpty(ackCommentSubtag)) - { - return -1; - } - - source.Write(ackCommentSubtag, ackComment); - return 0; + string ackItem = target.AckCommentSubtag ?? string.Empty; + return ackItem.Length == 0 + ? AlarmAckTarget.NotAckable($"Alarm '{target.AlarmFullReference}' has no ack-comment subtag configured.") + : AlarmAckTarget.For(ackItem); } private static void AddIfNotEmpty(List addresses, string? address) diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index f109dc3..85089ea 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -34,7 +34,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// and operated from an STA thread; the worker's /// runs an STA pump that hosts it. /// The consumer owns no internal timer: every COM call -/// (Subscribe, PollOnce, AcknowledgeBy*) must +/// (Subscribe, PollOnce) must /// be invoked on the STA that created the consumer. Polling cadence /// is driven externally by the worker's STA via /// StaRuntime.InvokeAsync(() => consumer.PollOnce()), which @@ -43,6 +43,15 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// off the owning STA and can deadlock on cross-apartment marshaling /// when the STA is not pumping messages, so no such timer exists. /// +/// +/// Acknowledgement. This consumer only receives +/// alarms. The wnwrap AlarmAckBy* calls were accepted and then +/// inert on the dev rig (see docs/AlarmProbeFindings.md), so +/// acknowledgement no longer goes through wnwrap: the consumer resolves +/// a Galaxy alarm to its <tag>.AckMsg item +/// () and +/// writes that item over MXAccess as the authenticated operator. +/// /// 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 DefaultVersion = "1.0"; private const int DefaultMaxAlarmsPerFetch = 1024; + private const string GalaxyProviderName = "Galaxy"; /// /// Floor on the resolved per-fetch cap. Mirrors the gateway-side @@ -93,7 +103,6 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer private long truncatedFetchCount; private bool lastSnapshotTruncated; private wwAlarmConsumerClass? client; - private wwAlarmConsumerClass? ackClient; private bool subscribed; private bool disposed; @@ -200,7 +209,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer // WIN-911-style code uses against the same wnwrap library. // Note rc=0 means the call was // 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); if (init != 0) { @@ -241,10 +250,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer // SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md), // calling it is necessary for subsequent GetXmlCurrentAlarms2 // calls to succeed. Without it, GetXmlCurrentAlarms2 returns - // E_FAIL (HRESULT 0x80004005) on the first poll. SetXmlAlarmQuery - // 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). + // E_FAIL (HRESULT 0x80004005) on the first poll. // // The wnwrap interop signature is `void SetXmlAlarmQuery(string)` // — there is no integer return code to gate on like the other v1 @@ -268,116 +274,49 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer 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; } } /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); + MxAlarmSnapshotRecord? record; + lock (syncRoot) + { + latestSnapshot.TryGetValue(alarmGuid, out record); + } - wwAlarmConsumerClass com = client - ?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); + if (record is null) + { + return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not in the current alarm snapshot."); + } - // VBGUID is wnwrap's GUID interop struct (same memory layout as - // 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); + return ResolveGalaxyAckTarget(record.ProviderName, record.TagName); } /// - public int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) { if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); + return ResolveGalaxyAckTarget(providerName, alarmName); + } - // Use the parallel ack-only consumer (no SetXmlAlarmQuery applied) - // — see docs/AlarmClientDiscovery.md "Option A — captured" for the - // empirical justification. - wwAlarmConsumerClass com = ackClient - ?? throw new InvalidOperationException( - "Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet."); + private static AlarmAckTarget ResolveGalaxyAckTarget(string? providerName, string? tagName) + { + if (!string.Equals(providerName, GalaxyProviderName, StringComparison.OrdinalIgnoreCase)) + { + return AlarmAckTarget.NotAckable( + $"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 - // it read: the IwwAlarmConsumer2 8-arg AlarmAckByName returned -55 - // "and looked like a stub" while the legacy 6-arg - // 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 - // 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); + if (string.IsNullOrWhiteSpace(tagName)) + { + return AlarmAckTarget.NotFound("Alarm record has no tag name."); + } + + return AlarmAckTarget.For(tagName + "." + AlarmAckWriter.AckCommentAttribute); } /// @@ -944,37 +883,18 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer 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; - } - /// public void Dispose() { wwAlarmConsumerClass? clientToDispose; - wwAlarmConsumerClass? ackClientToDispose; lock (syncRoot) { if (disposed) return; disposed = true; clientToDispose = client; client = null; - ackClientToDispose = ackClient; - ackClient = null; } ReleaseConsumerCom(clientToDispose); - ReleaseConsumerCom(ackClientToDispose); } private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer)