fix(dcl): route native OPC UA alarms by binding identity, not event name

A native OPC UA alarm source's SourceReference has to be two things at once:
it is parsed as a NodeId to open the monitored item, and matched as a plain-name
prefix against the event's SourceName to route the transition to an instance. No
string is both, so a NodeId-form binding subscribed correctly and then silently
dropped every transition — "pymodbus/plc/HR200".StartsWith("nsu=...;s=pymodbus/plc/HR200")
is false. Only the empty (Server-object) binding worked, because StartsWith("")
matches everything, which is why the sole live smoke test never caught it.

Each OPC UA alarm feed is opened for exactly one binding, so every transition on
it belongs to that binding. The adapter now tags each transition's routing
identity (SourceObjectReference) with the binding string verbatim via the pure
OpcUaAlarmMapper.BuildIdentity, making DataConnectionActor's routing key an exact
match regardless of whether the binding is stored as ns=<index> or the durable
nsu=<uri> form. The Server-object aggregate feed keeps an empty routing identity,
so it reaches only "mirror everything" subscribers and never leaks into a
specific-node binding. The per-condition SourceReference key stays the readable
SourceName.ConditionName, so persistence and display are unchanged, and MxGateway
is untouched — its bindings are names and its mapper already emits matching names.

Unblocked by lmxopcua#473 (OtOpcUa now populates SourceNode/SourceName/EventType
on conditions); SourceName is the RawPath, so the per-condition key is unique.
Live end-to-end verification against native alarms still needs a v3 rig.

Fixes: Gitea #17
This commit is contained in:
Joseph Doherty
2026-07-17 14:02:27 -04:00
parent d32050da24
commit 846cc5d96d
5 changed files with 163 additions and 7 deletions
@@ -12,6 +12,45 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// </summary>
public static class OpcUaAlarmMapper
{
/// <summary>
/// Derives the two identity strings a native OPC UA alarm transition carries:
/// the routing identity that <c>DataConnectionActor</c> matches against the stored
/// binding, and the per-condition key used for state, persistence and display.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why the routing identity is the binding string, not the event's name (Gitea #17).</b>
/// Routing does <c>transition.SourceObjectReference.StartsWith(bindingRef)</c>, where the
/// binding is a NodeId reference (the picker, CSV and config all store NodeIds). Deriving
/// the routing identity from the event's <c>SourceName</c> — a plain name like the RawPath
/// — never prefix-matches a NodeId binding, so every transition was silently dropped.
/// Each OPC UA alarm feed is opened for exactly one binding, so every transition on it
/// belongs to that binding: tagging the transition with <paramref name="subscriptionSourceReference"/>
/// verbatim makes the routing key an exact match, independent of whether the binding is
/// stored as <c>ns=&lt;index&gt;</c> or the durable <c>nsu=&lt;uri&gt;</c> form. An empty binding
/// (the Server-object aggregate feed) stays empty, so it reaches only the "mirror everything"
/// subscribers and never leaks into a specific-node binding.
/// </para>
/// <para>
/// The per-condition key keeps the human-readable <paramref name="sourceName"/> (the RawPath
/// for OtOpcUa) plus <paramref name="conditionName"/> so two conditions on one source stay
/// distinct; it falls back to the binding reference only when the server leaves SourceName
/// unset.
/// </para>
/// </remarks>
/// <param name="subscriptionSourceReference">The binding string this alarm feed was subscribed under (empty for the Server-object aggregate feed).</param>
/// <param name="sourceName">The event's <c>SourceName</c> field (the RawPath for OtOpcUa post-#473), or null/empty when unset.</param>
/// <param name="conditionName">The event's <c>ConditionName</c> field, or null/empty when unset.</param>
/// <returns>The per-condition <c>SourceReference</c> key and the <c>SourceObjectReference</c> routing identity.</returns>
public static (string SourceReference, string SourceObjectReference) BuildIdentity(
string? subscriptionSourceReference, string? sourceName, string? conditionName)
{
var routingRef = subscriptionSourceReference ?? "";
var keyBase = !string.IsNullOrEmpty(sourceName) ? sourceName : routingRef;
var sourceRef = string.IsNullOrEmpty(conditionName) ? keyBase : $"{keyBase}.{conditionName}";
return (sourceRef, routingRef);
}
/// <summary>Clamps an OPC UA severity (11000, sometimes out of range) to the unified 01000 scale.</summary>
/// <param name="severity">The raw OPC UA severity value to clamp.</param>
/// <returns>The severity clamped to the range [0, 1000].</returns>
@@ -494,7 +494,9 @@ public class RealOpcUaClient : IOpcUaClient
item.Notification += (_, e) =>
{
if (e.NotificationValue is EventFieldList efl)
HandleAlarmEvent(handle, efl, onTransition);
// sourceNodeId is the binding this feed was subscribed under; every
// transition on the feed is routed under it verbatim (see #17).
HandleAlarmEvent(handle, sourceNodeId, efl, onTransition);
};
_subscription.AddItem(item);
@@ -724,7 +726,9 @@ public class RealOpcUaClient : IOpcUaClient
}
}
private void HandleAlarmEvent(string handle, EventFieldList efl, Action<NativeAlarmTransition> onTransition)
private void HandleAlarmEvent(
string handle, string? subscriptionSourceReference, EventFieldList efl,
Action<NativeAlarmTransition> onTransition)
{
var fields = efl.EventFields;
if (fields == null || fields.Count < AlarmStateFields.Length)
@@ -746,14 +750,16 @@ public class RealOpcUaClient : IOpcUaClient
}
// Field layout (AlarmStateFields): [1]=SourceNode (NodeId), [2]=SourceName (string).
// Prefer the human-readable SourceName; fall back to the SourceNode NodeId string
// only when SourceName is absent/empty, so the condition still has a stable key.
// The routing identity is the binding this feed was subscribed under (not the
// event's name), so DataConnectionActor's NodeId-keyed routing matches see #17
// and OpcUaAlarmMapper.BuildIdentity. SourceName seeds the readable per-condition
// key; fall back to the SourceNode NodeId string only when it is absent.
var sourceName = fields[2].Value as string;
if (string.IsNullOrEmpty(sourceName))
sourceName = (fields[1].Value as NodeId)?.ToString() ?? "";
var conditionName = fields.Count > 11 ? fields[11].Value as string : null;
var sourceObjectRef = sourceName;
var sourceRef = string.IsNullOrEmpty(conditionName) ? sourceName : $"{sourceName}.{conditionName}";
var (sourceRef, sourceObjectRef) =
OpcUaAlarmMapper.BuildIdentity(subscriptionSourceReference, sourceName, conditionName);
if (string.IsNullOrEmpty(sourceRef))
return; // not a condition event we can key