fix(galaxy): picker-authored native alarms resolve via the feed's source reference (#895)
The gateway alarm feed names an alarm "Galaxy!<Area>.<tag>.<Attr>". The AdminUI Galaxy picker authors attributeRef "<tag>.<Attr>". OnAlarmFeedTransition reverse-mapped only the full reference, so a picker-authored native alarm never matched its condition and never went active. Its ack also dialled the bare reference, which AcknowledgeAlarm refuses. ResolveAlarmRawPath now matches the full reference, then SourceObjectReference, then the full reference without its Provider!Area. prefix. A fallback match records the full reference per RawPath, and AcknowledgeAsync dials it, so the gateway accepts the ack and the #869 confirmation watch matches. Found and re-verified live in the #615 G6 gate on the windev test Galaxy. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -101,6 +101,15 @@ public sealed class GalaxyDriver
|
||||
private readonly Dictionary<string, string> _attributeRefByRawPath;
|
||||
private readonly Dictionary<string, RawTagEntry> _rawTagByAttributeRef;
|
||||
|
||||
// lmxopcua#895: the gateway alarm feed names an alarm by its provider-qualified reference
|
||||
// ("Galaxy!<Area>.<tag_name>.<Attribute>"), but a tag authored through the AdminUI Galaxy picker carries
|
||||
// the plain attributeRef ("<tag_name>.<Attribute>"). 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<string, string> _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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Galaxy!Area.tag.Attr</c> form), its <c>SourceObjectReference</c>
|
||||
/// (<c>tag.Attr</c> — the form the AdminUI Galaxy picker authors), and the full reference with its
|
||||
/// <c>Provider!Area.</c> prefix stripped. Identity on a miss, as before. A match through either
|
||||
/// fallback records the full reference for <see cref="ToAlarmAckReference"/>.
|
||||
/// </summary>
|
||||
/// <param name="alarmFullReference">The feed's provider-qualified alarm reference.</param>
|
||||
/// <param name="sourceObjectReference">The feed's source reference (<c>tag.Attr</c>), may be empty.</param>
|
||||
/// <returns>The authored RawPath, or <paramref name="alarmFullReference"/> when no authored tag matches.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reference an acknowledge of the condition <paramref name="rawPath"/> 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).
|
||||
/// </summary>
|
||||
/// <param name="rawPath">The condition's RawPath (its ConditionId).</param>
|
||||
/// <returns>The gateway alarm reference to acknowledge.</returns>
|
||||
internal string ToAlarmAckReference(string rawPath) =>
|
||||
_alarmFullRefByRawPath.TryGetValue(rawPath, out var full) ? full : ToAttributeRef(rawPath);
|
||||
|
||||
/// <summary><c>Galaxy!Area.tag.Attr</c> → <c>tag.Attr</c>; null when the reference has no <c>!</c> provider
|
||||
/// prefix or nothing follows the area segment.</summary>
|
||||
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)..];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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,
|
||||
|
||||
+60
@@ -146,6 +146,66 @@ public sealed class GalaxyDriverRawPathKeyingTests
|
||||
ack.Calls[0].AlarmRef.ShouldBe(AttributeRef);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#895 (G6 live gate, windev 2026-11-12): the real gateway feed names an alarm
|
||||
/// <c>Galaxy!Area.tag.Attr</c> with <c>SourceObjectReference = tag.Attr</c>, while the AdminUI Galaxy
|
||||
/// picker authors <c>attributeRef = tag.Attr</c>. 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.
|
||||
/// </summary>
|
||||
[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<AlarmEventArgs>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#895: with no usable SourceObjectReference the <c>Provider!Area.</c> prefix is stripped
|
||||
/// to find the picker-authored tag.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
[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)
|
||||
|
||||
Reference in New Issue
Block a user