diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 6c4756ded..16976b42a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -101,6 +101,15 @@ public sealed class GalaxyDriver private readonly Dictionary _attributeRefByRawPath; private readonly Dictionary _rawTagByAttributeRef; + // lmxopcua#895: the gateway alarm feed names an alarm by its provider-qualified reference + // ("Galaxy!.."), but a tag authored through the AdminUI Galaxy picker carries + // the plain attributeRef ("."). When a transition resolves through its + // SourceObjectReference instead of the full reference, the full reference is remembered here per RawPath + // so an acknowledge of that condition dials the reference the gateway's AcknowledgeAlarm accepts (it + // refuses a non-provider reference) and the #869 confirmation watch matches the feed's transitions. + private readonly System.Collections.Concurrent.ConcurrentDictionary _alarmFullRefByRawPath = + new(StringComparer.OrdinalIgnoreCase); + // PR 4.4 — subscription lifecycle. The pump consumes the gw event stream and fans // out OnDataChange events to every registered driver subscription via the registry's // reverse map. The subscriber is the test seam — production uses @@ -464,6 +473,58 @@ public sealed class GalaxyDriver private string ToRawPath(string attributeRef) => _rawTagByAttributeRef.TryGetValue(attributeRef, out var e) ? e.RawPath : attributeRef; + /// + /// Resolves the RawPath an alarm-feed transition belongs to (lmxopcua#895). The authored attributeRef + /// is matched against, in order: the transition's full reference (a tag authored with the + /// provider-qualified Galaxy!Area.tag.Attr form), its SourceObjectReference + /// (tag.Attr — the form the AdminUI Galaxy picker authors), and the full reference with its + /// Provider!Area. prefix stripped. Identity on a miss, as before. A match through either + /// fallback records the full reference for . + /// + /// The feed's provider-qualified alarm reference. + /// The feed's source reference (tag.Attr), may be empty. + /// The authored RawPath, or when no authored tag matches. + internal string ResolveAlarmRawPath(string alarmFullReference, string? sourceObjectReference) + { + if (_rawTagByAttributeRef.TryGetValue(alarmFullReference, out var direct)) return direct.RawPath; + + if (!string.IsNullOrEmpty(sourceObjectReference) + && _rawTagByAttributeRef.TryGetValue(sourceObjectReference, out var bySource)) + { + _alarmFullRefByRawPath[bySource.RawPath] = alarmFullReference; + return bySource.RawPath; + } + + var stripped = StripProviderAndArea(alarmFullReference); + if (stripped is not null && _rawTagByAttributeRef.TryGetValue(stripped, out var byStripped)) + { + _alarmFullRefByRawPath[byStripped.RawPath] = alarmFullReference; + return byStripped.RawPath; + } + + return alarmFullReference; + } + + /// + /// The reference an acknowledge of the condition dials: the provider-qualified + /// reference the alarm feed reported for it when one was learned (lmxopcua#895), otherwise the authored + /// attributeRef (identity on a miss). + /// + /// The condition's RawPath (its ConditionId). + /// The gateway alarm reference to acknowledge. + internal string ToAlarmAckReference(string rawPath) => + _alarmFullRefByRawPath.TryGetValue(rawPath, out var full) ? full : ToAttributeRef(rawPath); + + /// Galaxy!Area.tag.Attr → tag.Attr; null when the reference has no ! provider + /// prefix or nothing follows the area segment. + private static string? StripProviderAndArea(string alarmFullReference) + { + var bang = alarmFullReference.IndexOf('!'); + if (bang < 0) return null; + var dot = alarmFullReference.IndexOf('.', bang + 1); + return dot < 0 || dot == alarmFullReference.Length - 1 ? null : alarmFullReference[(dot + 1)..]; + } + /// public string DriverInstanceId => _driverInstanceId; @@ -2634,7 +2695,7 @@ public sealed class GalaxyDriver var conditionReference = !string.IsNullOrEmpty(ack.ConditionId) ? ack.ConditionId : ack.SourceNodeId; - var alarmFullReference = ToAttributeRef(conditionReference); + var alarmFullReference = ToAlarmAckReference(conditionReference); // lmxopcua#749: the gateway acknowledges as a real Galaxy account (AuthenticateUser + // AckMsg write), and the OPC UA session has no Galaxy password. Resolve the account mapped @@ -2783,9 +2844,10 @@ public sealed class GalaxyDriver // v3: the ConditionId the driver reports must be the tag's RawPath — the same key the value // fan-out uses (OnPumpDataChange) and the identity the discoverer stamped as SourceName — so the // server-side condition state correlates the alarm to its node and a subsequent ack round-trips - // back through AcknowledgeAsync. The feed reports the Galaxy attributeRef; reverse-map it (identity - // on a miss). - var conditionRawPath = ToRawPath(transition.AlarmFullReference); + // back through AcknowledgeAsync. The feed reports the provider-qualified reference + // (Galaxy!Area.tag.Attr); reverse-map it to the authored tag, also through the plain tag.Attr form the + // AdminUI picker authors (lmxopcua#895), identity on a miss. + var conditionRawPath = ResolveAlarmRawPath(transition.AlarmFullReference, transition.SourceObjectReference); var args = new AlarmEventArgs( SubscriptionHandle: handle, SourceNodeId: transition.SourceObjectReference, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs index 23f3061fe..d6db935be 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs @@ -146,6 +146,66 @@ public sealed class GalaxyDriverRawPathKeyingTests ack.Calls[0].AlarmRef.ShouldBe(AttributeRef); } + /// + /// lmxopcua#895 (G6 live gate, windev 2026-11-12): the real gateway feed names an alarm + /// Galaxy!Area.tag.Attr with SourceObjectReference = tag.Attr, while the AdminUI Galaxy + /// picker authors attributeRef = tag.Attr. The transition must resolve to the tag's RawPath (before + /// the fix it surfaced under the full reference, which matched no condition, so the alarm never went + /// active), and the ack must dial the provider-qualified reference the gateway accepts. + /// + [Fact] + public async Task Alarm_PickerAuthoredAttributeRef_ResolvesViaSourceObjectReference_AndAcksFullReference() + { + const string fullRef = "Galaxy!Area1.Tank1.Level"; + var feed = new FakeAlarmFeed(); + var ack = new RecordingAcknowledger(); + using var driver = new GalaxyDriver( + "g", OptsWithRawTags(), hierarchySource: null, alarmAcknowledger: ack, alarmFeed: feed, + ackCredentialResolver: FixedAckCredentialResolver.Fallback()); + await driver.SubscribeAlarmsAsync(["Tank1"], CancellationToken.None); + var observed = new List(); + driver.OnAlarmEvent += (_, args) => observed.Add(args); + + feed.Emit(NewTransition(fullRef, AttributeRef)); + + observed.ShouldHaveSingleItem(); + observed[0].ConditionId.ShouldBe(RawPath); + + await driver.AcknowledgeAsync( + [new AlarmAcknowledgeRequest("Tank1", RawPath, "ack it")], CancellationToken.None); + + ack.Calls.ShouldHaveSingleItem(); + ack.Calls[0].AlarmRef.ShouldBe(fullRef); + } + + /// lmxopcua#895: with no usable SourceObjectReference the Provider!Area. prefix is stripped + /// to find the picker-authored tag. + [Fact] + public void ResolveAlarmRawPath_StripsProviderAndArea_WhenSourceReferenceDoesNotMatch() + { + using var driver = new GalaxyDriver("g", OptsWithRawTags(), hierarchySource: null); + + driver.ResolveAlarmRawPath("Galaxy!Area1.Tank1.Level", sourceObjectReference: "").ShouldBe(RawPath); + driver.ToAlarmAckReference(RawPath).ShouldBe("Galaxy!Area1.Tank1.Level"); + } + + /// lmxopcua#895: a tag authored with the full provider-qualified reference still resolves directly, + /// and an alarm no authored tag matches keeps the identity fallback (nothing learned). + [Fact] + public void ResolveAlarmRawPath_DirectMatchAndUnmatchedIdentity_AreUnchanged() + { + var opts = OptsWithRawTags() with + { + RawTags = [new RawTagEntry(RawPath, "{\"attributeRef\":\"Galaxy!Area1.Tank1.Level\"}", false)], + }; + using var driver = new GalaxyDriver("g", opts, hierarchySource: null); + + driver.ResolveAlarmRawPath("Galaxy!Area1.Tank1.Level", "Tank1.Level").ShouldBe(RawPath); + driver.ToAlarmAckReference(RawPath).ShouldBe("Galaxy!Area1.Tank1.Level"); + driver.ResolveAlarmRawPath("Galaxy!Area1.Other.Attr", "Other.Attr").ShouldBe("Galaxy!Area1.Other.Attr"); + driver.ToAlarmAckReference("Galaxy!Area1.Other.Attr").ShouldBe("Galaxy!Area1.Other.Attr"); + } + // ===== helpers / fakes ===== private static GalaxyAttribute Attr(string name, int sec)