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:
Joseph Doherty
2026-03-25 05:55:27 -04:00
commit a7576ffb38
283 changed files with 16493 additions and 0 deletions

View 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
}
}