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