Files
lmxopcua/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
Joseph Doherty bbd043e97b Add authentication and role-based write access control
Implements configurable user authentication (anonymous + username/password)
with pluggable credential provider (IUserAuthenticationProvider). Anonymous
writes can be disabled via AnonymousCanWrite setting while reads remain
open. Adds -U/-P flags to all CLI commands for authenticated sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:14:37 -04:00

1800 lines
80 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Opc.Ua;
using Opc.Ua.Server;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
using ZB.MOM.WW.LmxOpcUa.Host.Historian;
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 HistorianDataSource? _historianDataSource;
private readonly bool _alarmTrackingEnabled;
private readonly bool _anonymousCanWrite;
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 Dictionary<string, TagMetadata> _tagMetadata = new Dictionary<string, TagMetadata>(StringComparer.OrdinalIgnoreCase);
private IDictionary<NodeId, IList<IReference>>? _externalReferences;
// Data change dispatch queue: decouples MXAccess STA callbacks from OPC UA framework Lock
private readonly ConcurrentDictionary<string, Vtq> _pendingDataChanges = new ConcurrentDictionary<string, Vtq>(StringComparer.OrdinalIgnoreCase);
private readonly AutoResetEvent _dataChangeSignal = new AutoResetEvent(false);
private Thread? _dispatchThread;
private volatile bool _dispatchRunning;
private volatile bool _dispatchDisposed;
// Dispatch queue metrics
private long _totalMxChangeEvents;
private long _lastReportedMxChangeEvents;
private long _totalDispatchBatchSize;
private long _dispatchCycleCount;
private DateTime _lastMetricsReportTime = DateTime.UtcNow;
private double _lastEventsPerSecond;
private double _lastAvgBatchSize;
private sealed class TagMetadata
{
/// <summary>
/// Gets or sets the MXAccess data type code used to map Galaxy values into OPC UA variants.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the source Galaxy attribute should be exposed as an array node.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array.
/// </summary>
public int? ArrayDimension { get; set; }
}
// Alarm tracking: maps InAlarm tag reference → alarm source info
private readonly Dictionary<string, AlarmInfo> _alarmInAlarmTags = new Dictionary<string, AlarmInfo>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AlarmInfo> _alarmAckedTags = new Dictionary<string, AlarmInfo>(StringComparer.OrdinalIgnoreCase);
// Incremental sync: persistent node map and reverse lookup
private readonly Dictionary<int, NodeState> _nodeMap = new Dictionary<int, NodeState>();
private readonly Dictionary<int, List<string>> _gobjectToTagRefs = new Dictionary<int, List<string>>();
private List<GalaxyObjectInfo>? _lastHierarchy;
private List<GalaxyAttributeInfo>? _lastAttributes;
private sealed class AlarmInfo
{
/// <summary>
/// Gets or sets the full tag reference for the process value whose alarm state is tracked.
/// </summary>
public string SourceTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the OPC UA node identifier for the source variable that owns the alarm condition.
/// </summary>
public NodeId SourceNodeId { get; set; } = NodeId.Null;
/// <summary>
/// Gets or sets the operator-facing source name used in generated alarm events.
/// </summary>
public string SourceName { get; set; } = "";
/// <summary>
/// Gets or sets the most recent in-alarm state so duplicate transitions are not reissued.
/// </summary>
public bool LastInAlarm { get; set; }
/// <summary>
/// Gets or sets the retained OPC UA condition node associated with the source alarm.
/// </summary>
public AlarmConditionState? ConditionNode { get; set; }
/// <summary>
/// Gets or sets the Galaxy tag reference that supplies runtime alarm priority updates.
/// </summary>
public string PriorityTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy tag reference or attribute binding used to resolve the alarm message text.
/// </summary>
public string DescAttrNameTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the cached OPC UA severity derived from the latest alarm priority value.
/// </summary>
public ushort CachedSeverity { get; set; }
/// <summary>
/// Gets or sets the cached alarm message used when emitting active and cleared events.
/// </summary>
public string CachedMessage { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy tag reference for the alarm acknowledged state.
/// </summary>
public string AckedTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment.
/// </summary>
public string AckMsgTagReference { get; set; } = "";
}
/// <summary>
/// Gets the mapping from OPC UA node identifiers to the Galaxy tag references used for runtime I/O.
/// </summary>
public IReadOnlyDictionary<string, string> NodeIdToTagReference => _nodeIdToTagReference;
/// <summary>
/// Gets the number of variable nodes currently published from Galaxy attributes.
/// </summary>
public int VariableNodeCount { get; private set; }
/// <summary>
/// Gets the number of non-area object nodes currently published from the Galaxy hierarchy.
/// </summary>
public int ObjectNodeCount { get; private set; }
/// <summary>
/// Gets the total number of MXAccess data change events received since startup.
/// </summary>
public long TotalMxChangeEvents => Interlocked.Read(ref _totalMxChangeEvents);
/// <summary>
/// Gets the number of items currently waiting in the dispatch queue.
/// </summary>
public int PendingDataChangeCount => _pendingDataChanges.Count;
/// <summary>
/// Gets the most recently computed MXAccess data change events per second.
/// </summary>
public double MxChangeEventsPerSecond => _lastEventsPerSecond;
/// <summary>
/// Gets the most recently computed average dispatch batch size (proxy for queue depth under load).
/// </summary>
public double AverageDispatchBatchSize => _lastAvgBatchSize;
/// <summary>
/// Initializes a new node manager for the Galaxy-backed OPC UA namespace.
/// </summary>
/// <param name="server">The hosting OPC UA server internals.</param>
/// <param name="configuration">The OPC UA application configuration for the host.</param>
/// <param name="namespaceUri">The namespace URI that identifies the Galaxy model to clients.</param>
/// <param name="mxAccessClient">The runtime client used to service reads, writes, and subscriptions.</param>
/// <param name="metrics">The metrics collector used to track node manager activity.</param>
/// <param name="historianDataSource">The optional historian adapter used to satisfy OPC UA history read requests.</param>
/// <param name="alarmTrackingEnabled">Enables alarm-condition state generation for Galaxy attributes modeled as alarms.</param>
public LmxNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
string namespaceUri,
IMxAccessClient mxAccessClient,
PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null,
bool alarmTrackingEnabled = false,
bool anonymousCanWrite = true)
: base(server, configuration, namespaceUri)
{
_namespaceUri = namespaceUri;
_mxAccessClient = mxAccessClient;
_metrics = metrics;
_historianDataSource = historianDataSource;
_alarmTrackingEnabled = alarmTrackingEnabled;
_anonymousCanWrite = anonymousCanWrite;
// Wire up data change delivery
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
// Start background dispatch thread
StartDispatchThread();
}
/// <inheritdoc />
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>
/// <param name="hierarchy">The Galaxy object hierarchy that defines folders and objects in the namespace.</param>
/// <param name="attributes">The Galaxy attributes that become OPC UA variable nodes.</param>
public void BuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
lock (Lock)
{
_nodeIdToTagReference.Clear();
_tagToVariableNode.Clear();
_tagMetadata.Clear();
_alarmInAlarmTags.Clear();
_alarmAckedTags.Clear();
_nodeMap.Clear();
_gobjectToTagRefs.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 — enable events so alarm events propagate to clients subscribed at root
var rootFolder = CreateFolder(null, "ZB", "ZB");
rootFolder.NodeId = new NodeId("ZB", NamespaceIndex);
rootFolder.EventNotifier = EventNotifiers.SubscribeToEvents;
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<IReference>
{
new NodeStateReference(ReferenceTypeIds.Organizes, false, rootFolder.NodeId)
});
// Create nodes for each object in hierarchy
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))
{
// Group by primitive_name: empty = direct child, non-empty = sub-object
var byPrimitive = objAttrs
.GroupBy(a => a.PrimitiveName ?? "")
.OrderBy(g => g.Key);
// Collect primitive group names so we know which direct attributes have children
var primitiveGroupNames = new HashSet<string>(
byPrimitive.Select(g => g.Key).Where(k => !string.IsNullOrEmpty(k)),
StringComparer.OrdinalIgnoreCase);
// Track variable nodes created for direct attributes that also have primitive children
var variableNodes = new Dictionary<string, BaseDataVariableState>(StringComparer.OrdinalIgnoreCase);
// First pass: create direct (root-level) attribute variables
var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key));
if (directGroup != null)
{
foreach (var attr in directGroup)
{
var variable = CreateAttributeVariable(node, attr);
if (primitiveGroupNames.Contains(attr.AttributeName))
{
variableNodes[attr.AttributeName] = variable;
}
}
}
// Second pass: add primitive child attributes under the matching variable node
foreach (var group in byPrimitive)
{
if (string.IsNullOrEmpty(group.Key))
continue;
NodeState parentForAttrs;
if (variableNodes.TryGetValue(group.Key, out var existingVariable))
{
// Merge: use the existing variable node as parent
parentForAttrs = existingVariable;
}
else
{
// No matching dynamic attribute — create an object node
var primNode = CreateObject(node, group.Key, group.Key);
primNode.NodeId = new NodeId(obj.TagName + "." + group.Key, NamespaceIndex);
AddPredefinedNode(SystemContext, primNode);
parentForAttrs = primNode;
}
foreach (var attr in group)
{
CreateAttributeVariable(parentForAttrs, attr);
}
}
}
}
// Build alarm tracking: create AlarmConditionState for each alarm attribute
if (_alarmTrackingEnabled) foreach (var obj in sorted)
{
if (obj.IsArea) continue;
if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue;
var hasAlarms = false;
var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)).ToList();
foreach (var alarmAttr in alarmAttrs)
{
var inAlarmTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']') + ".InAlarm";
if (!_tagToVariableNode.ContainsKey(inAlarmTagRef))
continue;
var alarmNodeIdStr = alarmAttr.FullTagReference.EndsWith("[]")
? alarmAttr.FullTagReference.Substring(0, alarmAttr.FullTagReference.Length - 2)
: alarmAttr.FullTagReference;
// Find the source variable node for the alarm
_tagToVariableNode.TryGetValue(alarmAttr.FullTagReference, out var sourceVariable);
var sourceNodeId = new NodeId(alarmNodeIdStr, NamespaceIndex);
// Create AlarmConditionState attached to the source variable
var conditionNodeId = new NodeId(alarmNodeIdStr + ".Condition", NamespaceIndex);
var condition = new AlarmConditionState(sourceVariable);
condition.Create(SystemContext, conditionNodeId,
new QualifiedName(alarmAttr.AttributeName + "Alarm", NamespaceIndex),
new LocalizedText("en", alarmAttr.AttributeName + " Alarm"),
true);
condition.SourceNode.Value = sourceNodeId;
condition.SourceName.Value = alarmAttr.FullTagReference.TrimEnd('[', ']');
condition.ConditionName.Value = alarmAttr.AttributeName;
condition.AutoReportStateChanges = true;
// Set initial state: enabled, inactive, acknowledged
condition.SetEnableState(SystemContext, true);
condition.SetActiveState(SystemContext, false);
condition.SetAcknowledgedState(SystemContext, true);
condition.SetSeverity(SystemContext, EventSeverity.Medium);
condition.Retain.Value = false;
condition.OnReportEvent = (context, node, e) => Server.ReportEvent(context, e);
condition.OnAcknowledge = OnAlarmAcknowledge;
// Add HasCondition reference from source to condition
if (sourceVariable != null)
{
sourceVariable.AddReference(ReferenceTypeIds.HasCondition, false, conditionNodeId);
condition.AddReference(ReferenceTypeIds.HasCondition, true, sourceNodeId);
}
AddPredefinedNode(SystemContext, condition);
var baseTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']');
var alarmInfo = new AlarmInfo
{
SourceTagReference = alarmAttr.FullTagReference,
SourceNodeId = sourceNodeId,
SourceName = alarmAttr.AttributeName,
ConditionNode = condition,
PriorityTagReference = baseTagRef + ".Priority",
DescAttrNameTagReference = baseTagRef + ".DescAttrName",
AckedTagReference = baseTagRef + ".Acked",
AckMsgTagReference = baseTagRef + ".AckMsg"
};
_alarmInAlarmTags[inAlarmTagRef] = alarmInfo;
_alarmAckedTags[alarmInfo.AckedTagReference] = alarmInfo;
hasAlarms = true;
}
// Enable EventNotifier on object nodes that contain alarms
if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode))
{
if (objNode is BaseObjectState objState)
objState.EventNotifier = EventNotifiers.SubscribeToEvents;
else if (objNode is FolderState folderState)
folderState.EventNotifier = EventNotifiers.SubscribeToEvents;
}
}
// Auto-subscribe to InAlarm tags so we detect alarm transitions
if (_alarmTrackingEnabled)
SubscribeAlarmTags();
_lastHierarchy = new List<GalaxyObjectInfo>(hierarchy);
_lastAttributes = new List<GalaxyAttributeInfo>(attributes);
Log.Information("Address space built: {Objects} objects, {Variables} variables, {Mappings} tag references, {Alarms} alarm tags",
ObjectNodeCount, VariableNodeCount, _nodeIdToTagReference.Count, _alarmInAlarmTags.Count);
}
}
private void SubscribeAlarmTags()
{
foreach (var kvp in _alarmInAlarmTags)
{
// Subscribe to InAlarm, Priority, and DescAttrName for each alarm
var tagsToSubscribe = new[] { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference, kvp.Value.AckedTagReference };
foreach (var tag in tagsToSubscribe)
{
if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag))
continue;
try
{
_mxAccessClient.SubscribeAsync(tag, (_, _) => { });
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to auto-subscribe to alarm tag {Tag}", tag);
}
}
}
}
private ServiceResult OnAlarmAcknowledge(
ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment)
{
var alarmInfo = _alarmInAlarmTags.Values
.FirstOrDefault(a => a.ConditionNode == condition);
if (alarmInfo == null)
return new ServiceResult(StatusCodes.BadNodeIdUnknown);
try
{
var ackMessage = comment?.Text ?? "";
_mxAccessClient.WriteAsync(alarmInfo.AckMsgTagReference, ackMessage)
.GetAwaiter().GetResult();
Log.Information("Alarm acknowledge sent: {Source} (Message={AckMsg})",
alarmInfo.SourceName, ackMessage);
return ServiceResult.Good;
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to write AckMsg for {Source}", alarmInfo.SourceName);
return new ServiceResult(StatusCodes.BadInternalError);
}
}
private void ReportAlarmEvent(AlarmInfo info, bool active)
{
var condition = info.ConditionNode;
if (condition == null)
return;
ushort severity = info.CachedSeverity;
string message = active
? (!string.IsNullOrEmpty(info.CachedMessage) ? info.CachedMessage : $"Alarm active: {info.SourceName}")
: $"Alarm cleared: {info.SourceName}";
// Set a new EventId so clients can reference this event for acknowledge
condition.EventId.Value = Guid.NewGuid().ToByteArray();
condition.SetActiveState(SystemContext, active);
condition.Message.Value = new LocalizedText("en", message);
condition.SetSeverity(SystemContext, (EventSeverity)severity);
// Retain while active or unacknowledged
condition.Retain.Value = active || (condition.AckedState?.Id?.Value == false);
// Reset acknowledged state when alarm activates
if (active)
condition.SetAcknowledgedState(SystemContext, false);
// Report through the source node hierarchy so events reach subscribers on parent objects
if (_tagToVariableNode.TryGetValue(info.SourceTagReference, out var sourceVar) && sourceVar.Parent != null)
{
sourceVar.Parent.ReportEvent(SystemContext, condition);
}
// Also report to Server node for clients subscribed at server level
Server.ReportEvent(SystemContext, condition);
Log.Information("Alarm {State}: {Source} (Severity={Severity}, Message={Message})",
active ? "ACTIVE" : "CLEARED", info.SourceName, severity, message);
}
/// <summary>
/// Rebuilds the address space, removing old nodes and creating new ones. (OPC-010)
/// </summary>
/// <param name="hierarchy">The latest Galaxy object hierarchy to publish.</param>
/// <param name="attributes">The latest Galaxy attributes to publish.</param>
public void RebuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
SyncAddressSpace(hierarchy, attributes);
}
/// <summary>
/// Incrementally syncs the address space by detecting changed gobjects and rebuilding only those subtrees. (OPC-010)
/// </summary>
/// <param name="hierarchy">The latest Galaxy object hierarchy snapshot to compare against the currently published model.</param>
/// <param name="attributes">The latest Galaxy attribute snapshot to compare against the currently published variables.</param>
public void SyncAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
lock (Lock)
{
if (_lastHierarchy == null || _lastAttributes == null)
{
Log.Information("No previous state cached — performing full build");
BuildAddressSpace(hierarchy, attributes);
return;
}
var changedIds = AddressSpaceDiff.FindChangedGobjectIds(
_lastHierarchy, _lastAttributes, hierarchy, attributes);
if (changedIds.Count == 0)
{
Log.Information("No address space changes detected");
_lastHierarchy = hierarchy;
_lastAttributes = attributes;
return;
}
// Expand to include child subtrees in both old and new hierarchy
changedIds = AddressSpaceDiff.ExpandToSubtrees(changedIds, _lastHierarchy);
changedIds = AddressSpaceDiff.ExpandToSubtrees(changedIds, hierarchy);
Log.Information("Incremental sync: {Count} gobjects changed out of {Total}",
changedIds.Count, hierarchy.Count);
// Snapshot subscriptions for changed tags before teardown
var affectedSubscriptions = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var id in changedIds)
{
if (_gobjectToTagRefs.TryGetValue(id, out var tagRefs))
{
foreach (var tagRef in tagRefs)
{
if (_subscriptionRefCounts.TryGetValue(tagRef, out var count))
affectedSubscriptions[tagRef] = count;
}
}
}
// Tear down changed subtrees
TearDownGobjects(changedIds);
// Rebuild changed subtrees from new data
var changedHierarchy = hierarchy.Where(h => changedIds.Contains(h.GobjectId)).ToList();
var changedAttributes = attributes.Where(a => changedIds.Contains(a.GobjectId)).ToList();
BuildSubtree(changedHierarchy, changedAttributes);
// Restore subscriptions for surviving tags
foreach (var kvp in affectedSubscriptions)
{
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 sync", kvp.Key);
}
}
_lastHierarchy = new List<GalaxyObjectInfo>(hierarchy);
_lastAttributes = new List<GalaxyAttributeInfo>(attributes);
Log.Information("Incremental sync complete: {Objects} objects, {Variables} variables, {Alarms} alarms",
ObjectNodeCount, VariableNodeCount, _alarmInAlarmTags.Count);
}
}
private void TearDownGobjects(HashSet<int> gobjectIds)
{
foreach (var id in gobjectIds)
{
// Remove variable nodes and their tracking data
if (_gobjectToTagRefs.TryGetValue(id, out var tagRefs))
{
foreach (var tagRef in tagRefs.ToList())
{
// Unsubscribe if actively subscribed
if (_subscriptionRefCounts.ContainsKey(tagRef))
{
try { _mxAccessClient.UnsubscribeAsync(tagRef).GetAwaiter().GetResult(); }
catch { /* ignore */ }
_subscriptionRefCounts.Remove(tagRef);
}
// Remove alarm tracking for this tag's InAlarm/Priority/DescAttrName
var alarmKeysToRemove = _alarmInAlarmTags
.Where(kvp => kvp.Value.SourceTagReference == tagRef)
.Select(kvp => kvp.Key)
.ToList();
foreach (var alarmKey in alarmKeysToRemove)
{
var info = _alarmInAlarmTags[alarmKey];
// Unsubscribe alarm auto-subscriptions
foreach (var alarmTag in new[] { alarmKey, info.PriorityTagReference, info.DescAttrNameTagReference })
{
if (!string.IsNullOrEmpty(alarmTag))
{
try { _mxAccessClient.UnsubscribeAsync(alarmTag).GetAwaiter().GetResult(); }
catch { /* ignore */ }
}
}
_alarmInAlarmTags.Remove(alarmKey);
if (!string.IsNullOrEmpty(info.AckedTagReference))
_alarmAckedTags.Remove(info.AckedTagReference);
}
// Delete variable node
if (_tagToVariableNode.TryGetValue(tagRef, out var variable))
{
try { DeleteNode(SystemContext, variable.NodeId); }
catch { /* ignore */ }
_tagToVariableNode.Remove(tagRef);
}
// Clean up remaining mappings
var nodeIdStr = _nodeIdToTagReference.FirstOrDefault(kvp => kvp.Value == tagRef).Key;
if (nodeIdStr != null)
_nodeIdToTagReference.Remove(nodeIdStr);
_tagMetadata.Remove(tagRef);
VariableNodeCount--;
}
_gobjectToTagRefs.Remove(id);
}
// Delete the object/folder node itself
if (_nodeMap.TryGetValue(id, out var objNode))
{
try { DeleteNode(SystemContext, objNode.NodeId); }
catch { /* ignore */ }
_nodeMap.Remove(id);
if (!(objNode is FolderState))
ObjectNodeCount--;
}
}
}
private void BuildSubtree(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
if (hierarchy.Count == 0)
return;
var sorted = TopologicalSort(hierarchy);
var attrsByObject = attributes
.GroupBy(a => a.GobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
// Find root folder for orphaned nodes
NodeState? rootFolder = null;
if (PredefinedNodes.TryGetValue(new NodeId("ZB", NamespaceIndex), out var rootNode))
rootFolder = rootNode;
foreach (var obj in sorted)
{
NodeState parentNode;
if (_nodeMap.TryGetValue(obj.ParentGobjectId, out var p))
parentNode = p;
else if (rootFolder != null)
parentNode = rootFolder;
else
continue; // no parent available
// Create node with final NodeId before adding to parent
NodeState node;
var nodeId = new NodeId(obj.TagName, NamespaceIndex);
if (obj.IsArea)
{
var folder = new FolderState(parentNode)
{
SymbolicName = obj.BrowseName,
ReferenceTypeId = ReferenceTypes.Organizes,
TypeDefinitionId = ObjectTypeIds.FolderType,
NodeId = nodeId,
BrowseName = new QualifiedName(obj.BrowseName, NamespaceIndex),
DisplayName = new LocalizedText("en", obj.BrowseName),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
EventNotifier = EventNotifiers.None
};
parentNode.AddChild(folder);
node = folder;
}
else
{
var objNode = new BaseObjectState(parentNode)
{
SymbolicName = obj.BrowseName,
ReferenceTypeId = ReferenceTypes.HasComponent,
TypeDefinitionId = ObjectTypeIds.BaseObjectType,
NodeId = nodeId,
BrowseName = new QualifiedName(obj.BrowseName, NamespaceIndex),
DisplayName = new LocalizedText("en", obj.BrowseName),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
EventNotifier = EventNotifiers.None
};
parentNode.AddChild(objNode);
node = objNode;
ObjectNodeCount++;
}
AddPredefinedNode(SystemContext, node);
_nodeMap[obj.GobjectId] = node;
parentNode.ClearChangeMasks(SystemContext, false);
// Create variable nodes (same logic as BuildAddressSpace)
if (attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs))
{
var byPrimitive = objAttrs
.GroupBy(a => a.PrimitiveName ?? "")
.OrderBy(g => g.Key);
var primitiveGroupNames = new HashSet<string>(
byPrimitive.Select(g => g.Key).Where(k => !string.IsNullOrEmpty(k)),
StringComparer.OrdinalIgnoreCase);
var variableNodes = new Dictionary<string, BaseDataVariableState>(StringComparer.OrdinalIgnoreCase);
var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key));
if (directGroup != null)
{
foreach (var attr in directGroup)
{
var variable = CreateAttributeVariable(node, attr);
if (primitiveGroupNames.Contains(attr.AttributeName))
variableNodes[attr.AttributeName] = variable;
}
}
foreach (var group in byPrimitive)
{
if (string.IsNullOrEmpty(group.Key))
continue;
NodeState parentForAttrs;
if (variableNodes.TryGetValue(group.Key, out var existingVariable))
{
parentForAttrs = existingVariable;
}
else
{
var primNode = CreateObject(node, group.Key, group.Key);
primNode.NodeId = new NodeId(obj.TagName + "." + group.Key, NamespaceIndex);
AddPredefinedNode(SystemContext, primNode);
parentForAttrs = primNode;
}
foreach (var attr in group)
{
CreateAttributeVariable(parentForAttrs, attr);
}
}
}
}
// Alarm tracking for the new subtree
if (_alarmTrackingEnabled)
{
foreach (var obj in sorted)
{
if (obj.IsArea) continue;
if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue;
var hasAlarms = false;
var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)).ToList();
foreach (var alarmAttr in alarmAttrs)
{
var inAlarmTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']') + ".InAlarm";
if (!_tagToVariableNode.ContainsKey(inAlarmTagRef))
continue;
var alarmNodeIdStr = alarmAttr.FullTagReference.EndsWith("[]")
? alarmAttr.FullTagReference.Substring(0, alarmAttr.FullTagReference.Length - 2)
: alarmAttr.FullTagReference;
_tagToVariableNode.TryGetValue(alarmAttr.FullTagReference, out var sourceVariable);
var sourceNodeId = new NodeId(alarmNodeIdStr, NamespaceIndex);
var conditionNodeId = new NodeId(alarmNodeIdStr + ".Condition", NamespaceIndex);
var condition = new AlarmConditionState(sourceVariable);
condition.Create(SystemContext, conditionNodeId,
new QualifiedName(alarmAttr.AttributeName + "Alarm", NamespaceIndex),
new LocalizedText("en", alarmAttr.AttributeName + " Alarm"),
true);
condition.SourceNode.Value = sourceNodeId;
condition.SourceName.Value = alarmAttr.FullTagReference.TrimEnd('[', ']');
condition.ConditionName.Value = alarmAttr.AttributeName;
condition.AutoReportStateChanges = true;
condition.SetEnableState(SystemContext, true);
condition.SetActiveState(SystemContext, false);
condition.SetAcknowledgedState(SystemContext, true);
condition.SetSeverity(SystemContext, EventSeverity.Medium);
condition.Retain.Value = false;
condition.OnReportEvent = (context, n, e) => Server.ReportEvent(context, e);
condition.OnAcknowledge = OnAlarmAcknowledge;
if (sourceVariable != null)
{
sourceVariable.AddReference(ReferenceTypeIds.HasCondition, false, conditionNodeId);
condition.AddReference(ReferenceTypeIds.HasCondition, true, sourceNodeId);
}
AddPredefinedNode(SystemContext, condition);
var baseTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']');
var alarmInfo = new AlarmInfo
{
SourceTagReference = alarmAttr.FullTagReference,
SourceNodeId = sourceNodeId,
SourceName = alarmAttr.AttributeName,
ConditionNode = condition,
PriorityTagReference = baseTagRef + ".Priority",
DescAttrNameTagReference = baseTagRef + ".DescAttrName",
AckedTagReference = baseTagRef + ".Acked",
AckMsgTagReference = baseTagRef + ".AckMsg"
};
_alarmInAlarmTags[inAlarmTagRef] = alarmInfo;
_alarmAckedTags[alarmInfo.AckedTagReference] = alarmInfo;
hasAlarms = true;
}
if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode))
{
if (objNode is BaseObjectState objState)
objState.EventNotifier = EventNotifiers.SubscribeToEvents;
else if (objNode is FolderState folderState)
folderState.EventNotifier = EventNotifiers.SubscribeToEvents;
}
}
// Subscribe alarm tags for new subtree
foreach (var kvp in _alarmInAlarmTags)
{
// Only subscribe tags that belong to the newly built subtree
var gobjectIds = new HashSet<int>(hierarchy.Select(h => h.GobjectId));
var sourceTagRef = kvp.Value.SourceTagReference;
var ownerAttr = attributes.FirstOrDefault(a => a.FullTagReference == sourceTagRef);
if (ownerAttr == null || !gobjectIds.Contains(ownerAttr.GobjectId))
continue;
foreach (var tag in new[] { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference })
{
if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag))
continue;
try { _mxAccessClient.SubscribeAsync(tag, (_, _) => { }); }
catch { /* ignore */ }
}
}
}
}
/// <summary>
/// Sorts hierarchy so parents always appear before children, regardless of input order.
/// </summary>
private static List<GalaxyObjectInfo> TopologicalSort(List<GalaxyObjectInfo> hierarchy)
{
var byId = hierarchy.ToDictionary(h => h.GobjectId);
var knownIds = new HashSet<int>(hierarchy.Select(h => h.GobjectId));
var visited = new HashSet<int>();
var result = new List<GalaxyObjectInfo>(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 BaseDataVariableState 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 = GetNodeIdentifier(attr);
variable.NodeId = new NodeId(nodeIdString, NamespaceIndex);
if (attr.IsArray && attr.ArrayDimension.HasValue)
{
variable.ArrayDimensions = new ReadOnlyList<uint>(new List<uint> { (uint)attr.ArrayDimension.Value });
}
var accessLevel = SecurityClassificationMapper.IsWritable(attr.SecurityClassification)
? AccessLevels.CurrentReadOrWrite
: AccessLevels.CurrentRead;
if (attr.IsHistorized)
{
accessLevel |= AccessLevels.HistoryRead;
}
variable.AccessLevel = accessLevel;
variable.UserAccessLevel = accessLevel;
variable.Historizing = attr.IsHistorized;
variable.Value = NormalizePublishedValue(attr.FullTagReference, null);
variable.StatusCode = StatusCodes.BadWaitingForInitialData;
variable.Timestamp = DateTime.UtcNow;
AddPredefinedNode(SystemContext, variable);
_nodeIdToTagReference[nodeIdString] = attr.FullTagReference;
_tagToVariableNode[attr.FullTagReference] = variable;
_tagMetadata[attr.FullTagReference] = new TagMetadata
{
MxDataType = attr.MxDataType,
IsArray = attr.IsArray,
ArrayDimension = attr.ArrayDimension
};
// Track gobject → tag references for incremental sync
if (!_gobjectToTagRefs.TryGetValue(attr.GobjectId, out var tagList))
{
tagList = new List<string>();
_gobjectToTagRefs[attr.GobjectId] = tagList;
}
tagList.Add(attr.FullTagReference);
VariableNodeCount++;
return variable;
}
private static string GetNodeIdentifier(GalaxyAttributeInfo attr)
{
if (!attr.IsArray)
return attr.FullTagReference;
return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal)
? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2)
: attr.FullTagReference;
}
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
/// <inheritdoc />
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++)
{
if (nodesToRead[i].AttributeId != Attributes.Value)
continue;
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] = CreatePublishedDataValue(tagRef, vtq);
errors[i] = ServiceResult.Good;
}
catch (Exception ex)
{
Log.Warning(ex, "Read failed for {TagRef}", tagRef);
errors[i] = new ServiceResult(StatusCodes.BadInternalError);
}
}
}
}
/// <inheritdoc />
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++)
{
if (nodesToWrite[i].AttributeId != Attributes.Value)
continue;
// Skip if base rejected due to access level (read-only node)
if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable)
continue;
// Enforce role-based write access: reject anonymous writes when AnonymousCanWrite is false
if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null &&
!context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser))
{
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue;
}
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 writeValue = nodesToWrite[i];
var value = writeValue.Value.WrappedValue.Value;
if (!string.IsNullOrWhiteSpace(writeValue.IndexRange))
{
if (!TryApplyArrayElementWrite(tagRef, value, writeValue.IndexRange, out var updatedArray))
{
errors[i] = new ServiceResult(StatusCodes.BadIndexRangeInvalid);
continue;
}
value = updatedArray;
}
var success = _mxAccessClient.WriteAsync(tagRef, value).GetAwaiter().GetResult();
if (success)
{
PublishLocalWrite(tagRef, value);
errors[i] = ServiceResult.Good;
}
else
{
errors[i] = new ServiceResult(StatusCodes.BadInternalError);
}
}
catch (Exception ex)
{
Log.Warning(ex, "Write failed for {TagRef}", tagRef);
errors[i] = new ServiceResult(StatusCodes.BadInternalError);
}
}
}
}
private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, out object updatedArray)
{
updatedArray = null!;
if (!int.TryParse(indexRange, out var index) || index < 0)
return false;
var currentValue = NormalizePublishedValue(tagRef, _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult().Value);
if (currentValue is not Array currentArray || currentArray.Rank != 1 || index >= currentArray.Length)
return false;
var nextArray = (Array)currentArray.Clone();
var elementType = currentArray.GetType().GetElementType();
if (elementType == null)
return false;
var normalizedValue = NormalizeIndexedWriteValue(writeValue);
nextArray.SetValue(ConvertArrayElementValue(normalizedValue, elementType), index);
updatedArray = nextArray;
return true;
}
private static object? NormalizeIndexedWriteValue(object? value)
{
if (value is Array array && array.Length == 1)
return array.GetValue(0);
return value;
}
private static object? ConvertArrayElementValue(object? value, Type elementType)
{
if (value == null)
{
if (elementType.IsValueType)
return Activator.CreateInstance(elementType);
return null;
}
if (elementType.IsInstanceOfType(value))
return value;
if (elementType == typeof(string))
return value.ToString();
return Convert.ChangeType(value, elementType);
}
private void PublishLocalWrite(string tagRef, object? value)
{
if (!_tagToVariableNode.TryGetValue(tagRef, out var variable))
return;
var dataValue = CreatePublishedDataValue(tagRef, Vtq.Good(value));
variable.Value = dataValue.Value;
variable.StatusCode = dataValue.StatusCode;
variable.Timestamp = dataValue.SourceTimestamp;
variable.ClearChangeMasks(SystemContext, false);
}
private DataValue CreatePublishedDataValue(string tagRef, Vtq vtq)
{
var normalizedValue = NormalizePublishedValue(tagRef, vtq.Value);
if (ReferenceEquals(normalizedValue, vtq.Value))
return DataValueConverter.FromVtq(vtq);
return DataValueConverter.FromVtq(new Vtq(normalizedValue, vtq.Timestamp, vtq.Quality));
}
private object? NormalizePublishedValue(string tagRef, object? value)
{
if (value != null)
return value;
if (!_tagMetadata.TryGetValue(tagRef, out var metadata) || !metadata.IsArray || !metadata.ArrayDimension.HasValue)
return null;
return CreateDefaultArrayValue(metadata);
}
private static Array CreateDefaultArrayValue(TagMetadata metadata)
{
var elementType = MxDataTypeMapper.MapToClrType(metadata.MxDataType);
var values = Array.CreateInstance(elementType, metadata.ArrayDimension!.Value);
if (elementType == typeof(string))
{
for (int i = 0; i < values.Length; i++)
values.SetValue(string.Empty, i);
}
return values;
}
#endregion
#region Condition Refresh
/// <inheritdoc />
/// <param name="context">The OPC UA request context for the condition refresh operation.</param>
/// <param name="monitoredItems">The monitored event items that should receive retained alarm conditions.</param>
public override ServiceResult ConditionRefresh(OperationContext context, IList<IEventMonitoredItem> monitoredItems)
{
foreach (var kvp in _alarmInAlarmTags)
{
var info = kvp.Value;
if (info.ConditionNode == null || info.ConditionNode.Retain?.Value != true)
continue;
foreach (var item in monitoredItems)
{
item.QueueEvent(info.ConditionNode);
}
}
return ServiceResult.Good;
}
#endregion
#region HistoryRead
/// <inheritdoc />
protected override void HistoryReadRawModified(
ServerSystemContext context,
ReadRawModifiedDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<HistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
foreach (var handle in nodesToProcess)
{
var idx = handle.Index;
var nodeIdStr = handle.NodeId?.Identifier as string;
if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
{
errors[idx] = new ServiceResult(StatusCodes.BadNodeIdUnknown);
continue;
}
if (_historianDataSource == null)
{
errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported);
continue;
}
try
{
var maxValues = details.NumValuesPerNode > 0 ? (int)details.NumValuesPerNode : 0;
var dataValues = _historianDataSource.ReadRawAsync(
tagRef, details.StartTime, details.EndTime, maxValues)
.GetAwaiter().GetResult();
var historyData = new HistoryData();
historyData.DataValues.AddRange(dataValues);
results[idx] = new HistoryReadResult
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(historyData)
};
errors[idx] = ServiceResult.Good;
}
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead raw failed for {TagRef}", tagRef);
errors[idx] = new ServiceResult(StatusCodes.BadInternalError);
}
}
}
/// <inheritdoc />
protected override void HistoryReadProcessed(
ServerSystemContext context,
ReadProcessedDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<HistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
foreach (var handle in nodesToProcess)
{
var idx = handle.Index;
var nodeIdStr = handle.NodeId?.Identifier as string;
if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
{
errors[idx] = new ServiceResult(StatusCodes.BadNodeIdUnknown);
continue;
}
if (_historianDataSource == null)
{
errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported);
continue;
}
if (details.AggregateType == null || details.AggregateType.Count == 0)
{
errors[idx] = new ServiceResult(StatusCodes.BadAggregateListMismatch);
continue;
}
var aggregateId = details.AggregateType[idx < details.AggregateType.Count ? idx : 0];
var column = HistorianDataSource.MapAggregateToColumn(aggregateId);
if (column == null)
{
errors[idx] = new ServiceResult(StatusCodes.BadAggregateNotSupported);
continue;
}
try
{
var dataValues = _historianDataSource.ReadAggregateAsync(
tagRef, details.StartTime, details.EndTime,
details.ProcessingInterval, column)
.GetAwaiter().GetResult();
var historyData = new HistoryData();
historyData.DataValues.AddRange(dataValues);
results[idx] = new HistoryReadResult
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(historyData)
};
errors[idx] = ServiceResult.Good;
}
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead processed failed for {TagRef}", tagRef);
errors[idx] = new ServiceResult(StatusCodes.BadInternalError);
}
}
}
#endregion
#region Subscription Delivery
/// <summary>
/// Called by the OPC UA framework during monitored item creation.
/// Triggers ref-counted MXAccess subscriptions early so the runtime value
/// can arrive before the initial publish to the client.
/// </summary>
/// <inheritdoc />
protected override void OnMonitoredItemCreated(ServerSystemContext context, NodeHandle handle, MonitoredItem monitoredItem)
{
base.OnMonitoredItemCreated(context, handle, monitoredItem);
var nodeIdStr = handle?.NodeId?.Identifier as string;
if (nodeIdStr != null && _nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
SubscribeTag(tagRef);
}
/// <summary>
/// Called by the OPC UA framework after monitored items are deleted.
/// Decrements ref-counted MXAccess subscriptions.
/// </summary>
/// <inheritdoc />
protected override void OnDeleteMonitoredItemsComplete(ServerSystemContext context, IList<IMonitoredItem> monitoredItems)
{
foreach (var item in monitoredItems)
{
var nodeIdStr = GetNodeIdString(item);
if (nodeIdStr != null && _nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
UnsubscribeTag(tagRef);
}
}
/// <summary>
/// Called by the OPC UA framework after monitored items are transferred to a new session.
/// Rebuilds MXAccess subscription bookkeeping when transferred items arrive without local in-memory state.
/// </summary>
/// <inheritdoc />
protected override void OnMonitoredItemsTransferred(ServerSystemContext context, IList<IMonitoredItem> monitoredItems)
{
base.OnMonitoredItemsTransferred(context, monitoredItems);
var transferredTagRefs = monitoredItems
.Select(GetNodeIdString)
.Where(nodeIdStr => nodeIdStr != null && _nodeIdToTagReference.ContainsKey(nodeIdStr))
.Select(nodeIdStr => _nodeIdToTagReference[nodeIdStr!])
.ToList();
RestoreTransferredSubscriptions(transferredTagRefs);
}
private static string? GetNodeIdString(IMonitoredItem item)
{
if (item.ManagerHandle is NodeState node)
return node.NodeId?.Identifier as string;
return null;
}
/// <summary>
/// Increments the subscription reference count for a Galaxy tag and opens the runtime subscription when the first OPC UA monitored item appears.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to subscribe.</param>
internal void SubscribeTag(string fullTagReference)
{
var shouldSubscribe = false;
lock (Lock)
{
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
{
_subscriptionRefCounts[fullTagReference] = count + 1;
}
else
{
_subscriptionRefCounts[fullTagReference] = 1;
shouldSubscribe = true;
}
}
if (shouldSubscribe)
_ = _mxAccessClient.SubscribeAsync(fullTagReference, (_, _) => { });
}
/// <summary>
/// Decrements the subscription reference count for a Galaxy tag and closes the runtime subscription when no OPC UA monitored items remain.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to unsubscribe.</param>
internal void UnsubscribeTag(string fullTagReference)
{
var shouldUnsubscribe = false;
lock (Lock)
{
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
{
if (count <= 1)
{
_subscriptionRefCounts.Remove(fullTagReference);
shouldUnsubscribe = true;
}
else
{
_subscriptionRefCounts[fullTagReference] = count - 1;
}
}
}
if (shouldUnsubscribe)
_ = _mxAccessClient.UnsubscribeAsync(fullTagReference);
}
/// <summary>
/// Rebuilds subscription reference counts for monitored items that were transferred by the OPC UA stack.
/// Existing in-memory bookkeeping is preserved to avoid double-counting normal in-process transfers.
/// </summary>
/// <param name="fullTagReferences">The Galaxy tag references represented by the transferred monitored items.</param>
internal void RestoreTransferredSubscriptions(IEnumerable<string> fullTagReferences)
{
var transferredCounts = fullTagReferences
.GroupBy(tagRef => tagRef, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var tagsToSubscribe = new List<string>();
foreach (var kvp in transferredCounts)
{
lock (Lock)
{
if (_subscriptionRefCounts.ContainsKey(kvp.Key))
continue;
_subscriptionRefCounts[kvp.Key] = kvp.Value;
tagsToSubscribe.Add(kvp.Key);
}
}
foreach (var tagRef in tagsToSubscribe)
_ = _mxAccessClient.SubscribeAsync(tagRef, (_, _) => { });
}
private void OnMxAccessDataChange(string address, Vtq vtq)
{
if (_dispatchDisposed)
return;
Interlocked.Increment(ref _totalMxChangeEvents);
_pendingDataChanges[address] = vtq;
try
{
_dataChangeSignal.Set();
}
catch (ObjectDisposedException)
{
// Shutdown may race with one final callback from the runtime.
}
}
#endregion
#region Data Change Dispatch
private void StartDispatchThread()
{
_dispatchRunning = true;
_dispatchThread = new Thread(DispatchLoop)
{
Name = "OpcUaDataChangeDispatch",
IsBackground = true
};
_dispatchThread.Start();
}
private void StopDispatchThread()
{
_dispatchRunning = false;
_dataChangeSignal.Set();
_dispatchThread?.Join(TimeSpan.FromSeconds(5));
}
private void DispatchLoop()
{
Log.Information("Data change dispatch thread started");
while (_dispatchRunning)
{
try
{
_dataChangeSignal.WaitOne(TimeSpan.FromMilliseconds(100));
if (!_dispatchRunning)
break;
var keys = _pendingDataChanges.Keys.ToList();
if (keys.Count == 0)
{
ReportDispatchMetricsIfDue();
continue;
}
// Prepare updates outside the Lock. Shared-state lookups stay inside the Lock.
var updates = new List<(string address, BaseDataVariableState variable, DataValue dataValue)>(keys.Count);
var pendingAlarmEvents = new List<(string address, AlarmInfo info, bool active, ushort? severity, string? message)>();
var pendingAckedEvents = new List<(AlarmInfo info, bool acked)>();
foreach (var address in keys)
{
if (!_pendingDataChanges.TryRemove(address, out var vtq))
continue;
AlarmInfo? alarmInfo = null;
AlarmInfo? ackedAlarmInfo = null;
bool newInAlarm = false;
bool newAcked = false;
lock (Lock)
{
if (_tagToVariableNode.TryGetValue(address, out var variable))
{
try
{
var dataValue = CreatePublishedDataValue(address, vtq);
updates.Add((address, variable, dataValue));
}
catch (Exception ex)
{
Log.Warning(ex, "Error preparing data change for {Address}", address);
}
}
if (_alarmInAlarmTags.TryGetValue(address, out alarmInfo))
{
newInAlarm = vtq.Value is true || vtq.Value is 1 || (vtq.Value is int intVal && intVal != 0);
if (newInAlarm == alarmInfo.LastInAlarm)
alarmInfo = null;
}
// Check for Acked transitions
if (_alarmAckedTags.TryGetValue(address, out ackedAlarmInfo))
{
newAcked = vtq.Value is true || vtq.Value is 1 || (vtq.Value is int ackedIntVal && ackedIntVal != 0);
pendingAckedEvents.Add((ackedAlarmInfo, newAcked));
ackedAlarmInfo = null; // handled
}
}
if (alarmInfo == null)
continue;
ushort? severity = null;
string? message = null;
if (newInAlarm)
{
try
{
var pVtq = _mxAccessClient.ReadAsync(alarmInfo.PriorityTagReference).GetAwaiter().GetResult();
if (pVtq.Value is int ip)
severity = (ushort)System.Math.Min(System.Math.Max(ip, 1), 1000);
else if (pVtq.Value is short sp)
severity = (ushort)System.Math.Min(System.Math.Max((int)sp, 1), 1000);
}
catch
{
// Keep the previously cached severity when refresh reads fail.
}
try
{
var dVtq = _mxAccessClient.ReadAsync(alarmInfo.DescAttrNameTagReference).GetAwaiter().GetResult();
if (dVtq.Value is string desc && !string.IsNullOrEmpty(desc))
message = desc;
}
catch
{
// Keep the previously cached message when refresh reads fail.
}
}
pendingAlarmEvents.Add((address, alarmInfo, newInAlarm, severity, message));
}
// Apply under Lock so ClearChangeMasks propagates to monitored items.
if (updates.Count > 0 || pendingAlarmEvents.Count > 0 || pendingAckedEvents.Count > 0)
{
lock (Lock)
{
foreach (var (address, variable, dataValue) in updates)
{
if (!_tagToVariableNode.TryGetValue(address, out var currentVariable) || !ReferenceEquals(currentVariable, variable))
continue;
variable.Value = dataValue.Value;
variable.StatusCode = dataValue.StatusCode;
variable.Timestamp = dataValue.SourceTimestamp;
variable.ClearChangeMasks(SystemContext, false);
}
foreach (var (address, info, active, severity, message) in pendingAlarmEvents)
{
if (!_alarmInAlarmTags.TryGetValue(address, out var currentInfo) || !ReferenceEquals(currentInfo, info))
continue;
if (currentInfo.LastInAlarm == active)
continue;
currentInfo.LastInAlarm = active;
if (severity.HasValue)
currentInfo.CachedSeverity = severity.Value;
if (!string.IsNullOrEmpty(message))
currentInfo.CachedMessage = message!;
try
{
ReportAlarmEvent(currentInfo, active);
}
catch (Exception ex)
{
Log.Warning(ex, "Error reporting alarm event for {Source}", currentInfo.SourceName);
}
}
// Apply Acked state changes
foreach (var (info, acked) in pendingAckedEvents)
{
var condition = info.ConditionNode;
if (condition == null) continue;
try
{
condition.SetAcknowledgedState(SystemContext, acked);
condition.Retain.Value = (condition.ActiveState?.Id?.Value == true) || !acked;
if (_tagToVariableNode.TryGetValue(info.SourceTagReference, out var src) && src.Parent != null)
src.Parent.ReportEvent(SystemContext, condition);
Server.ReportEvent(SystemContext, condition);
Log.Information("Alarm {AckState}: {Source}",
acked ? "ACKNOWLEDGED" : "UNACKNOWLEDGED", info.SourceName);
}
catch (Exception ex)
{
Log.Warning(ex, "Error updating acked state for {Source}", info.SourceName);
}
}
}
}
Interlocked.Add(ref _totalDispatchBatchSize, updates.Count);
Interlocked.Increment(ref _dispatchCycleCount);
ReportDispatchMetricsIfDue();
}
catch (Exception ex)
{
Log.Error(ex, "Unhandled error in data change dispatch loop");
}
}
Log.Information("Data change dispatch thread stopped");
}
private void ReportDispatchMetricsIfDue()
{
var now = DateTime.UtcNow;
var elapsed = (now - _lastMetricsReportTime).TotalSeconds;
if (elapsed < 60) return;
var totalEvents = Interlocked.Read(ref _totalMxChangeEvents);
var lastReported = Interlocked.Read(ref _lastReportedMxChangeEvents);
var eventsPerSecond = (totalEvents - lastReported) / elapsed;
Interlocked.Exchange(ref _lastReportedMxChangeEvents, totalEvents);
var batchSize = Interlocked.Read(ref _totalDispatchBatchSize);
var cycles = Interlocked.Read(ref _dispatchCycleCount);
var avgQueueSize = cycles > 0 ? (double)batchSize / cycles : 0;
// Reset rolling counters
Interlocked.Exchange(ref _totalDispatchBatchSize, 0);
Interlocked.Exchange(ref _dispatchCycleCount, 0);
_lastMetricsReportTime = now;
_lastEventsPerSecond = eventsPerSecond;
_lastAvgBatchSize = avgQueueSize;
Log.Information(
"DataChange dispatch: EventsPerSec={EventsPerSec:F1}, AvgBatchSize={AvgBatchSize:F1}, PendingItems={Pending}, TotalEvents={Total}",
eventsPerSecond, avgQueueSize, _pendingDataChanges.Count, totalEvents);
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
{
_dispatchDisposed = true;
_mxAccessClient.OnTagValueChanged -= OnMxAccessDataChange;
StopDispatchThread();
_dataChangeSignal.Dispose();
}
base.Dispose(disposing);
}
#endregion
}
}