Wire Galaxy security_classification to OPC UA AccessLevel (ReadOnly for SecuredWrite/VerifiedWrite/ViewOnly). Use deployed package chain for attribute queries to exclude undeployed attributes. Group primitive attributes under their parent variable node (merged Variable+Object). Add is_historized and is_alarm detection via HistoryExtension/AlarmExtension primitives. Implement OPC UA HistoryRead backed by Wonderware Historian Runtime database. Implement AlarmConditionState nodes driven by InAlarm with condition refresh support. Add historyread and alarms CLI commands for testing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1276 lines
54 KiB
C#
1276 lines
54 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 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 readonly object _lock = new object();
|
|
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;
|
|
|
|
// 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
|
|
{
|
|
public int MxDataType { get; set; }
|
|
public bool IsArray { get; set; }
|
|
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 sealed class AlarmInfo
|
|
{
|
|
public string SourceTagReference { get; set; } = "";
|
|
public NodeId SourceNodeId { get; set; } = NodeId.Null;
|
|
public string SourceName { get; set; } = "";
|
|
public bool LastInAlarm { get; set; }
|
|
public AlarmConditionState? ConditionNode { get; set; }
|
|
public string PriorityTagReference { get; set; } = "";
|
|
public string DescAttrNameTagReference { get; set; } = "";
|
|
public ushort CachedSeverity { get; set; }
|
|
public string CachedMessage { 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>
|
|
public LmxNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
string namespaceUri,
|
|
IMxAccessClient mxAccessClient,
|
|
PerformanceMetrics metrics,
|
|
HistorianDataSource? historianDataSource = null)
|
|
: base(server, configuration, namespaceUri)
|
|
{
|
|
_namespaceUri = namespaceUri;
|
|
_mxAccessClient = mxAccessClient;
|
|
_metrics = metrics;
|
|
_historianDataSource = historianDataSource;
|
|
|
|
// 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();
|
|
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
|
|
var nodeMap = new Dictionary<int, NodeState>();
|
|
|
|
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
|
|
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.AttributeName;
|
|
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);
|
|
|
|
// 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('[', ']');
|
|
_alarmInAlarmTags[inAlarmTagRef] = new AlarmInfo
|
|
{
|
|
SourceTagReference = alarmAttr.FullTagReference,
|
|
SourceNodeId = sourceNodeId,
|
|
SourceName = alarmAttr.AttributeName,
|
|
ConditionNode = condition,
|
|
PriorityTagReference = baseTagRef + ".Priority",
|
|
DescAttrNameTagReference = baseTagRef + ".DescAttrName"
|
|
};
|
|
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
|
|
SubscribeAlarmTags();
|
|
|
|
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 };
|
|
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 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}";
|
|
|
|
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)
|
|
{
|
|
lock (Lock)
|
|
{
|
|
Log.Information("Rebuilding address space...");
|
|
var activeSubscriptions = new Dictionary<string, int>(_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);
|
|
}
|
|
}
|
|
|
|
// Unsubscribe auto-subscribed alarm tags
|
|
foreach (var kvp in _alarmInAlarmTags)
|
|
{
|
|
foreach (var tag in new[] { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference })
|
|
{
|
|
if (!string.IsNullOrEmpty(tag))
|
|
{
|
|
try { _mxAccessClient.UnsubscribeAsync(tag).GetAwaiter().GetResult(); }
|
|
catch { /* ignore */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
_tagMetadata.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");
|
|
}
|
|
}
|
|
|
|
/// <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
|
|
};
|
|
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;
|
|
|
|
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
|
|
|
|
/// <summary>
|
|
/// Reports all active retained alarm conditions during a condition refresh.
|
|
/// </summary>
|
|
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)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
|
|
{
|
|
_subscriptionRefCounts[fullTagReference] = count + 1;
|
|
}
|
|
else
|
|
{
|
|
_subscriptionRefCounts[fullTagReference] = 1;
|
|
_ = _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)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
|
|
{
|
|
if (count <= 1)
|
|
{
|
|
_subscriptionRefCounts.Remove(fullTagReference);
|
|
_ = _mxAccessClient.UnsubscribeAsync(fullTagReference);
|
|
}
|
|
else
|
|
{
|
|
_subscriptionRefCounts[fullTagReference] = count - 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
|
|
foreach (var kvp in transferredCounts)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_subscriptionRefCounts.ContainsKey(kvp.Key))
|
|
continue;
|
|
|
|
_subscriptionRefCounts[kvp.Key] = kvp.Value;
|
|
}
|
|
|
|
_ = _mxAccessClient.SubscribeAsync(kvp.Key, (_, _) => { });
|
|
}
|
|
}
|
|
|
|
private void OnMxAccessDataChange(string address, Vtq vtq)
|
|
{
|
|
Interlocked.Increment(ref _totalMxChangeEvents);
|
|
_pendingDataChanges[address] = vtq;
|
|
_dataChangeSignal.Set();
|
|
}
|
|
|
|
#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)
|
|
{
|
|
_dataChangeSignal.WaitOne(TimeSpan.FromMilliseconds(100));
|
|
|
|
if (!_dispatchRunning) break;
|
|
|
|
var keys = _pendingDataChanges.Keys.ToList();
|
|
if (keys.Count == 0)
|
|
{
|
|
ReportDispatchMetricsIfDue();
|
|
continue;
|
|
}
|
|
|
|
// Prepare updates outside the Lock — no IO, just value conversion
|
|
var updates = new List<(BaseDataVariableState variable, DataValue dataValue)>(keys.Count);
|
|
var pendingAlarmEvents = new List<(AlarmInfo info, bool active)>();
|
|
|
|
foreach (var address in keys)
|
|
{
|
|
if (_pendingDataChanges.TryRemove(address, out var vtq))
|
|
{
|
|
if (_tagToVariableNode.TryGetValue(address, out var variable))
|
|
{
|
|
try
|
|
{
|
|
var dataValue = CreatePublishedDataValue(address, vtq);
|
|
updates.Add((variable, dataValue));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "Error preparing data change for {Address}", address);
|
|
}
|
|
}
|
|
|
|
// Check for alarm InAlarm transitions
|
|
if (_alarmInAlarmTags.TryGetValue(address, out var alarmInfo))
|
|
{
|
|
var newInAlarm = vtq.Value is true || vtq.Value is 1 || (vtq.Value is int intVal && intVal != 0);
|
|
if (newInAlarm != alarmInfo.LastInAlarm)
|
|
{
|
|
alarmInfo.LastInAlarm = newInAlarm;
|
|
|
|
// Read Priority and DescAttrName via MXAccess (outside Lock, safe here)
|
|
if (newInAlarm)
|
|
{
|
|
try
|
|
{
|
|
var pVtq = _mxAccessClient.ReadAsync(alarmInfo.PriorityTagReference).GetAwaiter().GetResult();
|
|
if (pVtq.Value is int ip) alarmInfo.CachedSeverity = (ushort)System.Math.Min(System.Math.Max(ip, 1), 1000);
|
|
else if (pVtq.Value is short sp) alarmInfo.CachedSeverity = (ushort)System.Math.Min(System.Math.Max((int)sp, 1), 1000);
|
|
}
|
|
catch { /* keep previous */ }
|
|
|
|
try
|
|
{
|
|
var dVtq = _mxAccessClient.ReadAsync(alarmInfo.DescAttrNameTagReference).GetAwaiter().GetResult();
|
|
if (dVtq.Value is string desc && !string.IsNullOrEmpty(desc))
|
|
alarmInfo.CachedMessage = desc;
|
|
}
|
|
catch { /* keep previous */ }
|
|
}
|
|
|
|
pendingAlarmEvents.Add((alarmInfo, newInAlarm));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply under Lock so ClearChangeMasks propagates to monitored items
|
|
if (updates.Count > 0 || pendingAlarmEvents.Count > 0)
|
|
{
|
|
lock (Lock)
|
|
{
|
|
foreach (var (variable, dataValue) in updates)
|
|
{
|
|
variable.Value = dataValue.Value;
|
|
variable.StatusCode = dataValue.StatusCode;
|
|
variable.Timestamp = dataValue.SourceTimestamp;
|
|
variable.ClearChangeMasks(SystemContext, false);
|
|
}
|
|
|
|
// Report alarm events
|
|
foreach (var (info, active) in pendingAlarmEvents)
|
|
{
|
|
try
|
|
{
|
|
ReportAlarmEvent(info, active);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "Error reporting alarm event for {Source}", info.SourceName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Interlocked.Add(ref _totalDispatchBatchSize, updates.Count);
|
|
Interlocked.Increment(ref _dispatchCycleCount);
|
|
ReportDispatchMetricsIfDue();
|
|
}
|
|
|
|
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)
|
|
{
|
|
StopDispatchThread();
|
|
_dataChangeSignal.Dispose();
|
|
}
|
|
|
|
base.Dispose(disposing);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|