feat(dcl): OtOpcUa v3 cutover — nsu= namespace binding + native-alarm routing (#14, #17) #20

Merged
dohertj2 merged 2 commits from feat/otopcua-v3-cutover into main 2026-07-17 15:07:46 -04:00
5 changed files with 163 additions and 7 deletions
Showing only changes of commit 846cc5d96d - Show all commits
@@ -254,7 +254,9 @@ Other/custom protocols do not implement the capability; a subscribe request agai
### Connection Actor Behavior
The `DataConnectionActor` opens **one alarm feed per connection** (not per subscriber) and routes incoming transitions to instance subscribers by **source-object reference** — a prefix match of the transition's `SourceObjectReference` (falling back to `SourceReference`) against each subscriber's registered `SourceReference`. Subscribers (the Site Runtime's `NativeAlarmActor` instances) are **ref-counted per source**, so the underlying feed is opened once and torn down only when the last subscriber for that source unsubscribes.
The `DataConnectionActor` opens **one alarm feed per source** (not per subscriber) and routes incoming transitions to instance subscribers by **source-object reference** — a prefix match of the transition's `SourceObjectReference` (falling back to `SourceReference`) against each subscriber's registered `SourceReference`. Subscribers (the Site Runtime's `NativeAlarmActor` instances) are **ref-counted per source**, so the underlying feed is opened once and torn down only when the last subscriber for that source unsubscribes.
The registered `SourceReference` is a **NodeId** for OPC UA (the picker, CSV and config all store NodeIds), so the transition's routing identity must live in the same space or nothing matches. **Gitea #17**: the OPC UA adapter previously set `SourceObjectReference` from the event's `SourceName` (a plain name — the RawPath for OtOpcUa), which never prefix-matches a NodeId binding, so every native OPC UA alarm transition was silently dropped. Because each OPC UA feed is opened for exactly one binding, the adapter now tags every transition on that feed with the **binding string verbatim** (via the pure `OpcUaAlarmMapper.BuildIdentity`), making the routing key an exact match independent of whether the binding is stored as `ns=<index>` or the durable `nsu=<uri>` form. The Server-object aggregate feed (empty binding) 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 human-readable `SourceName.ConditionName`, so persistence and display are unchanged. MxGateway is unaffected — its bindings are object names and its mapper already emits matching names.
- **State gating**: `SubscribeAlarmsRequest` is handled only in the **Connected** state; requests arriving while **Connecting**/**Reconnecting** are stashed (standard Become/Stash) and processed on entering Connected.
- **Capability check**: if `_adapter is not IAlarmSubscribableConnection`, the actor replies `SubscribeAlarmsResponse(Success = false, ...)`.
@@ -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
@@ -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()
{
@@ -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()
{