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
{
///
/// Custom node manager that builds the OPC UA address space from Galaxy hierarchy data.
/// (OPC-002 through OPC-013)
///
public class LmxNodeManager : CustomNodeManager2
{
private static readonly ILogger Log = Serilog.Log.ForContext();
private readonly IMxAccessClient _mxAccessClient;
private readonly PerformanceMetrics _metrics;
private readonly string _namespaceUri;
// NodeId → full_tag_reference for read/write resolution
private readonly Dictionary _nodeIdToTagReference = new Dictionary(StringComparer.OrdinalIgnoreCase);
// Ref-counted MXAccess subscriptions
private readonly Dictionary _subscriptionRefCounts = new Dictionary(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _tagToVariableNode = new Dictionary(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new object();
private IDictionary>? _externalReferences;
public IReadOnlyDictionary 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> externalReferences)
{
lock (Lock)
{
_externalReferences = externalReferences;
base.CreateAddressSpace(externalReferences);
}
}
///
/// Builds the address space from Galaxy hierarchy and attributes data. (OPC-002, OPC-003)
///
public void BuildAddressSpace(List hierarchy, List attributes)
{
lock (Lock)
{
_nodeIdToTagReference.Clear();
_tagToVariableNode.Clear();
VariableNodeCount = 0;
ObjectNodeCount = 0;
// Topological sort: ensure parents appear before children regardless of input order
var sorted = TopologicalSort(hierarchy);
// Build lookup: gobject_id → list of attributes
var attrsByObject = attributes
.GroupBy(a => a.GobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
// Root folder
var rootFolder = CreateFolder(null, "ZB", "ZB");
rootFolder.NodeId = new NodeId("ZB", NamespaceIndex);
rootFolder.AddReference(ReferenceTypeIds.Organizes, true, ObjectIds.ObjectsFolder);
AddPredefinedNode(SystemContext, rootFolder);
// Add reverse reference from Objects folder → ZB root.
// BuildAddressSpace runs after CreateAddressSpace completes, so the
// externalReferences dict has already been consumed by the core node manager.
// Use MasterNodeManager.AddReferences to route the reference correctly.
Server.NodeManager.AddReferences(ObjectIds.ObjectsFolder, new List
{
new NodeStateReference(ReferenceTypeIds.Organizes, false, rootFolder.NodeId)
});
// Create nodes for each object in hierarchy
var nodeMap = new Dictionary();
foreach (var obj in sorted)
{
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);
}
}
///
/// Rebuilds the address space, removing old nodes and creating new ones. (OPC-010)
///
public void RebuildAddressSpace(List hierarchy, List attributes)
{
lock (Lock)
{
Log.Information("Rebuilding address space...");
var activeSubscriptions = new Dictionary(_subscriptionRefCounts, StringComparer.OrdinalIgnoreCase);
foreach (var tagRef in activeSubscriptions.Keys)
{
try
{
_mxAccessClient.UnsubscribeAsync(tagRef).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to unsubscribe {TagRef} during rebuild", tagRef);
}
}
// Remove all predefined nodes
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);
foreach (var kvp in activeSubscriptions)
{
if (!_tagToVariableNode.ContainsKey(kvp.Key))
continue;
try
{
_mxAccessClient.SubscribeAsync(kvp.Key, (_, _) => { }).GetAwaiter().GetResult();
_subscriptionRefCounts[kvp.Key] = kvp.Value;
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to restore subscription for {TagRef} after rebuild", kvp.Key);
}
}
Log.Information("Address space rebuild complete");
}
}
///
/// Sorts hierarchy so parents always appear before children, regardless of input order.
///
private static List TopologicalSort(List hierarchy)
{
var byId = hierarchy.ToDictionary(h => h.GobjectId);
var knownIds = new HashSet(hierarchy.Select(h => h.GobjectId));
var visited = new HashSet();
var result = new List(hierarchy.Count);
void Visit(GalaxyObjectInfo obj)
{
if (!visited.Add(obj.GobjectId)) return;
// Visit parent first if it exists in the hierarchy
if (knownIds.Contains(obj.ParentGobjectId) && byId.TryGetValue(obj.ParentGobjectId, out var parent))
Visit(parent);
result.Add(obj);
}
foreach (var obj in hierarchy)
Visit(obj);
return result;
}
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(new List { (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 nodesToRead,
IList results, IList 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 nodesToWrite,
IList 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
///
/// Called by the OPC UA framework after monitored items are created on nodes in our namespace.
/// Triggers ref-counted MXAccess subscriptions for the underlying tags.
///
protected override void OnCreateMonitoredItemsComplete(ServerSystemContext context, IList monitoredItems)
{
foreach (var item in monitoredItems)
{
var nodeIdStr = GetNodeIdString(item);
if (nodeIdStr != null && _nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
SubscribeTag(tagRef);
}
}
///
/// Called by the OPC UA framework after monitored items are deleted.
/// Decrements ref-counted MXAccess subscriptions.
///
protected override void OnDeleteMonitoredItemsComplete(ServerSystemContext context, IList monitoredItems)
{
foreach (var item in monitoredItems)
{
var nodeIdStr = GetNodeIdString(item);
if (nodeIdStr != null && _nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
UnsubscribeTag(tagRef);
}
}
private static string? GetNodeIdString(IMonitoredItem item)
{
if (item.ManagerHandle is NodeState node)
return node.NodeId?.Identifier as string;
return null;
}
internal void SubscribeTag(string fullTagReference)
{
lock (_lock)
{
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
{
_subscriptionRefCounts[fullTagReference] = count + 1;
}
else
{
_subscriptionRefCounts[fullTagReference] = 1;
_ = _mxAccessClient.SubscribeAsync(fullTagReference, (_, _) => { });
}
}
}
internal void UnsubscribeTag(string fullTagReference)
{
lock (_lock)
{
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
{
if (count <= 1)
{
_subscriptionRefCounts.Remove(fullTagReference);
_ = _mxAccessClient.UnsubscribeAsync(fullTagReference);
}
else
{
_subscriptionRefCounts[fullTagReference] = count - 1;
}
}
}
}
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
}
}