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:
@@ -6,6 +6,77 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
/// <summary>Task-11: pure OPC UA A&C field → AlarmConditionState/transition mapping.</summary>
|
||||
public class OpcUaAlarmMapperTests
|
||||
{
|
||||
// ── Gitea #17: routing identity must be the binding, not the event name ──
|
||||
|
||||
[Fact]
|
||||
public void BuildIdentity_NodeIdBinding_RoutesOnBindingNotName()
|
||||
{
|
||||
// OtOpcUa v3 post-#473: SourceName is the RawPath, ConditionName the leaf. The
|
||||
// binding is a durable node reference. The bug was SourceObjectReference = SourceName,
|
||||
// which never prefix-matches a NodeId binding, so every transition was dropped.
|
||||
var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity(
|
||||
subscriptionSourceReference: "nsu=https://zb.com/otopcua/raw;s=pymodbus/plc/HR200",
|
||||
sourceName: "pymodbus/plc/HR200",
|
||||
conditionName: "HR200");
|
||||
|
||||
// Routing identity == the binding, verbatim → DataConnectionActor exact-matches.
|
||||
Assert.Equal("nsu=https://zb.com/otopcua/raw;s=pymodbus/plc/HR200", sourceObjectRef);
|
||||
// Per-condition key stays the readable RawPath + condition, kept distinct.
|
||||
Assert.Equal("pymodbus/plc/HR200.HR200", sourceRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildIdentity_LegacyIndexBinding_RoutesVerbatim()
|
||||
{
|
||||
// A binding stored in the old ns= form routes just as well — the point of using
|
||||
// the binding verbatim is that it is form-agnostic.
|
||||
var (_, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity(
|
||||
subscriptionSourceReference: "ns=2;s=pymodbus/plc/HR200",
|
||||
sourceName: "pymodbus/plc/HR200",
|
||||
conditionName: "HR200");
|
||||
|
||||
Assert.Equal("ns=2;s=pymodbus/plc/HR200", sourceObjectRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildIdentity_AggregateFeed_StaysEmpty()
|
||||
{
|
||||
// Empty binding = the Server-object aggregate feed: it must reach only "mirror
|
||||
// everything" subscribers (StartsWith("") matches all) and never a specific-node
|
||||
// binding, so the routing identity stays empty while the per-condition key is real.
|
||||
var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity(
|
||||
subscriptionSourceReference: "",
|
||||
sourceName: "pymodbus/plc/HR200",
|
||||
conditionName: "HR200");
|
||||
|
||||
Assert.Equal("", sourceObjectRef);
|
||||
Assert.Equal("pymodbus/plc/HR200.HR200", sourceRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildIdentity_NoConditionName_UsesSourceNameAlone()
|
||||
{
|
||||
var (sourceRef, _) = OpcUaAlarmMapper.BuildIdentity(
|
||||
subscriptionSourceReference: "nsu=urn:x;s=Tank01",
|
||||
sourceName: "Tank01",
|
||||
conditionName: null);
|
||||
|
||||
Assert.Equal("Tank01", sourceRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildIdentity_NoSourceName_FallsBackToBindingForKey()
|
||||
{
|
||||
// A server that leaves SourceName unset still yields a stable, non-empty key.
|
||||
var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity(
|
||||
subscriptionSourceReference: "ns=2;s=X",
|
||||
sourceName: null,
|
||||
conditionName: "Hi");
|
||||
|
||||
Assert.Equal("ns=2;s=X.Hi", sourceRef);
|
||||
Assert.Equal("ns=2;s=X", sourceObjectRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeSeverity_ClampsTo0_1000()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user