feat(opcua): F10b SDK NodeManager binding — real OPC UA address-space writes
Some checks failed
v2-ci / build (push) Failing after 38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped

OtOpcUaNodeManager + SdkAddressSpaceSink: the v2 IOpcUaAddressSpaceSink
seam now has a production adapter against a real Opc.Ua.Server
CustomNodeManager2. Writes through OpcUaPublishActor's sink materialise
as real OPC UA Variable updates that subscribed clients see via the
standard ClearChangeMasks notification path.

OtOpcUaNodeManager (CustomNodeManager2):
  - Owns a ConcurrentDictionary<string, BaseDataVariableState> under a
    single namespace (https://zb.com/otopcua/ns) hung off Objects/.
  - WriteValue lazy-creates the variable on first write, sets Value +
    StatusCode (mapped from OpcUaQuality severity bits) + SourceTimestamp,
    then ClearChangeMasks to notify subscribers.
  - WriteAlarmState surfaces a [active, acknowledged] pair on a
    dedicated node id — full AlarmConditionState/event firing comes
    with #85 F14b (EquipmentNodeWalker SDK integration).
  - RebuildAddressSpace tears down every registered variable + clears
    the dictionary so the next write-pass starts fresh.
  - Address-space root folder is materialised in CreateAddressSpace.

SdkAddressSpaceSink: thin IOpcUaAddressSpaceSink → OtOpcUaNodeManager
bridge. Production DI binding (#108) constructs this once the host's
StandardServer has booted.

OtOpcUaSdkServer (StandardServer subclass): overrides
CreateMasterNodeManager to inject OtOpcUaNodeManager via the
MasterNodeManager additionalManagers ctor. NodeManager property
exposes the live instance so OpcUaApplicationHost callers can wrap
it in a sink.

Tests: OpcUaServer 20 -> 24 (+4):
- WriteValue creates + updates variables in the manager
- WriteAlarmState creates a node distinct from value writes
- RebuildAddressSpace clears everything; subsequent writes start fresh
- NullOpcUaAddressSpaceSink no-op sanity

Each test boots a real OpcUaApplicationHost on a free port with the
SDK certificate auto-create flow (F13a) intact — full integration
slice on macOS.

All 6 v2 test suites green: 167 tests passing.

F10 status updated to reflect SDK binding shipped. Residuals:
- #109 OpcUaPublishActor.RebuildAddressSpace → Phase7Applier wiring
- #108 Host DI default to SdkAddressSpaceSink when hasDriver
- #85 F14b EquipmentNodeWalker integration (proper AlarmConditionState
  + folder hierarchy)
- IServiceLevelPublisher SDK binding (writes Server.ServiceLevel node)
This commit is contained in:
Joseph Doherty
2026-05-26 09:49:44 -04:00
parent 7fa863f6da
commit d21f6947e1
5 changed files with 322 additions and 1 deletions

View File

@@ -0,0 +1,147 @@
using System.Collections.Concurrent;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Custom OPC UA <see cref="CustomNodeManager2"/> that owns the writable address space for
/// the OtOpcUa server. Variable nodes are created lazily on first <see cref="WriteValue"/>
/// under the manager's namespace; subsequent writes update the existing node's Value +
/// StatusCode + SourceTimestamp and notify subscribed clients via the standard
/// <c>ClearChangeMasks</c> path.
///
/// This is the F10b production wiring behind the v2 <see cref="IOpcUaAddressSpaceSink"/>
/// seam — once a <see cref="SdkAddressSpaceSink"/> is bound, OpcUaPublishActor's writes
/// materialise as real OPC UA Variable updates that clients can browse + subscribe to.
///
/// Node-id encoding uses the manager's default namespace + the caller-supplied string id
/// as the identifier portion (e.g. <c>"ns=2;s=eq-1/temp"</c>). Equipment-folder hierarchy
/// and OPC UA type metadata still come from the Phase7Applier / EquipmentNodeWalker
/// integration (F14b, tracked under #85) — this manager treats every id as a flat
/// <see cref="BaseDataVariableState"/> under the namespace root.
/// </summary>
public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
private FolderState? _root;
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: base(server, configuration, DefaultNamespaceUri)
{
// SystemContext is initialised by the base ctor.
}
public int VariableCount => _variables.Count;
/// <summary>
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
/// variable node on first call; subsequent calls update Value + StatusCode +
/// SourceTimestamp and call <c>ClearChangeMasks</c> so subscribed clients see the change.
/// </summary>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
{
ArgumentException.ThrowIfNullOrEmpty(nodeId);
var variable = _variables.GetOrAdd(nodeId, CreateVariable);
lock (Lock)
{
variable.Value = value;
variable.StatusCode = StatusFromQuality(quality);
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
/// <summary>Apply an alarm-state write. Surfaced as a two-element Variable carrying
/// <c>[active, acknowledged]</c> — proper <c>AlarmConditionState</c> + event firing
/// comes when the F14b walker integration lands and registers real condition nodes.</summary>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
var variable = _variables.GetOrAdd(alarmNodeId, CreateVariable);
lock (Lock)
{
variable.Value = new[] { active, acknowledged };
variable.StatusCode = StatusCodes.Good;
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
/// <summary>Clear every registered variable from the address space. Phase7Applier calls this
/// when Equipment/Alarm topology changes; the populator then re-adds via WriteValue on the
/// next pass.</summary>
public void RebuildAddressSpace()
{
lock (Lock)
{
foreach (var v in _variables.Values)
{
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_variables.Clear();
}
}
/// <inheritdoc />
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{
lock (Lock)
{
base.CreateAddressSpace(externalReferences);
// Create one root folder under Objects/ for every variable we mint to hang under.
_root = new FolderState(null)
{
NodeId = new NodeId("OtOpcUa", NamespaceIndex),
BrowseName = new QualifiedName("OtOpcUa", NamespaceIndex),
DisplayName = "OtOpcUa",
EventNotifier = EventNotifiers.None,
TypeDefinitionId = ObjectTypeIds.FolderType,
};
_root.AddReference(ReferenceTypeIds.Organizes, isInverse: true, ObjectIds.ObjectsFolder);
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var refs))
{
refs = new List<IReference>();
externalReferences[ObjectIds.ObjectsFolder] = refs;
}
refs.Add(new NodeStateReference(ReferenceTypeIds.Organizes, isInverse: false, _root.NodeId));
AddPredefinedNode(SystemContext, _root);
}
}
private BaseDataVariableState CreateVariable(string nodeId)
{
var v = new BaseDataVariableState(_root)
{
NodeId = new NodeId(nodeId, NamespaceIndex),
BrowseName = new QualifiedName(nodeId, NamespaceIndex),
DisplayName = nodeId,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = DataTypeIds.BaseDataType,
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Historizing = false,
};
_root?.AddChild(v);
AddPredefinedNode(SystemContext, v);
return v;
}
private static StatusCode StatusFromQuality(OpcUaQuality quality) => quality switch
{
OpcUaQuality.Good => StatusCodes.Good,
OpcUaQuality.Uncertain => StatusCodes.Uncertain,
_ => StatusCodes.Bad,
};
}

View File

@@ -0,0 +1,27 @@
using Opc.Ua;
using Opc.Ua.Server;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// <see cref="StandardServer"/> subclass that wires in the v2 <see cref="OtOpcUaNodeManager"/>.
/// Exposes the live node manager after start so callers (<see cref="OpcUaApplicationHost"/>,
/// the fused Host's DI binding) can wrap it in a <see cref="SdkAddressSpaceSink"/> and hand
/// it to <c>OpcUaPublishActor</c>.
/// </summary>
public sealed class OtOpcUaSdkServer : StandardServer
{
private OtOpcUaNodeManager? _otOpcUaNodeManager;
/// <summary>The custom node manager once <c>StartAsync</c> has called
/// <see cref="CreateMasterNodeManager"/>. Null until the SDK has bootstrapped.</summary>
public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager;
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)
{
_otOpcUaNodeManager = new OtOpcUaNodeManager(server, configuration);
return new MasterNodeManager(server, configuration, dynamicNamespaceUri: null, _otOpcUaNodeManager);
}
}

View File

@@ -0,0 +1,28 @@
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Production <see cref="IOpcUaAddressSpaceSink"/> binding for v2 — bridges
/// OpcUaPublishActor's writes to the SDK address space owned by
/// <see cref="OtOpcUaNodeManager"/>. The host wires this in once the StandardServer has
/// been started (so the node manager exists).
/// </summary>
public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
{
private readonly OtOpcUaNodeManager _nodeManager;
public SdkAddressSpaceSink(OtOpcUaNodeManager nodeManager)
{
ArgumentNullException.ThrowIfNull(nodeManager);
_nodeManager = nodeManager;
}
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
}