Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
Joseph Doherty 46834a43bd Phase 3 PR 17 — complete OPC UA server startup end-to-end + integration test. PR 16 shipped the materialization shape (DriverNodeManager / OtOpcUaServer) without the activation glue; this PR finishes the scope so an external OPC UA client can actually connect, browse, and read. New OpcUaServerOptions DTO bound from the OpcUaServer section of appsettings.json (EndpointUrl default opc.tcp://0.0.0.0:4840/OtOpcUa, ApplicationName, ApplicationUri, PkiStoreRoot default %ProgramData%\OtOpcUa\pki, AutoAcceptUntrustedClientCertificates default true for dev — production flips via config). OpcUaApplicationHost wraps Opc.Ua.Configuration.ApplicationInstance: BuildConfiguration constructs the ApplicationConfiguration programmatically (no external XML) with SecurityConfiguration pointing at <PkiStoreRoot>/own, /issuers, /trusted, /rejected directories — stack auto-creates the cert folders on first run and generates a self-signed application certificate via CheckApplicationInstanceCertificate, ServerConfiguration.BaseAddresses set to the endpoint URL + SecurityPolicies just None + UserTokenPolicies just Anonymous with PolicyId='Anonymous' + SecurityPolicyUri=None so the client's UserTokenPolicy lookup succeeds at OpenSession, TransportQuotas.OperationTimeout=15s + MinRequestThreadCount=5 / MaxRequestThreadCount=100 / MaxQueuedRequestCount=200, CertificateValidator auto-accepts untrusted when configured. StartAsync creates the OtOpcUaServer (passes DriverHost + ILoggerFactory so one DriverNodeManager is created per registered driver in CreateMasterNodeManager from PR 16), calls ApplicationInstance.Start(server) to bind the endpoint, then walks each DriverNodeManager and drives a fresh GenericDriverNodeManager.BuildAddressSpaceAsync against it so the driver's discovery streams into the address space that's already serving clients. Per-driver discovery is isolated per decision #12: a discovery exception marks the driver's subtree faulted but the server stays up serving the other drivers' subtrees. DriverHost.GetDriver(instanceId) public accessor added alongside the existing GetHealth so OtOpcUaServer can enumerate drivers during CreateMasterNodeManager. DriverNodeManager.Driver property made public so OpcUaApplicationHost can identify which driver each node manager wraps during the discovery loop. OpcUaServerService constructor takes OpcUaApplicationHost — ExecuteAsync sequence now: bootstrap.LoadCurrentGenerationAsync → applicationHost.StartAsync → infinite Task.Delay until stop. StopAsync disposes the application host (which stops the server via OtOpcUaServer.Stop) before disposing DriverHost. Program.cs binds OpcUaServerOptions from appsettings + registers OpcUaApplicationHost + OpcUaServerOptions as singletons. Integration test (OpcUaServerIntegrationTests, Category=Integration): IAsyncLifetime spins up the server on a random non-default port (48400+random for test isolation) with a per-test-run PKI store root (%temp%/otopcua-test-<guid>) + a FakeDriver registered in DriverHost that has ITagDiscovery + IReadable implementations — DiscoverAsync registers TestFolder>Var1, ReadAsync returns 42. Client_can_connect_and_browse_driver_subtree creates an in-process OPC UA client session via CoreClientUtils.SelectEndpoint (which talks to the running server's GetEndpoints and fetches the live EndpointDescription with the actual PolicyId), browses the fake driver's root, asserts TestFolder appears in the returned references. Client_can_read_a_driver_variable_through_the_node_manager constructs the variable NodeId using the namespace index the server registered (urn:OtOpcUa:fake), calls Session.ReadValue, asserts the DataValue.Value is 42 — the whole pipeline (client → server endpoint → DriverNodeManager.OnReadValue → FakeDriver.ReadAsync → back through the node manager → response to client) round-trips correctly. Dispose tears down the session, server, driver host, and PKI store directory. Full solution: 0 errors, 165 tests pass (8 Core unit + 14 Proxy unit + 24 Configuration unit + 6 Shared unit + 91 Galaxy.Host unit + 4 Server (2 unit NodeBootstrap + 2 new integration) + 18 Admin). End-to-end outcome: PR 14's GalaxyAlarmTracker alarm events now flow through PR 15's GenericDriverNodeManager event forwarder → PR 16's ConditionSink → OPC UA AlarmConditionState.ReportEvent → out to every OPC UA client subscribed to the alarm condition. The full alarm subsystem (driver-side subscription of the Galaxy 4-attribute quartet, Core-side routing by source node id, Server-side AlarmConditionState materialization with ReportEvent dispatch) is now complete and observable through any compliant OPC UA client. LDAP / security-profile wire-up (replacing the anonymous-only endpoint with BasicSignAndEncrypt + user identity mapping to NodePermissions role) is the next layer — it reuses the same ApplicationConfiguration plumbing this PR introduces but needs a deployment-policy source (central config DB) for the cert trust decisions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:18:37 -04:00

364 lines
16 KiB
C#

using System.Globalization;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
/// <summary>
/// Concrete <see cref="CustomNodeManager2"/> that materializes the driver's address space
/// into OPC UA nodes. Implements <see cref="IAddressSpaceBuilder"/> itself so
/// <c>GenericDriverNodeManager.BuildAddressSpaceAsync</c> can stream nodes directly into the
/// OPC UA server's namespace. PR 15's <c>MarkAsAlarmCondition</c> hook creates a sibling
/// <see cref="AlarmConditionState"/> node per alarm-flagged variable; subsequent driver
/// <c>OnAlarmEvent</c> pushes land through the returned sink to drive Activate /
/// Acknowledge / Deactivate transitions.
/// </summary>
/// <remarks>
/// Read / Subscribe / Write are routed to the driver's capability interfaces — the node
/// manager holds references to <see cref="IReadable"/>, <see cref="ISubscribable"/>, and
/// <see cref="IWritable"/> when present. Nodes with no driver backing (plain folders) are
/// served directly from the internal PredefinedNodes table.
/// </remarks>
public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
{
private readonly IDriver _driver;
private readonly IReadable? _readable;
private readonly IWritable? _writable;
private readonly ILogger<DriverNodeManager> _logger;
/// <summary>The driver whose address space this node manager exposes.</summary>
public IDriver Driver => _driver;
private FolderState? _driverRoot;
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
// Active building folder — set per Folder() call so Variable() lands under the right parent.
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
// returns a child builder per Folder call and the caller threads nesting through those references.
private FolderState _currentFolder = null!;
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
IDriver driver, ILogger<DriverNodeManager> logger)
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
{
_driver = driver;
_readable = driver as IReadable;
_writable = driver as IWritable;
_logger = logger;
}
protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context) => new();
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{
lock (Lock)
{
_driverRoot = new FolderState(null)
{
SymbolicName = _driver.DriverInstanceId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = ObjectTypeIds.FolderType,
NodeId = new NodeId(_driver.DriverInstanceId, NamespaceIndex),
BrowseName = new QualifiedName(_driver.DriverInstanceId, NamespaceIndex),
DisplayName = new LocalizedText(_driver.DriverInstanceId),
EventNotifier = EventNotifiers.None,
};
// Link under Objects folder so clients see the driver subtree at browse root.
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var references))
{
references = new List<IReference>();
externalReferences[ObjectIds.ObjectsFolder] = references;
}
references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, _driverRoot.NodeId));
AddPredefinedNode(SystemContext, _driverRoot);
_currentFolder = _driverRoot;
}
}
// ------- IAddressSpaceBuilder implementation (PR 15 contract) -------
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
lock (Lock)
{
var folder = new FolderState(_currentFolder)
{
SymbolicName = browseName,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = ObjectTypeIds.FolderType,
NodeId = new NodeId($"{_currentFolder.NodeId.Identifier}/{browseName}", NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(displayName),
};
_currentFolder.AddChild(folder);
AddPredefinedNode(SystemContext, folder);
return new NestedBuilder(this, folder);
}
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
lock (Lock)
{
var v = new BaseDataVariableState(_currentFolder)
{
SymbolicName = browseName,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(displayName),
DataType = MapDataType(attributeInfo.DriverDataType),
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentReadOrWrite,
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
Historizing = attributeInfo.IsHistorized,
};
_currentFolder.AddChild(v);
AddPredefinedNode(SystemContext, v);
_variablesByFullRef[attributeInfo.FullName] = v;
v.OnReadValue = OnReadValue;
v.OnWriteValue = OnWriteValue;
return new VariableHandle(this, v, attributeInfo.FullName);
}
}
public void AddProperty(string browseName, DriverDataType dataType, object? value)
{
lock (Lock)
{
var p = new PropertyState(_currentFolder)
{
SymbolicName = browseName,
ReferenceTypeId = ReferenceTypeIds.HasProperty,
TypeDefinitionId = VariableTypeIds.PropertyType,
NodeId = new NodeId($"{_currentFolder.NodeId.Identifier}/{browseName}", NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(browseName),
DataType = MapDataType(dataType),
ValueRank = ValueRanks.Scalar,
Value = value,
};
_currentFolder.AddChild(p);
AddPredefinedNode(SystemContext, p);
}
}
private ServiceResult OnReadValue(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
{
if (_readable is null)
{
statusCode = StatusCodes.BadNotReadable;
return ServiceResult.Good;
}
try
{
var fullRef = node.NodeId.Identifier as string ?? "";
var result = _readable.ReadAsync([fullRef], CancellationToken.None).GetAwaiter().GetResult();
if (result.Count == 0)
{
statusCode = StatusCodes.BadNoData;
return ServiceResult.Good;
}
var snap = result[0];
value = snap.Value;
statusCode = snap.StatusCode;
timestamp = snap.ServerTimestampUtc;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OnReadValue failed for {NodeId}", node.NodeId);
statusCode = StatusCodes.BadInternalError;
}
return ServiceResult.Good;
}
private static NodeId MapDataType(DriverDataType t) => t switch
{
DriverDataType.Boolean => DataTypeIds.Boolean,
DriverDataType.Int32 => DataTypeIds.Int32,
DriverDataType.Float32 => DataTypeIds.Float,
DriverDataType.Float64 => DataTypeIds.Double,
DriverDataType.String => DataTypeIds.String,
DriverDataType.DateTime => DataTypeIds.DateTime,
_ => DataTypeIds.BaseDataType,
};
/// <summary>
/// Nested builder returned by <see cref="Folder"/>. Temporarily retargets the parent's
/// <see cref="_currentFolder"/> during each call so Variable/Folder calls land under the
/// correct subtree. Not thread-safe if callers drive Discovery concurrently — but
/// <c>GenericDriverNodeManager</c> discovery is sequential per driver.
/// </summary>
private sealed class NestedBuilder(DriverNodeManager owner, FolderState folder) : IAddressSpaceBuilder
{
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
var prior = owner._currentFolder;
owner._currentFolder = folder;
try { return owner.Folder(browseName, displayName); }
finally { owner._currentFolder = prior; }
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
var prior = owner._currentFolder;
owner._currentFolder = folder;
try { return owner.Variable(browseName, displayName, attributeInfo); }
finally { owner._currentFolder = prior; }
}
public void AddProperty(string browseName, DriverDataType dataType, object? value)
{
var prior = owner._currentFolder;
owner._currentFolder = folder;
try { owner.AddProperty(browseName, dataType, value); }
finally { owner._currentFolder = prior; }
}
}
private sealed class VariableHandle : IVariableHandle
{
private readonly DriverNodeManager _owner;
private readonly BaseDataVariableState _variable;
public string FullReference { get; }
public VariableHandle(DriverNodeManager owner, BaseDataVariableState variable, string fullRef)
{
_owner = owner;
_variable = variable;
FullReference = fullRef;
}
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
lock (_owner.Lock)
{
var alarm = new AlarmConditionState(_variable)
{
SymbolicName = _variable.BrowseName.Name + "_Condition",
ReferenceTypeId = ReferenceTypeIds.HasComponent,
NodeId = new NodeId(FullReference + ".Condition", _owner.NamespaceIndex),
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
DisplayName = new LocalizedText(info.SourceName),
};
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, false);
alarm.SourceName.Value = info.SourceName;
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
alarm.EnabledState.Value = new LocalizedText("Enabled");
alarm.EnabledState.Id.Value = true;
alarm.Retain.Value = false;
alarm.AckedState.Value = new LocalizedText("Acknowledged");
alarm.AckedState.Id.Value = true;
alarm.ActiveState.Value = new LocalizedText("Inactive");
alarm.ActiveState.Id.Value = false;
_variable.AddChild(alarm);
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
return new ConditionSink(_owner, alarm);
}
}
private static int MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 250,
AlarmSeverity.Medium => 500,
AlarmSeverity.High => 700,
AlarmSeverity.Critical => 900,
_ => 500,
};
}
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
: IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args)
{
lock (owner.Lock)
{
alarm.Severity.Value = (ushort)MapSeverity(args.Severity);
alarm.Time.Value = args.SourceTimestampUtc;
alarm.Message.Value = new LocalizedText(args.Message);
// Map the driver's transition type to OPC UA Part 9 state. The driver uses
// AlarmEventArgs but the state transition kind is encoded in AlarmType by
// convention — Galaxy's GalaxyAlarmTracker emits "Active"/"Acknowledged"/"Inactive".
switch (args.AlarmType)
{
case "Active":
alarm.SetActiveState(owner.SystemContext, true);
alarm.SetAcknowledgedState(owner.SystemContext, false);
alarm.Retain.Value = true;
break;
case "Acknowledged":
alarm.SetAcknowledgedState(owner.SystemContext, true);
break;
case "Inactive":
alarm.SetActiveState(owner.SystemContext, false);
// Retain stays true until the condition is both Inactive and Acknowledged
// so alarm clients keep the record in their condition refresh snapshot.
if (alarm.AckedState.Id.Value) alarm.Retain.Value = false;
break;
}
alarm.ClearChangeMasks(owner.SystemContext, true);
alarm.ReportEvent(owner.SystemContext, alarm);
}
}
private static int MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 250,
AlarmSeverity.Medium => 500,
AlarmSeverity.High => 700,
AlarmSeverity.Critical => 900,
_ => 500,
};
}
/// <summary>
/// Per-variable write hook wired on each <see cref="BaseDataVariableState"/>. Routes the
/// value into the driver's <see cref="IWritable"/> and surfaces its per-tag status code.
/// </summary>
private ServiceResult OnWriteValue(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
{
if (_writable is null) return StatusCodes.BadNotWritable;
var fullRef = node.NodeId.Identifier as string;
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
try
{
var results = _writable.WriteAsync(
[new DriverWriteRequest(fullRef!, value)],
CancellationToken.None).GetAwaiter().GetResult();
if (results.Count > 0 && results[0].StatusCode != 0)
{
statusCode = results[0].StatusCode;
return ServiceResult.Good;
}
return ServiceResult.Good;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Write failed for {FullRef}", fullRef);
return new ServiceResult(StatusCodes.BadInternalError);
}
}
// Diagnostics hook for tests — number of variables registered in this node manager.
internal int VariableCount => _variablesByFullRef.Count;
internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v)
=> _variablesByFullRef.TryGetValue(fullRef, out v!);
}