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
@@ -67,6 +67,44 @@ public class DataConnectionActorAlarmTests : TestKit
ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.SourceObjectReference == "Tank01");
}
[Fact]
public void SubscribeAlarms_NodeIdBinding_RoutesTransition()
{
// Gitea #17 end-to-end: an instance binds a native alarm source by NodeId (the form
// the picker / CSV / config store). The adapter builds the transition's identity via
// OpcUaAlarmMapper.BuildIdentity(binding, SourceName, ConditionName). With the old
// "SourceObjectReference = SourceName" logic the event's name ("pymodbus/plc/HR200")
// never prefix-matches the NodeId binding, so this transition was dropped. Now the
// routing identity IS the binding, so it delivers.
const string binding = "nsu=https://zb.com/otopcua/raw;s=pymodbus/plc/HR200";
AlarmTransitionCallback? cb = null;
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
((IAlarmSubscribableConnection)adapter)
.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
Arg.Do<AlarmTransitionCallback>(c => cb = c), Arg.Any<CancellationToken>())
.Returns(Task.FromResult("alarm-sub-1"));
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
"conn", adapter, _options, _health, _factory, "OpcUa")));
actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", binding, null, DateTimeOffset.UtcNow));
ExpectMsg<SubscribeAlarmsResponse>(m => m.Success);
Assert.NotNull(cb);
var (sourceRef, sourceObjectRef) = DataConnectionLayer.Adapters.OpcUaAlarmMapper.BuildIdentity(
subscriptionSourceReference: binding,
sourceName: "pymodbus/plc/HR200", // the event's SourceName (RawPath) — NOT the binding
conditionName: "HR200");
cb!(Raise(sourceRef, sourceObjectRef));
var update = ExpectMsg<NativeAlarmTransitionUpdate>();
Assert.Equal(binding, update.Transition.SourceObjectReference);
Assert.Equal("pymodbus/plc/HR200.HR200", update.Transition.SourceReference);
}
[Fact]
public void SubscribeAlarms_OnNonAlarmCapableAdapter_RepliesFailure()
{