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
7 changed files with 462 additions and 13 deletions
@@ -199,6 +199,10 @@ DCL is a clean data pipe on the hot path. Browse is an **opt-in capability** for
- `DataConnectionManagerActor` handles `BrowseNodeCommand` (fields: `ConnectionName`, `ParentNodeId`, and an optional opaque `ContinuationToken` for paging) and replies with `BrowseNodeResult` (children + `Truncated` + an optional continuation token + structured `BrowseFailure?`). The Central UI facade is `IBrowseService`/`BrowseService`, backing the `NodeBrowserDialog` tag picker.
- **Browse type-info**: each child `BrowseNode` carries optional best-effort type metadata — `DataType` (friendly name), `ValueRank` (scalar/array), and `Writable`. `DataType` is populated by **both** OPC UA and MxGateway: `RealOpcUaClient` batch-reads built-in data-type node ids and maps them to friendly names for **Variable** nodes; `MxGatewayDataConnection` surfaces each attribute leaf's `GalaxyAttribute.data_type_name` (free-form Galaxy type text) as `DataType`. `ValueRank` and `Writable` are OPC-UA-only; MxGateway leaves them unset.
- Node ids are opaque protocol-specific strings: OPC UA uses NodeIds; MxGateway uses Galaxy gobject ids for navigable objects and full tag references for selectable attribute leaves.
- **OPC UA namespace binding (Gitea #14)**: a NodeId's `ns=<index>` is meaningful only against one server's namespace table, so a server that adds/removes/reorders a namespace silently re-points every stored binding — best case `BadNodeIdUnknown`, worst case the index names a different namespace holding a colliding identifier and the binding resolves to the **wrong node with Good quality**. The namespace **URI** is the stable identity. `OpcUaNodeReference` is therefore the single translation seam between stored references and the wire:
- `Resolve(reference, session.NamespaceUris)` accepts **both** `ns=<index>;s=<id>` (existing bindings, unchanged) and the durable `nsu=<namespace-uri>;s=<id>`, mapping the URI to the live index at use time. It is used at every parse site — subscribe, read, write, alarm-subscribe, browse. A URI absent from the server's `NamespaceArray` throws with a message naming the URI, rather than binding to whatever sits at that index; a `svr=` reference to another server is rejected outright.
- `ToDurable(expandedNodeId, session.NamespaceUris)` is what the browser emits, so **what the picker shows is what gets stored** and newly-authored bindings are index-proof from the start. Namespace 0 is exempt (spec-fixed) and keeps its short form. It also closes a round-trip gap: the browse previously emitted `ExpandedNodeId.ToString()`, which for a URI- or server-index-carrying reference produced a string `NodeId.Parse` could not read back.
- Bindings stored before this change keep their `ns=` form and keep working; they are only as durable as the server's namespace order. Re-authoring against a picker (which now emits `nsu=`) is what makes them durable — see `docs/plans/` and Gitea #14 for the OtOpcUa v3.0 cutover, where v2's sole custom namespace and v3's `raw` namespace both sit at index 2, so v2-era bindings resolve against v3 without error while meaning something else.
- Browse runs against the live session; no caching at DCL.
- **Frame-size guard**: the reply crosses the site→central Akka frame (default 128 KB) on a temp Ask actor; an oversized reply is silently discarded by remoting, hanging the picker. The child handler caps each `BrowseNodeResult` to a byte budget (~100 KB) before replying, OR-ing the adapter's own truncation signal into `Truncated`. This is protocol-agnostic (every adapter's reply funnels through it). Per-protocol upstream caps narrow the window first: OPC UA requests at most 500 references per node (continuation point → `Truncated`); MxGateway relies on the gateway's `BrowseChildren` page cap.
- **`BrowseNext` paging** (OPC UA): a `Truncated` level no longer forces manual node-id entry. When the OPC UA browse is truncated, the adapter returns the session's continuation point as the reply's opaque `ContinuationToken`; a follow-up `BrowseNodeCommand` carrying that token calls `Session.BrowseNext` to fetch the next page (the picker exposes this as **"Load more"**). Continuation points are session-bound and can expire — `BadContinuationPointInvalid` is caught and the browse restarts from a fresh first page rather than failing. Manual node-id entry remains as a fallback when the site or its session is offline.
@@ -250,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>
@@ -0,0 +1,118 @@
using Opc.Ua;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>
/// Translates the node-reference strings ScadaBridge stores in configuration
/// (<c>TemplateAttribute.DataSourceReference</c> and friends) to session-local
/// <see cref="NodeId"/>s, and back again for the node browser.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists.</b> A stored <c>ns=2;s=Foo</c> reference hard-codes a namespace
/// <i>index</i>, and an index is only meaningful relative to one server's namespace
/// table. A server that adds, removes or reorders a namespace silently re-points every
/// stored binding: best case the read fails <c>BadNodeIdUnknown</c>, worst case the
/// index now names a different namespace whose table happens to contain a colliding
/// identifier and the binding resolves to the wrong node with Good quality. The
/// namespace <i>URI</i> is the stable identity — the index is just its position today.
/// </para>
/// <para>
/// <b>The durable form</b> is therefore <c>nsu=&lt;namespace-uri&gt;;s=&lt;identifier&gt;</c>,
/// resolved against the live namespace table at use time. <see cref="Resolve"/> accepts
/// both forms, so existing <c>ns=</c> bindings keep working unchanged; <see cref="ToDurable"/>
/// is what the browser emits so newly-authored bindings are index-proof from the start.
/// </para>
/// <para>
/// Namespace 0 is exempt: the OPC UA spec fixes it to <c>http://opcfoundation.org/UA/</c>,
/// so standard NodeIds like <c>i=85</c> are already stable and stay in their short form.
/// </para>
/// </remarks>
public static class OpcUaNodeReference
{
/// <summary>
/// Resolves a stored node reference — either <c>ns=&lt;index&gt;;…</c> or the durable
/// <c>nsu=&lt;uri&gt;;…</c> form — to a <see cref="NodeId"/> valid for this session.
/// </summary>
/// <param name="reference">The stored reference string.</param>
/// <param name="namespaceUris">The connected session's namespace table.</param>
/// <returns>A session-local <see cref="NodeId"/>.</returns>
/// <exception cref="ArgumentException">
/// The reference is unparseable, names a namespace URI this server does not publish,
/// or points at a different server via <c>svr=</c>.
/// </exception>
public static NodeId Resolve(string reference, NamespaceTable namespaceUris)
{
ArgumentException.ThrowIfNullOrEmpty(reference);
ArgumentNullException.ThrowIfNull(namespaceUris);
ExpandedNodeId expanded;
try
{
expanded = ExpandedNodeId.Parse(reference);
}
catch (Exception ex)
{
throw new ArgumentException(
$"'{reference}' is not a valid OPC UA node reference. Expected 'ns=<index>;s=<id>' " +
$"or the namespace-URI-qualified 'nsu=<uri>;s=<id>'.",
nameof(reference), ex);
}
// svr= addresses a node on a *different* server via this server's server table.
// Nothing downstream follows that indirection, so fail loudly rather than hand
// the SDK a reference it would resolve against the wrong address space.
if (expanded.ServerIndex != 0)
{
throw new ArgumentException(
$"'{reference}' targets another server (svr={expanded.ServerIndex}); " +
"ScadaBridge binds only to nodes on the connected server.",
nameof(reference));
}
// Maps nsu= -> the live index; leaves an ns= reference's index untouched.
var nodeId = ExpandedNodeId.ToNodeId(expanded, namespaceUris);
if (NodeId.IsNull(nodeId))
{
throw new ArgumentException(
$"'{reference}' names namespace URI '{expanded.NamespaceUri}', which this server " +
"does not publish in its NamespaceArray. The binding likely predates a server " +
"upgrade that changed its namespaces.",
nameof(reference));
}
return nodeId;
}
/// <summary>
/// Renders a browse result as the durable, index-proof reference to store.
/// </summary>
/// <param name="nodeId">The <see cref="ExpandedNodeId"/> a browse returned.</param>
/// <param name="namespaceUris">The connected session's namespace table.</param>
/// <returns>
/// <c>nsu=&lt;uri&gt;;s=&lt;id&gt;</c> for server-specific namespaces; the plain short form for
/// namespace 0. Falls back to the raw string when the namespace cannot be named.
/// </returns>
public static string ToDurable(ExpandedNodeId nodeId, NamespaceTable namespaceUris)
{
ArgumentNullException.ThrowIfNull(nodeId);
ArgumentNullException.ThrowIfNull(namespaceUris);
// A browse can hand back a reference carrying a NamespaceUri or a server index
// rather than a plain index. Resolving to session-local first collapses those
// shapes; without it the raw ToString() can emit a string Resolve cannot re-read.
var local = ExpandedNodeId.ToNodeId(nodeId, namespaceUris);
if (NodeId.IsNull(local))
return nodeId.ToString() ?? string.Empty;
// ns=0 is spec-fixed, so its short form is already durable.
if (local.NamespaceIndex == 0)
return local.ToString() ?? string.Empty;
var uri = namespaceUris.GetString(local.NamespaceIndex);
if (string.IsNullOrEmpty(uri))
return local.ToString() ?? string.Empty;
return new ExpandedNodeId(local, uri).ToString() ?? string.Empty;
}
}
@@ -411,7 +411,7 @@ public class RealOpcUaClient : IOpcUaClient
var monitoredItem = new MonitoredItem(_subscription.DefaultItem)
{
DisplayName = nodeId,
StartNodeId = nodeId,
StartNodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
AttributeId = Attributes.Value,
SamplingInterval = _options.SamplingIntervalMs,
QueueSize = (uint)_options.QueueSize,
@@ -475,7 +475,9 @@ public class RealOpcUaClient : IOpcUaClient
_alarmInRefresh[handle] = false;
_alarmLastState[handle] = new Dictionary<string, (bool, bool)>(StringComparer.Ordinal);
var startNode = string.IsNullOrEmpty(sourceNodeId) ? ObjectIds.Server : NodeId.Parse(sourceNodeId);
var startNode = string.IsNullOrEmpty(sourceNodeId)
? ObjectIds.Server
: OpcUaNodeReference.Resolve(sourceNodeId, _session.NamespaceUris);
var item = new MonitoredItem(_subscription.DefaultItem)
{
DisplayName = $"alarm:{sourceNodeId ?? "Server"}",
@@ -492,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);
@@ -722,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)
@@ -744,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
@@ -829,7 +837,7 @@ public class RealOpcUaClient : IOpcUaClient
var readValue = new ReadValueId
{
NodeId = nodeId,
NodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
AttributeId = Attributes.Value
};
@@ -848,7 +856,7 @@ public class RealOpcUaClient : IOpcUaClient
var writeValue = new WriteValue
{
NodeId = nodeId,
NodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
@@ -960,7 +968,7 @@ public class RealOpcUaClient : IOpcUaClient
// an absolute NodeId expression.
var nodeToBrowse = string.IsNullOrEmpty(parentNodeId)
? ObjectIds.ObjectsFolder
: NodeId.Parse(parentNodeId);
: OpcUaNodeReference.Resolve(parentNodeId, session.NamespaceUris);
// No token → fresh browse of the node. A non-empty token → continue a
// prior browse via BrowseNext, falling back to a fresh browse if the
@@ -1066,7 +1074,11 @@ public class RealOpcUaClient : IOpcUaClient
foreach (var r in refs)
{
children.Add(new Commons.Interfaces.Protocol.BrowseNode(
NodeId: r.NodeId.ToString(),
// Durable nsu= form, not r.NodeId.ToString(): the raw ExpandedNodeId
// string can carry a namespace URI or server index that Resolve cannot
// read back, and a bare ns= index rots the moment the server's
// namespace table changes. What the picker shows is what gets stored.
NodeId: OpcUaNodeReference.ToDurable(r.NodeId, session.NamespaceUris),
DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)",
NodeClass: MapNodeClass(r.NodeClass),
HasChildren: r.NodeClass == NodeClass.Object));
@@ -0,0 +1,165 @@
using Opc.Ua;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
/// <summary>
/// Gitea #14: stored bindings hard-code a namespace <i>index</i>, which is only
/// meaningful against one server's namespace table. These tests pin the durable
/// <c>nsu=&lt;uri&gt;</c> form and the back-compat of the existing <c>ns=</c> form.
/// </summary>
public class OpcUaNodeReferenceTests
{
// Mirrors an OtOpcUa v3 server: 0=UA (spec), 1=ApplicationUri, 2=raw, 3=uns.
private const string RawUri = "https://zb.com/otopcua/raw";
private const string UnsUri = "https://zb.com/otopcua/uns";
private static NamespaceTable V3Table()
{
var t = new NamespaceTable(); // index 0 = http://opcfoundation.org/UA/
t.Append("urn:localhost:OtOpcUa"); // 1
t.Append(RawUri); // 2
t.Append(UnsUri); // 3
return t;
}
// The v2 server: a single custom namespace, which also landed at index 2.
private static NamespaceTable V2Table()
{
var t = new NamespaceTable();
t.Append("urn:localhost:OtOpcUa"); // 1
t.Append("https://zb.com/otopcua/ns"); // 2
return t;
}
[Fact]
public void Resolve_IndexForm_StillWorks()
{
// Back-compat: every binding in the field today is this shape.
var nodeId = OpcUaNodeReference.Resolve("ns=2;s=pymodbus/plc/HR200", V3Table());
Assert.Equal(2, nodeId.NamespaceIndex);
Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier);
}
[Fact]
public void Resolve_UriForm_MapsToLiveIndex()
{
var nodeId = OpcUaNodeReference.Resolve($"nsu={RawUri};s=pymodbus/plc/HR200", V3Table());
Assert.Equal(2, nodeId.NamespaceIndex);
Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier);
}
[Fact]
public void Resolve_UriForm_TracksIndexAcrossServers()
{
// The point of the durable form: same reference, a server that orders its
// namespaces differently, still the right namespace.
var reordered = new NamespaceTable();
reordered.Append("urn:localhost:OtOpcUa"); // 1
reordered.Append("urn:something:else"); // 2 <- pushed raw along
reordered.Append(RawUri); // 3
var v3 = OpcUaNodeReference.Resolve($"nsu={RawUri};s=Tag", V3Table());
var moved = OpcUaNodeReference.Resolve($"nsu={RawUri};s=Tag", reordered);
Assert.Equal(2, v3.NamespaceIndex);
Assert.Equal(3, moved.NamespaceIndex); // followed the URI, not the index
}
[Fact]
public void Resolve_IndexForm_SilentlyBindsToTheWrongNamespace_AcrossServerUpgrade()
{
// This is the #14 hazard, pinned as a characterisation test rather than a wish:
// v2's sole custom namespace and v3's raw namespace BOTH sit at index 2, so a
// v2-era ns=2 binding resolves happily against v3 while meaning something else
// entirely. Nothing in the index form can detect that — which is the whole
// argument for re-authoring bindings in the nsu= form.
const string v2Binding = "ns=2;s=Line1/Pump/Speed";
var againstV2 = OpcUaNodeReference.Resolve(v2Binding, V2Table());
var againstV3 = OpcUaNodeReference.Resolve(v2Binding, V3Table());
Assert.Equal(againstV2.NamespaceIndex, againstV3.NamespaceIndex);
Assert.Equal("https://zb.com/otopcua/ns", V2Table().GetString(againstV2.NamespaceIndex));
Assert.Equal(RawUri, V3Table().GetString(againstV3.NamespaceIndex)); // different namespace, no error
// The durable form fails loudly instead.
var ex = Assert.Throws<ArgumentException>(
() => OpcUaNodeReference.Resolve($"nsu=https://zb.com/otopcua/ns;s=Line1/Pump/Speed", V3Table()));
Assert.Contains("does not publish", ex.Message);
}
[Fact]
public void Resolve_UnknownNamespaceUri_Throws()
{
var ex = Assert.Throws<ArgumentException>(
() => OpcUaNodeReference.Resolve("nsu=https://example.invalid/nope;s=Tag", V3Table()));
Assert.Contains("NamespaceArray", ex.Message);
}
[Fact]
public void Resolve_ForeignServerReference_Throws()
{
var ex = Assert.Throws<ArgumentException>(
() => OpcUaNodeReference.Resolve($"svr=1;nsu={RawUri};s=Tag", V3Table()));
Assert.Contains("another server", ex.Message);
}
[Theory]
[InlineData("")]
[InlineData(" ;;; not a node id")]
public void Resolve_Unparseable_Throws(string reference)
{
Assert.ThrowsAny<ArgumentException>(() => OpcUaNodeReference.Resolve(reference, V3Table()));
}
[Fact]
public void ToDurable_ServerNamespace_EmitsUriForm()
{
var browsed = new ExpandedNodeId("pymodbus/plc/HR200", 2, null, 0);
var durable = OpcUaNodeReference.ToDurable(browsed, V3Table());
Assert.Equal($"nsu={RawUri};s=pymodbus/plc/HR200", durable);
}
[Fact]
public void ToDurable_StandardNamespace_StaysShort()
{
// ns=0 is spec-fixed; qualifying it would be noise.
var durable = OpcUaNodeReference.ToDurable(new ExpandedNodeId(85u, 0), V3Table());
Assert.Equal("i=85", durable);
}
[Fact]
public void ToDurable_RoundTripsThroughResolve()
{
// The bug this closes: browse emitted ExpandedNodeId.ToString(), which for a
// URI-carrying reference produced a string NodeId.Parse could not read back.
var table = V3Table();
var browsed = new ExpandedNodeId("Area/Line/Equip/Speed", 0, UnsUri, 0);
var durable = OpcUaNodeReference.ToDurable(browsed, table);
var resolved = OpcUaNodeReference.Resolve(durable, table);
Assert.Equal(3, resolved.NamespaceIndex);
Assert.Equal("Area/Line/Equip/Speed", resolved.Identifier);
}
[Fact]
public void ToDurable_UriNotInTable_FallsBackRatherThanThrowing()
{
// Browsing is a discovery path — a namespace we cannot name should degrade to
// the raw string, not abort the browse.
var browsed = new ExpandedNodeId("Tag", 0, "https://example.invalid/nope", 0);
var durable = OpcUaNodeReference.ToDurable(browsed, V3Table());
Assert.Contains("Tag", durable);
}
}
@@ -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()
{