Implement LmxOpcUa server — all 6 phases complete
Full OPC UA server on .NET Framework 4.8 (x86) exposing AVEVA System Platform Galaxy tags via MXAccess. Mirrors Galaxy object hierarchy as OPC UA address space, translating contained-name browse paths to tag-name runtime references. Components implemented: - Configuration: AppConfiguration with 4 sections, validator - Domain: ConnectionState, Quality, Vtq, MxDataTypeMapper, error codes - MxAccess: StaComThread, MxAccessClient (partial classes), MxProxyAdapter using strongly-typed ArchestrA.MxAccess COM interop - Galaxy Repository: SQL queries (hierarchy, attributes, change detection), ChangeDetectionService with auto-rebuild on deploy - OPC UA Server: LmxNodeManager (CustomNodeManager2), LmxOpcUaServer, OpcUaServerHost with programmatic config, SecurityPolicy None - Status Dashboard: HTTP server with HTML/JSON/health endpoints - Integration: Full 14-step startup, graceful shutdown, component wiring 175 tests (174 unit + 1 integration), all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
369
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
Normal file
369
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
Normal file
@@ -0,0 +1,369 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom node manager that builds the OPC UA address space from Galaxy hierarchy data.
|
||||
/// (OPC-002 through OPC-013)
|
||||
/// </summary>
|
||||
public class LmxNodeManager : CustomNodeManager2
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<LmxNodeManager>();
|
||||
|
||||
private readonly IMxAccessClient _mxAccessClient;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly string _namespaceUri;
|
||||
|
||||
// NodeId → full_tag_reference for read/write resolution
|
||||
private readonly Dictionary<string, string> _nodeIdToTagReference = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Ref-counted MXAccess subscriptions
|
||||
private readonly Dictionary<string, int> _subscriptionRefCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, BaseDataVariableState> _tagToVariableNode = new Dictionary<string, BaseDataVariableState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private IDictionary<NodeId, IList<IReference>>? _externalReferences;
|
||||
|
||||
public IReadOnlyDictionary<string, string> NodeIdToTagReference => _nodeIdToTagReference;
|
||||
public int VariableNodeCount { get; private set; }
|
||||
public int ObjectNodeCount { get; private set; }
|
||||
|
||||
public LmxNodeManager(
|
||||
IServerInternal server,
|
||||
ApplicationConfiguration configuration,
|
||||
string namespaceUri,
|
||||
IMxAccessClient mxAccessClient,
|
||||
PerformanceMetrics metrics)
|
||||
: base(server, configuration, namespaceUri)
|
||||
{
|
||||
_namespaceUri = namespaceUri;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
_metrics = metrics;
|
||||
|
||||
// Wire up data change delivery
|
||||
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
|
||||
}
|
||||
|
||||
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_externalReferences = externalReferences;
|
||||
base.CreateAddressSpace(externalReferences);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the address space from Galaxy hierarchy and attributes data. (OPC-002, OPC-003)
|
||||
/// </summary>
|
||||
public void BuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_nodeIdToTagReference.Clear();
|
||||
_tagToVariableNode.Clear();
|
||||
VariableNodeCount = 0;
|
||||
ObjectNodeCount = 0;
|
||||
|
||||
// Build lookup: gobject_id → object info
|
||||
var objectMap = hierarchy.ToDictionary(h => h.GobjectId);
|
||||
|
||||
// Build lookup: gobject_id → list of attributes
|
||||
var attrsByObject = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Find root objects (those whose parent is not in the hierarchy)
|
||||
var rootFolder = CreateFolder(null, "ZB", "ZB");
|
||||
rootFolder.NodeId = new NodeId("ZB", NamespaceIndex);
|
||||
rootFolder.AddReference(ReferenceTypeIds.Organizes, true, ObjectIds.ObjectsFolder);
|
||||
|
||||
// Add reverse reference from Objects folder to our root
|
||||
var extRefs = _externalReferences ?? new Dictionary<NodeId, IList<IReference>>();
|
||||
AddExternalReference(ObjectIds.ObjectsFolder, ReferenceTypeIds.Organizes, false, rootFolder.NodeId, extRefs);
|
||||
|
||||
AddPredefinedNode(SystemContext, rootFolder);
|
||||
|
||||
// Create nodes for each object in hierarchy
|
||||
var nodeMap = new Dictionary<int, NodeState>();
|
||||
var parentIds = new HashSet<int>(hierarchy.Select(h => h.ParentGobjectId));
|
||||
|
||||
foreach (var obj in hierarchy)
|
||||
{
|
||||
NodeState parentNode;
|
||||
if (nodeMap.TryGetValue(obj.ParentGobjectId, out var p))
|
||||
parentNode = p;
|
||||
else
|
||||
parentNode = rootFolder;
|
||||
|
||||
NodeState node;
|
||||
if (obj.IsArea)
|
||||
{
|
||||
// Areas → FolderType + Organizes reference
|
||||
var folder = CreateFolder(parentNode, obj.BrowseName, obj.BrowseName);
|
||||
folder.NodeId = new NodeId(obj.TagName, NamespaceIndex);
|
||||
node = folder;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-areas → BaseObjectType + HasComponent reference
|
||||
var objNode = CreateObject(parentNode, obj.BrowseName, obj.BrowseName);
|
||||
objNode.NodeId = new NodeId(obj.TagName, NamespaceIndex);
|
||||
node = objNode;
|
||||
ObjectNodeCount++;
|
||||
}
|
||||
|
||||
AddPredefinedNode(SystemContext, node);
|
||||
nodeMap[obj.GobjectId] = node;
|
||||
|
||||
// Create variable nodes for this object's attributes
|
||||
if (attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs))
|
||||
{
|
||||
foreach (var attr in objAttrs)
|
||||
{
|
||||
CreateAttributeVariable(node, attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("Address space built: {Objects} objects, {Variables} variables, {Mappings} tag references",
|
||||
ObjectNodeCount, VariableNodeCount, _nodeIdToTagReference.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the address space, removing old nodes and creating new ones. (OPC-010)
|
||||
/// </summary>
|
||||
public void RebuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
Log.Information("Rebuilding address space...");
|
||||
|
||||
// Remove all predefined nodes
|
||||
var nodesToRemove = new List<NodeId>();
|
||||
foreach (var kvp in _nodeIdToTagReference)
|
||||
{
|
||||
var nodeId = new NodeId(kvp.Key, NamespaceIndex);
|
||||
nodesToRemove.Add(nodeId);
|
||||
}
|
||||
|
||||
foreach (var nodeId in PredefinedNodes.Keys.ToList())
|
||||
{
|
||||
try { DeleteNode(SystemContext, nodeId); }
|
||||
catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
|
||||
PredefinedNodes.Clear();
|
||||
_nodeIdToTagReference.Clear();
|
||||
_tagToVariableNode.Clear();
|
||||
_subscriptionRefCounts.Clear();
|
||||
|
||||
// Rebuild
|
||||
BuildAddressSpace(hierarchy, attributes);
|
||||
Log.Information("Address space rebuild complete");
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateAttributeVariable(NodeState parent, GalaxyAttributeInfo attr)
|
||||
{
|
||||
var opcUaDataTypeId = MxDataTypeMapper.MapToOpcUaDataType(attr.MxDataType);
|
||||
var variable = CreateVariable(parent, attr.AttributeName, attr.AttributeName, new NodeId(opcUaDataTypeId),
|
||||
attr.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar);
|
||||
|
||||
var nodeIdString = attr.FullTagReference;
|
||||
variable.NodeId = new NodeId(nodeIdString, NamespaceIndex);
|
||||
|
||||
if (attr.IsArray && attr.ArrayDimension.HasValue)
|
||||
{
|
||||
variable.ArrayDimensions = new ReadOnlyList<uint>(new List<uint> { (uint)attr.ArrayDimension.Value });
|
||||
}
|
||||
|
||||
variable.AccessLevel = AccessLevels.CurrentReadOrWrite;
|
||||
variable.UserAccessLevel = AccessLevels.CurrentReadOrWrite;
|
||||
variable.StatusCode = StatusCodes.BadWaitingForInitialData;
|
||||
variable.Timestamp = DateTime.UtcNow;
|
||||
|
||||
AddPredefinedNode(SystemContext, variable);
|
||||
_nodeIdToTagReference[nodeIdString] = attr.FullTagReference;
|
||||
_tagToVariableNode[attr.FullTagReference] = variable;
|
||||
VariableNodeCount++;
|
||||
}
|
||||
|
||||
private FolderState CreateFolder(NodeState? parent, string path, string name)
|
||||
{
|
||||
var folder = new FolderState(parent)
|
||||
{
|
||||
SymbolicName = name,
|
||||
ReferenceTypeId = ReferenceTypes.Organizes,
|
||||
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||||
NodeId = new NodeId(path, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(name, NamespaceIndex),
|
||||
DisplayName = new LocalizedText("en", name),
|
||||
WriteMask = AttributeWriteMask.None,
|
||||
UserWriteMask = AttributeWriteMask.None,
|
||||
EventNotifier = EventNotifiers.None
|
||||
};
|
||||
|
||||
parent?.AddChild(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
private BaseObjectState CreateObject(NodeState parent, string path, string name)
|
||||
{
|
||||
var obj = new BaseObjectState(parent)
|
||||
{
|
||||
SymbolicName = name,
|
||||
ReferenceTypeId = ReferenceTypes.HasComponent,
|
||||
TypeDefinitionId = ObjectTypeIds.BaseObjectType,
|
||||
NodeId = new NodeId(path, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(name, NamespaceIndex),
|
||||
DisplayName = new LocalizedText("en", name),
|
||||
WriteMask = AttributeWriteMask.None,
|
||||
UserWriteMask = AttributeWriteMask.None,
|
||||
EventNotifier = EventNotifiers.None
|
||||
};
|
||||
|
||||
parent.AddChild(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
private BaseDataVariableState CreateVariable(NodeState parent, string path, string name, NodeId dataType, int valueRank)
|
||||
{
|
||||
var variable = new BaseDataVariableState(parent)
|
||||
{
|
||||
SymbolicName = name,
|
||||
ReferenceTypeId = ReferenceTypes.HasComponent,
|
||||
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
||||
NodeId = new NodeId(path, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(name, NamespaceIndex),
|
||||
DisplayName = new LocalizedText("en", name),
|
||||
WriteMask = AttributeWriteMask.None,
|
||||
UserWriteMask = AttributeWriteMask.None,
|
||||
DataType = dataType,
|
||||
ValueRank = valueRank,
|
||||
AccessLevel = AccessLevels.CurrentReadOrWrite,
|
||||
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
|
||||
Historizing = false,
|
||||
StatusCode = StatusCodes.Good,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
parent.AddChild(variable);
|
||||
return variable;
|
||||
}
|
||||
|
||||
#region Read/Write Handlers
|
||||
|
||||
public override void Read(OperationContext context, double maxAge, IList<ReadValueId> nodesToRead,
|
||||
IList<DataValue> results, IList<ServiceResult> errors)
|
||||
{
|
||||
base.Read(context, maxAge, nodesToRead, results, errors);
|
||||
|
||||
for (int i = 0; i < nodesToRead.Count; i++)
|
||||
{
|
||||
var nodeId = nodesToRead[i].NodeId;
|
||||
if (nodeId.NamespaceIndex != NamespaceIndex) continue;
|
||||
|
||||
var nodeIdStr = nodeId.Identifier as string;
|
||||
if (nodeIdStr == null) continue;
|
||||
|
||||
if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||
{
|
||||
try
|
||||
{
|
||||
var vtq = _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult();
|
||||
results[i] = DataValueConverter.FromVtq(vtq);
|
||||
errors[i] = ServiceResult.Good;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Read failed for {TagRef}", tagRef);
|
||||
errors[i] = new ServiceResult(StatusCodes.BadInternalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(OperationContext context, IList<WriteValue> nodesToWrite,
|
||||
IList<ServiceResult> errors)
|
||||
{
|
||||
base.Write(context, nodesToWrite, errors);
|
||||
|
||||
for (int i = 0; i < nodesToWrite.Count; i++)
|
||||
{
|
||||
var nodeId = nodesToWrite[i].NodeId;
|
||||
if (nodeId.NamespaceIndex != NamespaceIndex) continue;
|
||||
|
||||
var nodeIdStr = nodeId.Identifier as string;
|
||||
if (nodeIdStr == null) continue;
|
||||
|
||||
if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = nodesToWrite[i].Value.WrappedValue.Value;
|
||||
var success = _mxAccessClient.WriteAsync(tagRef, value).GetAwaiter().GetResult();
|
||||
errors[i] = success ? ServiceResult.Good : new ServiceResult(StatusCodes.BadInternalError);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Write failed for {TagRef}", tagRef);
|
||||
errors[i] = new ServiceResult(StatusCodes.BadInternalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Subscription Delivery
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to MXAccess for the given tag reference. Called by the service wiring layer.
|
||||
/// </summary>
|
||||
public void SubscribeTag(string fullTagReference)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
|
||||
{
|
||||
_subscriptionRefCounts[fullTagReference] = count + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_subscriptionRefCounts[fullTagReference] = 1;
|
||||
_ = _mxAccessClient.SubscribeAsync(fullTagReference, (_, _) => { });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMxAccessDataChange(string address, Vtq vtq)
|
||||
{
|
||||
if (_tagToVariableNode.TryGetValue(address, out var variable))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataValue = DataValueConverter.FromVtq(vtq);
|
||||
variable.Value = dataValue.Value;
|
||||
variable.StatusCode = dataValue.StatusCode;
|
||||
variable.Timestamp = dataValue.SourceTimestamp;
|
||||
variable.ClearChangeMasks(SystemContext, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error updating variable node for {Address}", address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user