diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index d75c853..253e325 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -8,8 +8,6 @@
-
-
@@ -24,9 +22,6 @@
-
-
-
diff --git a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/AvevaHistorianPluginEntry.cs b/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/AvevaHistorianPluginEntry.cs
deleted file mode 100644
index 99321be..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/AvevaHistorianPluginEntry.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using ZB.MOM.WW.OtOpcUa.Host.Configuration;
-using ZB.MOM.WW.OtOpcUa.Host.Historian;
-
-namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
-{
- ///
- /// Reflection entry point invoked by HistorianPluginLoader in the Host. Kept
- /// deliberately simple so the plugin contract is a single static factory method.
- ///
- public static class AvevaHistorianPluginEntry
- {
- public static IHistorianDataSource Create(HistorianConfiguration config)
- => new HistorianDataSource(config);
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianClusterEndpointPicker.cs b/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianClusterEndpointPicker.cs
deleted file mode 100644
index 8357ee0..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianClusterEndpointPicker.cs
+++ /dev/null
@@ -1,181 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using ZB.MOM.WW.OtOpcUa.Host.Configuration;
-using ZB.MOM.WW.OtOpcUa.Host.Historian;
-
-namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
-{
- ///
- /// Thread-safe, pure-logic endpoint picker for the Wonderware Historian cluster. Tracks which
- /// configured nodes are healthy, places failed nodes in a time-bounded cooldown, and hands
- /// out an ordered list of eligible candidates for the data source to try in sequence.
- ///
- ///
- /// Design notes:
- ///
- /// - No SDK dependency — fully unit-testable with an injected clock.
- /// - Per-node state is guarded by a single lock; operations are microsecond-scale
- /// so contention is a non-issue.
- /// - Cooldown is purely passive: a node re-enters the healthy pool the next time
- /// it is queried after its cooldown window elapses. There is no background probe.
- /// - Nodes are returned in configuration order so operators can express a
- /// preference (primary first, fallback second).
- /// - When is empty, the picker is
- /// initialized with a single entry from
- /// so legacy deployments continue to work unchanged.
- ///
- ///
- internal sealed class HistorianClusterEndpointPicker
- {
- private readonly Func _clock;
- private readonly TimeSpan _cooldown;
- private readonly object _lock = new object();
- private readonly List _nodes;
-
- public HistorianClusterEndpointPicker(HistorianConfiguration config)
- : this(config, () => DateTime.UtcNow) { }
-
- internal HistorianClusterEndpointPicker(HistorianConfiguration config, Func clock)
- {
- _clock = clock ?? throw new ArgumentNullException(nameof(clock));
- _cooldown = TimeSpan.FromSeconds(Math.Max(0, config.FailureCooldownSeconds));
-
- var names = (config.ServerNames != null && config.ServerNames.Count > 0)
- ? config.ServerNames
- : new List { config.ServerName };
-
- _nodes = names
- .Where(n => !string.IsNullOrWhiteSpace(n))
- .Select(n => n.Trim())
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .Select(n => new NodeEntry { Name = n })
- .ToList();
- }
-
- ///
- /// Gets the total number of configured cluster nodes. Stable — nodes are never added
- /// or removed after construction.
- ///
- public int NodeCount
- {
- get
- {
- lock (_lock)
- return _nodes.Count;
- }
- }
-
- ///
- /// Returns an ordered snapshot of nodes currently eligible for a connection attempt,
- /// with any node whose cooldown has elapsed automatically restored to the pool.
- /// An empty list means all nodes are in active cooldown.
- ///
- public IReadOnlyList GetHealthyNodes()
- {
- lock (_lock)
- {
- var now = _clock();
- return _nodes
- .Where(n => IsHealthyAt(n, now))
- .Select(n => n.Name)
- .ToList();
- }
- }
-
- ///
- /// Gets the count of nodes currently eligible for a connection attempt (i.e., not in cooldown).
- ///
- public int HealthyNodeCount
- {
- get
- {
- lock (_lock)
- {
- var now = _clock();
- return _nodes.Count(n => IsHealthyAt(n, now));
- }
- }
- }
-
- ///
- /// Places into cooldown starting at the current clock time.
- /// Increments the node's failure counter and stores the latest error message for
- /// surfacing on the dashboard. Unknown node names are ignored.
- ///
- public void MarkFailed(string node, string? error)
- {
- lock (_lock)
- {
- var entry = FindEntry(node);
- if (entry == null)
- return;
-
- var now = _clock();
- entry.FailureCount++;
- entry.LastError = error;
- entry.LastFailureTime = now;
- entry.CooldownUntil = _cooldown.TotalMilliseconds > 0 ? now + _cooldown : (DateTime?)null;
- }
- }
-
- ///
- /// Marks as healthy immediately — clears any active cooldown but
- /// leaves the cumulative failure counter intact for operator diagnostics. Unknown node
- /// names are ignored.
- ///
- public void MarkHealthy(string node)
- {
- lock (_lock)
- {
- var entry = FindEntry(node);
- if (entry == null)
- return;
- entry.CooldownUntil = null;
- }
- }
-
- ///
- /// Captures the current per-node state for the health dashboard. Freshly computed from
- /// so recently-expired cooldowns are reported as healthy.
- ///
- public List SnapshotNodeStates()
- {
- lock (_lock)
- {
- var now = _clock();
- return _nodes.Select(n => new HistorianClusterNodeState
- {
- Name = n.Name,
- IsHealthy = IsHealthyAt(n, now),
- CooldownUntil = IsHealthyAt(n, now) ? null : n.CooldownUntil,
- FailureCount = n.FailureCount,
- LastError = n.LastError,
- LastFailureTime = n.LastFailureTime
- }).ToList();
- }
- }
-
- private static bool IsHealthyAt(NodeEntry entry, DateTime now)
- {
- return entry.CooldownUntil == null || entry.CooldownUntil <= now;
- }
-
- private NodeEntry? FindEntry(string node)
- {
- for (var i = 0; i < _nodes.Count; i++)
- if (string.Equals(_nodes[i].Name, node, StringComparison.OrdinalIgnoreCase))
- return _nodes[i];
- return null;
- }
-
- private sealed class NodeEntry
- {
- public string Name { get; set; } = "";
- public DateTime? CooldownUntil { get; set; }
- public int FailureCount { get; set; }
- public string? LastError { get; set; }
- public DateTime? LastFailureTime { get; set; }
- }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianDataSource.cs b/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianDataSource.cs
deleted file mode 100644
index 489005c..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianDataSource.cs
+++ /dev/null
@@ -1,704 +0,0 @@
-using System;
-using System.Collections.Generic;
-using StringCollection = System.Collections.Specialized.StringCollection;
-using System.Threading;
-using System.Threading.Tasks;
-using ArchestrA;
-using Opc.Ua;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Configuration;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-using ZB.MOM.WW.OtOpcUa.Host.Historian;
-
-namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
-{
- ///
- /// Reads historical data from the Wonderware Historian via the aahClientManaged SDK.
- ///
- public sealed class HistorianDataSource : IHistorianDataSource
- {
- private static readonly ILogger Log = Serilog.Log.ForContext();
-
- private readonly HistorianConfiguration _config;
- private readonly object _connectionLock = new object();
- private readonly object _eventConnectionLock = new object();
- private readonly IHistorianConnectionFactory _factory;
- private HistorianAccess? _connection;
- private HistorianAccess? _eventConnection;
- private bool _disposed;
-
- // Runtime query health state. Guarded by _healthLock — updated on every read
- // method exit (success or failure) so the dashboard can distinguish "plugin
- // loaded but never queried" from "plugin loaded and queries are failing".
- private readonly object _healthLock = new object();
- private long _totalSuccesses;
- private long _totalFailures;
- private int _consecutiveFailures;
- private DateTime? _lastSuccessTime;
- private DateTime? _lastFailureTime;
- private string? _lastError;
- private string? _activeProcessNode;
- private string? _activeEventNode;
-
- // Cluster endpoint picker — shared across process + event paths so a node that
- // fails on one silo is skipped on the other. Initialized from config at construction.
- private readonly HistorianClusterEndpointPicker _picker;
-
- ///
- /// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian SDK queries.
- ///
- /// The Historian SDK connection settings used for runtime history lookups.
- public HistorianDataSource(HistorianConfiguration config)
- : this(config, new SdkHistorianConnectionFactory(), null) { }
-
- ///
- /// Initializes a Historian reader with a custom connection factory for testing. When
- /// is a new picker is built from
- /// , preserving backward compatibility with existing tests.
- ///
- internal HistorianDataSource(
- HistorianConfiguration config,
- IHistorianConnectionFactory factory,
- HistorianClusterEndpointPicker? picker = null)
- {
- _config = config;
- _factory = factory;
- _picker = picker ?? new HistorianClusterEndpointPicker(config);
- }
-
- ///
- /// Iterates the picker's healthy node list, cloning the configuration per attempt and
- /// handing it to the factory. Marks each tried node as healthy on success or failed on
- /// exception. Returns the winning connection + node name; throws when no nodes succeed.
- ///
- private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode(HistorianConnectionType type)
- {
- var candidates = _picker.GetHealthyNodes();
- if (candidates.Count == 0)
- {
- var total = _picker.NodeCount;
- throw new InvalidOperationException(
- total == 0
- ? "No historian nodes configured"
- : $"All {total} historian nodes are in cooldown — no healthy endpoints to connect to");
- }
-
- Exception? lastException = null;
- foreach (var node in candidates)
- {
- var attemptConfig = CloneConfigWithServerName(node);
- try
- {
- var conn = _factory.CreateAndConnect(attemptConfig, type);
- _picker.MarkHealthy(node);
- return (conn, node);
- }
- catch (Exception ex)
- {
- _picker.MarkFailed(node, ex.Message);
- lastException = ex;
- Log.Warning(ex,
- "Historian node {Node} failed during connect attempt; trying next candidate", node);
- }
- }
-
- var inner = lastException?.Message ?? "(no detail)";
- throw new InvalidOperationException(
- $"All {candidates.Count} healthy historian candidate(s) failed during connect: {inner}",
- lastException);
- }
-
- private HistorianConfiguration CloneConfigWithServerName(string serverName)
- {
- return new HistorianConfiguration
- {
- Enabled = _config.Enabled,
- ServerName = serverName,
- ServerNames = _config.ServerNames,
- FailureCooldownSeconds = _config.FailureCooldownSeconds,
- IntegratedSecurity = _config.IntegratedSecurity,
- UserName = _config.UserName,
- Password = _config.Password,
- Port = _config.Port,
- CommandTimeoutSeconds = _config.CommandTimeoutSeconds,
- MaxValuesPerRead = _config.MaxValuesPerRead
- };
- }
-
- ///
- public HistorianHealthSnapshot GetHealthSnapshot()
- {
- var nodeStates = _picker.SnapshotNodeStates();
- var healthyCount = 0;
- foreach (var n in nodeStates)
- if (n.IsHealthy)
- healthyCount++;
-
- lock (_healthLock)
- {
- return new HistorianHealthSnapshot
- {
- TotalQueries = _totalSuccesses + _totalFailures,
- TotalSuccesses = _totalSuccesses,
- TotalFailures = _totalFailures,
- ConsecutiveFailures = _consecutiveFailures,
- LastSuccessTime = _lastSuccessTime,
- LastFailureTime = _lastFailureTime,
- LastError = _lastError,
- ProcessConnectionOpen = Volatile.Read(ref _connection) != null,
- EventConnectionOpen = Volatile.Read(ref _eventConnection) != null,
- ActiveProcessNode = _activeProcessNode,
- ActiveEventNode = _activeEventNode,
- NodeCount = nodeStates.Count,
- HealthyNodeCount = healthyCount,
- Nodes = nodeStates
- };
- }
- }
-
- private void RecordSuccess()
- {
- lock (_healthLock)
- {
- _totalSuccesses++;
- _lastSuccessTime = DateTime.UtcNow;
- _consecutiveFailures = 0;
- _lastError = null;
- }
- }
-
- private void RecordFailure(string error)
- {
- lock (_healthLock)
- {
- _totalFailures++;
- _lastFailureTime = DateTime.UtcNow;
- _consecutiveFailures++;
- _lastError = error;
- }
- }
-
- private void EnsureConnected()
- {
- if (_disposed)
- throw new ObjectDisposedException(nameof(HistorianDataSource));
-
- // Fast path: already connected (no lock needed)
- if (Volatile.Read(ref _connection) != null)
- return;
-
- // Create and wait for connection outside the lock so concurrent history
- // requests are not serialized behind a slow Historian handshake. The cluster
- // picker iterates configured nodes and returns the first that successfully connects.
- var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Process);
-
- lock (_connectionLock)
- {
- if (_disposed)
- {
- conn.CloseConnection(out _);
- conn.Dispose();
- throw new ObjectDisposedException(nameof(HistorianDataSource));
- }
-
- if (_connection != null)
- {
- // Another thread connected while we were waiting
- conn.CloseConnection(out _);
- conn.Dispose();
- return;
- }
-
- _connection = conn;
- lock (_healthLock)
- _activeProcessNode = winningNode;
- Log.Information("Historian SDK connection opened to {Server}:{Port}", winningNode, _config.Port);
- }
- }
-
- private void HandleConnectionError(Exception? ex = null)
- {
- lock (_connectionLock)
- {
- if (_connection == null)
- return;
-
- try
- {
- _connection.CloseConnection(out _);
- _connection.Dispose();
- }
- catch (Exception disposeEx)
- {
- Log.Debug(disposeEx, "Error disposing Historian SDK connection during error recovery");
- }
-
- _connection = null;
- string? failedNode;
- lock (_healthLock)
- {
- failedNode = _activeProcessNode;
- _activeProcessNode = null;
- }
-
- if (failedNode != null)
- _picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
- Log.Warning(ex, "Historian SDK connection reset (node={Node}) — will reconnect on next request",
- failedNode ?? "(unknown)");
- }
- }
-
- private void EnsureEventConnected()
- {
- if (_disposed)
- throw new ObjectDisposedException(nameof(HistorianDataSource));
-
- if (Volatile.Read(ref _eventConnection) != null)
- return;
-
- var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Event);
-
- lock (_eventConnectionLock)
- {
- if (_disposed)
- {
- conn.CloseConnection(out _);
- conn.Dispose();
- throw new ObjectDisposedException(nameof(HistorianDataSource));
- }
-
- if (_eventConnection != null)
- {
- conn.CloseConnection(out _);
- conn.Dispose();
- return;
- }
-
- _eventConnection = conn;
- lock (_healthLock)
- _activeEventNode = winningNode;
- Log.Information("Historian SDK event connection opened to {Server}:{Port}",
- winningNode, _config.Port);
- }
- }
-
- private void HandleEventConnectionError(Exception? ex = null)
- {
- lock (_eventConnectionLock)
- {
- if (_eventConnection == null)
- return;
-
- try
- {
- _eventConnection.CloseConnection(out _);
- _eventConnection.Dispose();
- }
- catch (Exception disposeEx)
- {
- Log.Debug(disposeEx, "Error disposing Historian SDK event connection during error recovery");
- }
-
- _eventConnection = null;
- string? failedNode;
- lock (_healthLock)
- {
- failedNode = _activeEventNode;
- _activeEventNode = null;
- }
-
- if (failedNode != null)
- _picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
- Log.Warning(ex, "Historian SDK event connection reset (node={Node}) — will reconnect on next request",
- failedNode ?? "(unknown)");
- }
- }
-
-
- ///
- public Task> ReadRawAsync(
- string tagName, DateTime startTime, DateTime endTime, int maxValues,
- CancellationToken ct = default)
- {
- var results = new List();
-
- try
- {
- EnsureConnected();
-
- using var query = _connection!.CreateHistoryQuery();
- var args = new HistoryQueryArgs
- {
- TagNames = new StringCollection { tagName },
- StartDateTime = startTime,
- EndDateTime = endTime,
- RetrievalMode = HistorianRetrievalMode.Full
- };
-
- if (maxValues > 0)
- args.BatchSize = (uint)maxValues;
- else if (_config.MaxValuesPerRead > 0)
- args.BatchSize = (uint)_config.MaxValuesPerRead;
-
- if (!query.StartQuery(args, out var error))
- {
- Log.Warning("Historian SDK raw query start failed for {Tag}: {Error}", tagName, error.ErrorCode);
- RecordFailure($"raw StartQuery: {error.ErrorCode}");
- HandleConnectionError();
- return Task.FromResult(results);
- }
-
- var count = 0;
- var limit = maxValues > 0 ? maxValues : _config.MaxValuesPerRead;
-
- while (query.MoveNext(out error))
- {
- ct.ThrowIfCancellationRequested();
-
- var result = query.QueryResult;
- var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
-
- object? value;
- if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0)
- value = result.StringValue;
- else
- value = result.Value;
-
- var quality = (byte)(result.OpcQuality & 0xFF);
-
- results.Add(new DataValue
- {
- Value = new Variant(value),
- SourceTimestamp = timestamp,
- ServerTimestamp = timestamp,
- StatusCode = QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality))
- });
-
- count++;
- if (limit > 0 && count >= limit)
- break;
- }
-
- query.EndQuery(out _);
- RecordSuccess();
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (ObjectDisposedException)
- {
- throw;
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "HistoryRead raw failed for {Tag}", tagName);
- RecordFailure($"raw: {ex.Message}");
- HandleConnectionError(ex);
- }
-
- Log.Debug("HistoryRead raw: {Tag} returned {Count} values ({Start} to {End})",
- tagName, results.Count, startTime, endTime);
-
- return Task.FromResult(results);
- }
-
- ///
- public Task> ReadAggregateAsync(
- string tagName, DateTime startTime, DateTime endTime,
- double intervalMs, string aggregateColumn,
- CancellationToken ct = default)
- {
- var results = new List();
-
- try
- {
- EnsureConnected();
-
- using var query = _connection!.CreateAnalogSummaryQuery();
- var args = new AnalogSummaryQueryArgs
- {
- TagNames = new StringCollection { tagName },
- StartDateTime = startTime,
- EndDateTime = endTime,
- Resolution = (ulong)intervalMs
- };
-
- if (!query.StartQuery(args, out var error))
- {
- Log.Warning("Historian SDK aggregate query start failed for {Tag}: {Error}", tagName,
- error.ErrorCode);
- RecordFailure($"aggregate StartQuery: {error.ErrorCode}");
- HandleConnectionError();
- return Task.FromResult(results);
- }
-
- while (query.MoveNext(out error))
- {
- ct.ThrowIfCancellationRequested();
-
- var result = query.QueryResult;
- var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
- var value = ExtractAggregateValue(result, aggregateColumn);
-
- results.Add(new DataValue
- {
- Value = new Variant(value),
- SourceTimestamp = timestamp,
- ServerTimestamp = timestamp,
- StatusCode = value != null ? StatusCodes.Good : StatusCodes.BadNoData
- });
- }
-
- query.EndQuery(out _);
- RecordSuccess();
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (ObjectDisposedException)
- {
- throw;
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "HistoryRead aggregate failed for {Tag}", tagName);
- RecordFailure($"aggregate: {ex.Message}");
- HandleConnectionError(ex);
- }
-
- Log.Debug("HistoryRead aggregate ({Aggregate}): {Tag} returned {Count} values",
- aggregateColumn, tagName, results.Count);
-
- return Task.FromResult(results);
- }
-
- ///
- public Task> ReadAtTimeAsync(
- string tagName, DateTime[] timestamps,
- CancellationToken ct = default)
- {
- var results = new List();
-
- if (timestamps == null || timestamps.Length == 0)
- return Task.FromResult(results);
-
- try
- {
- EnsureConnected();
-
- foreach (var timestamp in timestamps)
- {
- ct.ThrowIfCancellationRequested();
-
- using var query = _connection!.CreateHistoryQuery();
- var args = new HistoryQueryArgs
- {
- TagNames = new StringCollection { tagName },
- StartDateTime = timestamp,
- EndDateTime = timestamp,
- RetrievalMode = HistorianRetrievalMode.Interpolated,
- BatchSize = 1
- };
-
- if (!query.StartQuery(args, out var error))
- {
- results.Add(new DataValue
- {
- Value = Variant.Null,
- SourceTimestamp = timestamp,
- ServerTimestamp = timestamp,
- StatusCode = StatusCodes.BadNoData
- });
- continue;
- }
-
- if (query.MoveNext(out error))
- {
- var result = query.QueryResult;
- object? value;
- if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0)
- value = result.StringValue;
- else
- value = result.Value;
-
- var quality = (byte)(result.OpcQuality & 0xFF);
- results.Add(new DataValue
- {
- Value = new Variant(value),
- SourceTimestamp = timestamp,
- ServerTimestamp = timestamp,
- StatusCode = QualityMapper.MapToOpcUaStatusCode(
- QualityMapper.MapFromMxAccessQuality(quality))
- });
- }
- else
- {
- results.Add(new DataValue
- {
- Value = Variant.Null,
- SourceTimestamp = timestamp,
- ServerTimestamp = timestamp,
- StatusCode = StatusCodes.BadNoData
- });
- }
-
- query.EndQuery(out _);
- }
- RecordSuccess();
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (ObjectDisposedException)
- {
- throw;
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "HistoryRead at-time failed for {Tag}", tagName);
- RecordFailure($"at-time: {ex.Message}");
- HandleConnectionError(ex);
- }
-
- Log.Debug("HistoryRead at-time: {Tag} returned {Count} values for {Timestamps} timestamps",
- tagName, results.Count, timestamps.Length);
-
- return Task.FromResult(results);
- }
-
- ///
- public Task> ReadEventsAsync(
- string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
- CancellationToken ct = default)
- {
- var results = new List();
-
- try
- {
- EnsureEventConnected();
-
- using var query = _eventConnection!.CreateEventQuery();
- var args = new EventQueryArgs
- {
- StartDateTime = startTime,
- EndDateTime = endTime,
- EventCount = maxEvents > 0 ? (uint)maxEvents : (uint)_config.MaxValuesPerRead,
- QueryType = HistorianEventQueryType.Events,
- EventOrder = HistorianEventOrder.Ascending
- };
-
- if (!string.IsNullOrEmpty(sourceName))
- {
- query.AddEventFilter("Source", HistorianComparisionType.Equal, sourceName, out _);
- }
-
- if (!query.StartQuery(args, out var error))
- {
- Log.Warning("Historian SDK event query start failed: {Error}", error.ErrorCode);
- RecordFailure($"events StartQuery: {error.ErrorCode}");
- HandleEventConnectionError();
- return Task.FromResult(results);
- }
-
- var count = 0;
- while (query.MoveNext(out error))
- {
- ct.ThrowIfCancellationRequested();
- results.Add(ToDto(query.QueryResult));
- count++;
- if (maxEvents > 0 && count >= maxEvents)
- break;
- }
-
- query.EndQuery(out _);
- RecordSuccess();
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (ObjectDisposedException)
- {
- throw;
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "HistoryRead events failed for source {Source}", sourceName ?? "(all)");
- RecordFailure($"events: {ex.Message}");
- HandleEventConnectionError(ex);
- }
-
- Log.Debug("HistoryRead events: source={Source} returned {Count} events ({Start} to {End})",
- sourceName ?? "(all)", results.Count, startTime, endTime);
-
- return Task.FromResult(results);
- }
-
- private static HistorianEventDto ToDto(HistorianEvent evt)
- {
- return new HistorianEventDto
- {
- Id = evt.Id,
- Source = evt.Source,
- EventTime = evt.EventTime,
- ReceivedTime = evt.ReceivedTime,
- DisplayText = evt.DisplayText,
- Severity = (ushort)evt.Severity
- };
- }
-
- ///
- /// Extracts the requested aggregate value from an by column name.
- ///
- internal static double? ExtractAggregateValue(AnalogSummaryQueryResult result, string column)
- {
- switch (column)
- {
- case "Average": return result.Average;
- case "Minimum": return result.Minimum;
- case "Maximum": return result.Maximum;
- case "ValueCount": return result.ValueCount;
- case "First": return result.First;
- case "Last": return result.Last;
- case "StdDev": return result.StdDev;
- default: return null;
- }
- }
-
- ///
- /// Closes the Historian SDK connection and releases resources.
- ///
- public void Dispose()
- {
- if (_disposed)
- return;
- _disposed = true;
-
- try
- {
- _connection?.CloseConnection(out _);
- _connection?.Dispose();
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Error closing Historian SDK connection");
- }
-
- try
- {
- _eventConnection?.CloseConnection(out _);
- _eventConnection?.Dispose();
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Error closing Historian SDK event connection");
- }
-
- _connection = null;
- _eventConnection = null;
- }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/IHistorianConnectionFactory.cs b/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/IHistorianConnectionFactory.cs
deleted file mode 100644
index 3254bfb..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/IHistorianConnectionFactory.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-using System;
-using System.Threading;
-using ArchestrA;
-using ZB.MOM.WW.OtOpcUa.Host.Configuration;
-
-namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
-{
- ///
- /// Creates and opens Historian SDK connections. Extracted so tests can inject
- /// fakes that control connection success, failure, and timeout behavior.
- ///
- internal interface IHistorianConnectionFactory
- {
- ///
- /// Creates a new Historian SDK connection, opens it, and waits until it is ready.
- /// Throws on connection failure or timeout.
- ///
- HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type);
- }
-
- ///
- /// Production implementation that creates real Historian SDK connections.
- ///
- internal sealed class SdkHistorianConnectionFactory : IHistorianConnectionFactory
- {
- public HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type)
- {
- var conn = new HistorianAccess();
-
- var args = new HistorianConnectionArgs
- {
- ServerName = config.ServerName,
- TcpPort = (ushort)config.Port,
- IntegratedSecurity = config.IntegratedSecurity,
- UseArchestrAUser = config.IntegratedSecurity,
- ConnectionType = type,
- ReadOnly = true,
- PacketTimeout = (uint)(config.CommandTimeoutSeconds * 1000)
- };
-
- if (!config.IntegratedSecurity)
- {
- args.UserName = config.UserName ?? string.Empty;
- args.Password = config.Password ?? string.Empty;
- }
-
- if (!conn.OpenConnection(args, out var error))
- {
- conn.Dispose();
- throw new InvalidOperationException(
- $"Failed to open Historian SDK connection to {config.ServerName}:{config.Port}: {error.ErrorCode}");
- }
-
- // The SDK connects asynchronously — poll until the connection is ready
- var timeoutMs = config.CommandTimeoutSeconds * 1000;
- var elapsed = 0;
- while (elapsed < timeoutMs)
- {
- var status = new HistorianConnectionStatus();
- conn.GetConnectionStatus(ref status);
-
- if (status.ConnectedToServer)
- return conn;
-
- if (status.ErrorOccurred)
- {
- conn.Dispose();
- throw new InvalidOperationException(
- $"Historian SDK connection failed: {status.Error}");
- }
-
- Thread.Sleep(250);
- elapsed += 250;
- }
-
- conn.Dispose();
- throw new TimeoutException(
- $"Historian SDK connection to {config.ServerName}:{config.Port} timed out after {config.CommandTimeoutSeconds}s");
- }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj b/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj
deleted file mode 100644
index 4c05a0b..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj
+++ /dev/null
@@ -1,93 +0,0 @@
-
-
-
- net48
- x86
- 9.0
- enable
- ZB.MOM.WW.OtOpcUa.Historian.Aveva
- ZB.MOM.WW.OtOpcUa.Historian.Aveva
-
- false
-
- $(MSBuildThisFileDirectory)..\ZB.MOM.WW.OtOpcUa.Host\bin\$(Configuration)\net48\Historian\
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- false
- true
-
-
-
-
-
-
- ..\..\lib\aahClientManaged.dll
- false
-
-
- ..\..\lib\aahClientCommon.dll
- false
-
-
-
-
-
-
- PreserveNewest
-
-
- PreserveNewest
-
-
- PreserveNewest
-
-
- PreserveNewest
-
-
- PreserveNewest
-
-
- PreserveNewest
-
-
-
-
-
- <_HistorianStageFiles Include="$(OutDir)aahClient.dll"/>
- <_HistorianStageFiles Include="$(OutDir)aahClientCommon.dll"/>
- <_HistorianStageFiles Include="$(OutDir)aahClientManaged.dll"/>
- <_HistorianStageFiles Include="$(OutDir)Historian.CBE.dll"/>
- <_HistorianStageFiles Include="$(OutDir)Historian.DPAPI.dll"/>
- <_HistorianStageFiles Include="$(OutDir)ArchestrA.CloudHistorian.Contract.dll"/>
- <_HistorianStageFiles Include="$(OutDir)$(AssemblyName).dll"/>
- <_HistorianStageFiles Include="$(OutDir)$(AssemblyName).pdb" Condition="Exists('$(OutDir)$(AssemblyName).pdb')"/>
-
-
-
-
-
-
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmFilterConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmFilterConfiguration.cs
deleted file mode 100644
index 37ac945..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmFilterConfiguration.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using System.Collections.Generic;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// Configures the template-based alarm object filter under OpcUa.AlarmFilter.
- ///
- ///
- /// Each entry in is a wildcard pattern matched against the template
- /// derivation chain of every Galaxy object. Supported wildcard: *. Matching is case-insensitive
- /// and the leading $ used by Galaxy template tag_names is normalized away, so operators can
- /// write TestMachine* instead of $TestMachine*. An entry may itself contain comma-separated
- /// patterns for convenience (e.g., "TestMachine*, Pump_*"). An empty list disables the filter,
- /// restoring current behavior: all alarm-bearing objects are monitored when
- /// is .
- ///
- public class AlarmFilterConfiguration
- {
- ///
- /// Gets or sets the wildcard patterns that select which Galaxy objects contribute alarm conditions.
- /// An object is included when any template in its derivation chain matches any pattern, and the
- /// inclusion propagates to all descendants in the containment hierarchy. Each object is evaluated
- /// once: overlapping matches never create duplicate alarm subscriptions.
- ///
- public List ObjectFilters { get; set; } = new();
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AppConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AppConfiguration.cs
deleted file mode 100644
index 0d6ca24..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AppConfiguration.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// Top-level configuration holder binding all sections from appsettings.json. (SVC-003)
- ///
- public class AppConfiguration
- {
- ///
- /// Gets or sets the OPC UA endpoint settings exposed to downstream clients that browse the LMX address space.
- ///
- public OpcUaConfiguration OpcUa { get; set; } = new();
-
- ///
- /// Gets or sets the MXAccess runtime connection settings used to read and write live Galaxy attributes.
- ///
- public MxAccessConfiguration MxAccess { get; set; } = new();
-
- ///
- /// Gets or sets the repository settings used to query Galaxy metadata for address-space construction.
- ///
- public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new();
-
- ///
- /// Gets or sets the embedded dashboard settings used to surface service health to operators.
- ///
- public DashboardConfiguration Dashboard { get; set; } = new();
-
- ///
- /// Gets or sets the Wonderware Historian connection settings used to serve OPC UA historical data.
- ///
- public HistorianConfiguration Historian { get; set; } = new();
-
- ///
- /// Gets or sets the authentication and role-based access control settings.
- ///
- public AuthenticationConfiguration Authentication { get; set; } = new();
-
- ///
- /// Gets or sets the transport security settings that control which OPC UA security profiles are exposed.
- ///
- public SecurityProfileConfiguration Security { get; set; } = new();
-
- ///
- /// Gets or sets the redundancy settings that control how this server participates in a redundant pair.
- ///
- public RedundancyConfiguration Redundancy { get; set; } = new();
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AuthenticationConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AuthenticationConfiguration.cs
deleted file mode 100644
index e7978ae..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AuthenticationConfiguration.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// Authentication and role-based access control settings for the OPC UA server.
- ///
- public class AuthenticationConfiguration
- {
- ///
- /// Gets or sets a value indicating whether anonymous OPC UA connections are accepted.
- ///
- public bool AllowAnonymous { get; set; } = true;
-
- ///
- /// Gets or sets a value indicating whether anonymous users can write tag values.
- /// When false, only authenticated users can write. Existing security classification restrictions still apply.
- ///
- public bool AnonymousCanWrite { get; set; } = true;
-
- ///
- /// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true,
- /// credentials are validated against the LDAP server and group membership determines permissions.
- ///
- public LdapConfiguration Ldap { get; set; } = new();
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigurationValidator.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigurationValidator.cs
deleted file mode 100644
index 248f106..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigurationValidator.cs
+++ /dev/null
@@ -1,314 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Data.SqlClient;
-using System.Linq;
-using Opc.Ua;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// Validates and logs effective configuration at startup. (SVC-003, SVC-005)
- ///
- public static class ConfigurationValidator
- {
- private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
-
- ///
- /// Validates the effective host configuration and writes the resolved values to the startup log before service
- /// initialization continues.
- ///
- ///
- /// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries,
- /// and dashboard behavior.
- ///
- ///
- /// when the required settings are present and within supported bounds; otherwise,
- /// .
- ///
- public static bool ValidateAndLog(AppConfiguration config)
- {
- var valid = true;
-
- Log.Information("=== Effective Configuration ===");
-
- // OPC UA
- Log.Information(
- "OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
- config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName,
- config.OpcUa.GalaxyName);
- Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}",
- config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes);
-
- if (config.OpcUa.Port < 1 || config.OpcUa.Port > 65535)
- {
- Log.Error("OpcUa.Port must be between 1 and 65535");
- valid = false;
- }
-
- if (string.IsNullOrWhiteSpace(config.OpcUa.GalaxyName))
- {
- Log.Error("OpcUa.GalaxyName must not be empty");
- valid = false;
- }
-
- // Alarm filter
- var alarmFilterCount = config.OpcUa.AlarmFilter?.ObjectFilters?.Count ?? 0;
- Log.Information(
- "OpcUa.AlarmTrackingEnabled={AlarmEnabled}, AlarmFilter.ObjectFilters=[{Filters}]",
- config.OpcUa.AlarmTrackingEnabled,
- alarmFilterCount == 0 ? "(none)" : string.Join(", ", config.OpcUa.AlarmFilter!.ObjectFilters));
- if (alarmFilterCount > 0 && !config.OpcUa.AlarmTrackingEnabled)
- Log.Warning(
- "OpcUa.AlarmFilter.ObjectFilters has {Count} patterns but OpcUa.AlarmTrackingEnabled is false — filter will have no effect",
- alarmFilterCount);
-
- // MxAccess
- Log.Information(
- "MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}",
- config.MxAccess.ClientName, config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds,
- config.MxAccess.MaxConcurrentOperations);
- Log.Information(
- "MxAccess.MonitorInterval={MonitorInterval}s, AutoReconnect={AutoReconnect}, ProbeTag={ProbeTag}, ProbeStaleThreshold={ProbeStale}s",
- config.MxAccess.MonitorIntervalSeconds, config.MxAccess.AutoReconnect,
- config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds);
- Log.Information(
- "MxAccess.RuntimeStatusProbesEnabled={Enabled}, RuntimeStatusUnknownTimeoutSeconds={Timeout}s, RequestTimeoutSeconds={RequestTimeout}s",
- config.MxAccess.RuntimeStatusProbesEnabled, config.MxAccess.RuntimeStatusUnknownTimeoutSeconds,
- config.MxAccess.RequestTimeoutSeconds);
-
- if (string.IsNullOrWhiteSpace(config.MxAccess.ClientName))
- {
- Log.Error("MxAccess.ClientName must not be empty");
- valid = false;
- }
-
- if (config.MxAccess.RuntimeStatusUnknownTimeoutSeconds < 5)
- Log.Warning(
- "MxAccess.RuntimeStatusUnknownTimeoutSeconds={Timeout} is below the recommended floor of 5s; initial probe resolution may time out before MxAccess has delivered the first callback",
- config.MxAccess.RuntimeStatusUnknownTimeoutSeconds);
-
- if (config.MxAccess.RequestTimeoutSeconds < 1)
- {
- Log.Error("MxAccess.RequestTimeoutSeconds must be at least 1");
- valid = false;
- }
- else if (config.MxAccess.RequestTimeoutSeconds <
- Math.Max(config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds))
- {
- Log.Warning(
- "MxAccess.RequestTimeoutSeconds={RequestTimeout} is below Read/Write inner timeouts ({Read}s/{Write}s); outer safety bound may fire before the inner client completes its own error path",
- config.MxAccess.RequestTimeoutSeconds,
- config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds);
- }
-
- // Galaxy Repository
- Log.Information(
- "GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}",
- SanitizeConnectionString(config.GalaxyRepository.ConnectionString), config.GalaxyRepository.ChangeDetectionIntervalSeconds,
- config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes);
-
- var effectivePlatformName = string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName)
- ? Environment.MachineName
- : config.GalaxyRepository.PlatformName;
- Log.Information(
- "GalaxyRepository.Scope={Scope}, PlatformName={PlatformName}",
- config.GalaxyRepository.Scope,
- config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform
- ? effectivePlatformName
- : "(n/a)");
-
- if (config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform &&
- string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName))
- Log.Information(
- "GalaxyRepository.PlatformName not set — using Environment.MachineName '{MachineName}'",
- Environment.MachineName);
-
- if (string.IsNullOrWhiteSpace(config.GalaxyRepository.ConnectionString))
- {
- Log.Error("GalaxyRepository.ConnectionString must not be empty");
- valid = false;
- }
-
- // Dashboard
- Log.Information("Dashboard.Enabled={Enabled}, Port={Port}, RefreshInterval={Refresh}s",
- config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds);
-
- // Security
- Log.Information(
- "Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}",
- string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates,
- config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize);
-
- Log.Information("Security.PkiRootPath={PkiRootPath}", config.Security.PkiRootPath ?? "(default)");
- Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject ?? "(default)");
- Log.Information("Security.CertificateLifetimeMonths={Months}", config.Security.CertificateLifetimeMonths);
-
- var unknownProfiles = config.Security.Profiles
- .Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, StringComparer.OrdinalIgnoreCase))
- .ToList();
- if (unknownProfiles.Count > 0)
- Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}",
- string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames));
-
- if (config.Security.MinimumCertificateKeySize < 2048)
- {
- Log.Error("Security.MinimumCertificateKeySize must be at least 2048");
- valid = false;
- }
-
- if (config.Security.AutoAcceptClientCertificates)
- Log.Warning(
- "Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production");
-
- if (config.Security.Profiles.Count == 1 &&
- config.Security.Profiles[0].Equals("None", StringComparison.OrdinalIgnoreCase))
- Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
-
- // Historian
- var clusterNodes = config.Historian.ServerNames ?? new List();
- var effectiveNodes = clusterNodes.Count > 0
- ? string.Join(",", clusterNodes)
- : config.Historian.ServerName;
- Log.Information(
- "Historian.Enabled={Enabled}, Nodes=[{Nodes}], IntegratedSecurity={IntegratedSecurity}, Port={Port}",
- config.Historian.Enabled, effectiveNodes, config.Historian.IntegratedSecurity,
- config.Historian.Port);
- Log.Information(
- "Historian.CommandTimeoutSeconds={Timeout}, MaxValuesPerRead={MaxValues}, FailureCooldownSeconds={Cooldown}, RequestTimeoutSeconds={RequestTimeout}",
- config.Historian.CommandTimeoutSeconds, config.Historian.MaxValuesPerRead,
- config.Historian.FailureCooldownSeconds, config.Historian.RequestTimeoutSeconds);
-
- if (config.Historian.Enabled)
- {
- if (clusterNodes.Count == 0 && string.IsNullOrWhiteSpace(config.Historian.ServerName))
- {
- Log.Error("Historian.ServerName (or ServerNames) must not be empty when Historian is enabled");
- valid = false;
- }
-
- if (config.Historian.FailureCooldownSeconds < 0)
- {
- Log.Error("Historian.FailureCooldownSeconds must be zero or positive");
- valid = false;
- }
-
- if (config.Historian.RequestTimeoutSeconds < 1)
- {
- Log.Error("Historian.RequestTimeoutSeconds must be at least 1");
- valid = false;
- }
- else if (config.Historian.RequestTimeoutSeconds < config.Historian.CommandTimeoutSeconds)
- {
- Log.Warning(
- "Historian.RequestTimeoutSeconds={RequestTimeout} is below CommandTimeoutSeconds={CmdTimeout}; outer safety bound may fire before the inner SDK completes its own error path",
- config.Historian.RequestTimeoutSeconds, config.Historian.CommandTimeoutSeconds);
- }
-
- if (clusterNodes.Count > 0 && !string.IsNullOrWhiteSpace(config.Historian.ServerName)
- && config.Historian.ServerName != "localhost")
- Log.Warning(
- "Historian.ServerName='{ServerName}' is ignored because Historian.ServerNames has {Count} entries",
- config.Historian.ServerName, clusterNodes.Count);
-
- if (config.Historian.Port < 1 || config.Historian.Port > 65535)
- {
- Log.Error("Historian.Port must be between 1 and 65535");
- valid = false;
- }
-
- if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.UserName))
- {
- Log.Error("Historian.UserName must not be empty when IntegratedSecurity is disabled");
- valid = false;
- }
-
- if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.Password))
- Log.Warning("Historian.Password is empty — authentication may fail");
- }
-
- // Authentication
- Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
- config.Authentication.AllowAnonymous, config.Authentication.AnonymousCanWrite);
-
- if (config.Authentication.Ldap.Enabled)
- {
- Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
- config.Authentication.Ldap.Host, config.Authentication.Ldap.Port,
- config.Authentication.Ldap.BaseDN);
- Log.Information(
- "Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}",
- config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup,
- config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup,
- config.Authentication.Ldap.AlarmAckGroup);
-
- if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
- Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
- }
-
- // Redundancy
- if (config.OpcUa.ApplicationUri != null)
- Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);
-
- Log.Information(
- "Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}",
- config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role,
- config.Redundancy.ServiceLevelBase);
-
- if (config.Redundancy.ServerUris.Count > 0)
- Log.Information("Redundancy.ServerUris=[{ServerUris}]",
- string.Join(", ", config.Redundancy.ServerUris));
-
- if (config.Redundancy.Enabled)
- {
- if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri))
- {
- Log.Error(
- "OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity");
- valid = false;
- }
-
- if (config.Redundancy.ServerUris.Count < 2)
- Log.Warning(
- "Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers");
-
- if (config.OpcUa.ApplicationUri != null &&
- !config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri))
- Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris",
- config.OpcUa.ApplicationUri);
-
- var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true);
- if (mode == RedundancySupport.None)
- Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None",
- config.Redundancy.Mode);
- }
-
- if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255)
- {
- Log.Error("Redundancy.ServiceLevelBase must be between 1 and 255");
- valid = false;
- }
-
- Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID");
- return valid;
- }
-
- private static string SanitizeConnectionString(string connectionString)
- {
- if (string.IsNullOrWhiteSpace(connectionString))
- return "(empty)";
- try
- {
- var builder = new SqlConnectionStringBuilder(connectionString);
- if (!string.IsNullOrEmpty(builder.Password))
- builder.Password = "********";
- return builder.ConnectionString;
- }
- catch
- {
- return "(unparseable)";
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/DashboardConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/DashboardConfiguration.cs
deleted file mode 100644
index 5e81b80..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/DashboardConfiguration.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// Status dashboard configuration. (SVC-003, DASH-001)
- ///
- public class DashboardConfiguration
- {
- ///
- /// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service.
- ///
- public bool Enabled { get; set; } = true;
-
- ///
- /// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state.
- ///
- public int Port { get; set; } = 8081;
-
- ///
- /// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot.
- ///
- public int RefreshIntervalSeconds { get; set; } = 10;
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs
deleted file mode 100644
index fb0b482..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// Galaxy repository database configuration. (SVC-003, GR-005)
- ///
- public class GalaxyRepositoryConfiguration
- {
- ///
- /// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata.
- ///
- public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
-
- ///
- /// Gets or sets how often, in seconds, the service polls for Galaxy deploy changes that require an address-space
- /// rebuild.
- ///
- public int ChangeDetectionIntervalSeconds { get; set; } = 30;
-
- ///
- /// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog.
- ///
- public int CommandTimeoutSeconds { get; set; } = 30;
-
- ///
- /// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model.
- ///
- public bool ExtendedAttributes { get; set; } = false;
-
- ///
- /// Gets or sets the scope of Galaxy objects loaded into the OPC UA address space.
- /// Galaxy loads all deployed objects (default). LocalPlatform loads only
- /// objects hosted by the platform deployed on this machine.
- ///
- public GalaxyScope Scope { get; set; } = GalaxyScope.Galaxy;
-
- ///
- /// Gets or sets an explicit platform node name for filtering.
- /// When , the local machine name (Environment.MachineName) is used.
- ///
- public string? PlatformName { get; set; }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyScope.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyScope.cs
deleted file mode 100644
index 77fdb7c..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyScope.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// Controls how much of the Galaxy object hierarchy is loaded into the OPC UA address space.
- ///
- public enum GalaxyScope
- {
- ///
- /// Load all deployed objects from the entire Galaxy (default, backward-compatible behavior).
- ///
- Galaxy,
-
- ///
- /// Load only objects hosted by the local platform and the structural areas needed to reach them.
- ///
- LocalPlatform
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianConfiguration.cs
deleted file mode 100644
index 924ca71..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianConfiguration.cs
+++ /dev/null
@@ -1,76 +0,0 @@
-using System.Collections.Generic;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// Wonderware Historian SDK configuration for OPC UA historical data access.
- ///
- public class HistorianConfiguration
- {
- ///
- /// Gets or sets a value indicating whether OPC UA historical data access is enabled.
- ///
- public bool Enabled { get; set; } = false;
-
- ///
- /// Gets or sets the single Historian server hostname used when
- /// is empty. Preserved for backward compatibility with pre-cluster deployments.
- ///
- public string ServerName { get; set; } = "localhost";
-
- ///
- /// Gets or sets the ordered list of Historian cluster nodes. When non-empty, this list
- /// supersedes : the data source attempts each node in order on
- /// connect, falling through to the next on failure. A failed node is placed in cooldown
- /// for before being re-eligible.
- ///
- public List ServerNames { get; set; } = new();
-
- ///
- /// Gets or sets the cooldown window, in seconds, that a historian node is skipped after
- /// a connection failure. A value of zero retries the node on every request. Default 60s.
- ///
- public int FailureCooldownSeconds { get; set; } = 60;
-
- ///
- /// Gets or sets a value indicating whether Windows Integrated Security is used.
- /// When false, and are used instead.
- ///
- public bool IntegratedSecurity { get; set; } = true;
-
- ///
- /// Gets or sets the username for Historian authentication when is false.
- ///
- public string? UserName { get; set; }
-
- ///
- /// Gets or sets the password for Historian authentication when is false.
- ///
- public string? Password { get; set; }
-
- ///
- /// Gets or sets the Historian server TCP port.
- ///
- public int Port { get; set; } = 32568;
-
- ///
- /// Gets or sets the packet timeout in seconds for Historian SDK operations.
- ///
- public int CommandTimeoutSeconds { get; set; } = 30;
-
- ///
- /// Gets or sets the maximum number of values returned per HistoryRead request.
- ///
- public int MaxValuesPerRead { get; set; } = 10000;
-
- ///
- /// Gets or sets an outer safety timeout, in seconds, applied to sync-over-async Historian
- /// operations invoked from the OPC UA stack thread (HistoryReadRaw, HistoryReadProcessed,
- /// HistoryReadAtTime, HistoryReadEvents). This is a backstop for the case where a
- /// historian query hangs outside — e.g., a slow SDK
- /// reconnect or mid-failover cluster node. Must be comfortably larger than
- /// so normal operation is never affected. Default 60s.
- ///
- public int RequestTimeoutSeconds { get; set; } = 60;
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapConfiguration.cs
deleted file mode 100644
index be94cfd..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapConfiguration.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// LDAP authentication and group-to-role mapping settings.
- ///
- public class LdapConfiguration
- {
- ///
- /// Gets or sets whether LDAP authentication is enabled.
- /// When true, user credentials are validated against the configured LDAP server
- /// and group membership determines OPC UA permissions.
- ///
- public bool Enabled { get; set; } = false;
-
- ///
- /// Gets or sets the LDAP server hostname or IP address.
- ///
- public string Host { get; set; } = "localhost";
-
- ///
- /// Gets or sets the LDAP server port.
- ///
- public int Port { get; set; } = 3893;
-
- ///
- /// Gets or sets the base DN for LDAP operations.
- ///
- public string BaseDN { get; set; } = "dc=lmxopcua,dc=local";
-
- ///
- /// Gets or sets the bind DN template. Use {username} as a placeholder.
- ///
- public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local";
-
- ///
- /// Gets or sets the service account DN used for LDAP searches (group lookups).
- ///
- public string ServiceAccountDn { get; set; } = "";
-
- ///
- /// Gets or sets the service account password.
- ///
- public string ServiceAccountPassword { get; set; } = "";
-
- ///
- /// Gets or sets the LDAP connection timeout in seconds.
- ///
- public int TimeoutSeconds { get; set; } = 5;
-
- ///
- /// Gets or sets the LDAP group name that grants read-only access.
- ///
- public string ReadOnlyGroup { get; set; } = "ReadOnly";
-
- ///
- /// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes.
- ///
- public string WriteOperateGroup { get; set; } = "WriteOperate";
-
- ///
- /// Gets or sets the LDAP group name that grants write access for Tune attributes.
- ///
- public string WriteTuneGroup { get; set; } = "WriteTune";
-
- ///
- /// Gets or sets the LDAP group name that grants write access for Configure attributes.
- ///
- public string WriteConfigureGroup { get; set; } = "WriteConfigure";
-
- ///
- /// Gets or sets the LDAP group name that grants alarm acknowledgment access.
- ///
- public string AlarmAckGroup { get; set; } = "AlarmAck";
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/MxAccessConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/MxAccessConfiguration.cs
deleted file mode 100644
index 4e460f5..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/MxAccessConfiguration.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// MXAccess client configuration. (SVC-003, MXA-008, MXA-009)
- ///
- public class MxAccessConfiguration
- {
- ///
- /// Gets or sets the client name registered with the MXAccess runtime for this bridge instance.
- ///
- public string ClientName { get; set; } = "LmxOpcUa";
-
- ///
- /// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node.
- ///
- public string? NodeName { get; set; }
-
- ///
- /// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics.
- ///
- public string? GalaxyName { get; set; }
-
- ///
- /// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete.
- ///
- public int ReadTimeoutSeconds { get; set; } = 5;
-
- ///
- /// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime.
- ///
- public int WriteTimeoutSeconds { get; set; } = 5;
-
- ///
- /// Gets or sets an outer safety timeout, in seconds, applied to sync-over-async MxAccess
- /// operations invoked from the OPC UA stack thread (Read, Write, address-space rebuild probe
- /// sync). This is a backstop for the case where an async path hangs outside the inner
- /// / bounds — e.g., a
- /// slow reconnect or a scheduler stall. Must be comfortably larger than the inner timeouts
- /// so normal operation is never affected. Default 30s.
- ///
- public int RequestTimeoutSeconds { get; set; } = 30;
-
- ///
- /// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime.
- ///
- public int MaxConcurrentOperations { get; set; } = 10;
-
- ///
- /// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection.
- ///
- public int MonitorIntervalSeconds { get; set; } = 5;
-
- ///
- /// Gets or sets a value indicating whether the bridge should automatically attempt to re-establish a dropped MXAccess
- /// session.
- ///
- public bool AutoReconnect { get; set; } = true;
-
- ///
- /// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data.
- ///
- public string? ProbeTag { get; set; }
-
- ///
- /// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale.
- ///
- public int ProbeStaleThresholdSeconds { get; set; } = 60;
-
- ///
- /// Gets or sets a value indicating whether the bridge advises <ObjectName>.ScanState for every
- /// deployed $WinPlatform and $AppEngine, reporting per-host runtime state on the status
- /// dashboard and proactively invalidating OPC UA variable quality when a host transitions to Stopped.
- /// Enabled by default. Disable to return to legacy behavior where host runtime state is invisible and
- /// MxAccess's per-tag bad-quality fan-out is the only stop signal.
- ///
- public bool RuntimeStatusProbesEnabled { get; set; } = true;
-
- ///
- /// Gets or sets the maximum seconds to wait for the initial probe callback before marking a host as
- /// Stopped. Only applies to the Unknown → Stopped transition. Because ScanState is delivered
- /// on-change only, a stably Running host does not time out — no starvation check runs on Running
- /// entries. Default 15s.
- ///
- public int RuntimeStatusUnknownTimeoutSeconds { get; set; } = 15;
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaConfiguration.cs
deleted file mode 100644
index e9516e6..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaConfiguration.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013)
- ///
- public class OpcUaConfiguration
- {
- ///
- /// Gets or sets the IP address or hostname the OPC UA server binds to.
- /// Defaults to 0.0.0.0 (all interfaces). Set to a specific IP or hostname to restrict listening.
- ///
- public string BindAddress { get; set; } = "0.0.0.0";
-
- ///
- /// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
- ///
- public int Port { get; set; } = 4840;
-
- ///
- /// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server.
- ///
- public string EndpointPath { get; set; } = "/LmxOpcUa";
-
- ///
- /// Gets or sets the server name presented to OPC UA clients and used in diagnostics.
- ///
- public string ServerName { get; set; } = "LmxOpcUa";
-
- ///
- /// Gets or sets the Galaxy name represented by the published OPC UA namespace.
- ///
- public string GalaxyName { get; set; } = "ZB";
-
- ///
- /// Gets or sets the explicit application URI for this server instance.
- /// When , defaults to urn:{GalaxyName}:LmxOpcUa.
- /// Must be set to a unique value per instance when redundancy is enabled.
- ///
- public string? ApplicationUri { get; set; }
-
- ///
- /// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
- ///
- public int MaxSessions { get; set; } = 100;
-
- ///
- /// Gets or sets the session timeout, in minutes, before idle client sessions are closed.
- ///
- public int SessionTimeoutMinutes { get; set; } = 30;
-
- ///
- /// Gets or sets a value indicating whether alarm tracking is enabled.
- /// When enabled, AlarmConditionState nodes are created for alarm attributes and InAlarm transitions are monitored.
- ///
- public bool AlarmTrackingEnabled { get; set; } = false;
-
- ///
- /// Gets or sets the template-based alarm object filter. When
- /// is empty, all alarm-bearing objects are monitored (current behavior). When patterns are supplied, only
- /// objects whose template derivation chain matches a pattern (and their descendants) have alarms monitored.
- ///
- public AlarmFilterConfiguration AlarmFilter { get; set; } = new();
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/RedundancyConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/RedundancyConfiguration.cs
deleted file mode 100644
index 74a79c6..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/RedundancyConfiguration.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System.Collections.Generic;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// Non-transparent redundancy settings that control how the server advertises itself
- /// within a redundant pair and computes its dynamic ServiceLevel.
- ///
- public class RedundancyConfiguration
- {
- ///
- /// Gets or sets whether redundancy is enabled. When (default),
- /// the server reports RedundancySupport.None and ServiceLevel = 255.
- ///
- public bool Enabled { get; set; } = false;
-
- ///
- /// Gets or sets the redundancy mode. Valid values: Warm, Hot.
- ///
- public string Mode { get; set; } = "Warm";
-
- ///
- /// Gets or sets the role of this instance. Valid values: Primary, Secondary.
- /// The primary advertises a higher ServiceLevel than the secondary when both are healthy.
- ///
- public string Role { get; set; } = "Primary";
-
- ///
- /// Gets or sets the ApplicationUri values for all servers in the redundant set.
- /// Must include this instance's own OpcUa.ApplicationUri.
- ///
- public List ServerUris { get; set; } = new();
-
- ///
- /// Gets or sets the base ServiceLevel when the server is fully healthy.
- /// The secondary automatically receives ServiceLevelBase - 50.
- /// Valid range: 1-255.
- ///
- public int ServiceLevelBase { get; set; } = 200;
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecurityProfileConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecurityProfileConfiguration.cs
deleted file mode 100644
index 67f584b..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecurityProfileConfiguration.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using System.Collections.Generic;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
-{
- ///
- /// Transport security settings that control which OPC UA security profiles the server exposes and how client
- /// certificates are handled.
- ///
- public class SecurityProfileConfiguration
- {
- ///
- /// Gets or sets the list of security profile names to expose as server endpoints.
- /// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt".
- /// Defaults to ["None"] for backward compatibility.
- ///
- public List Profiles { get; set; } = new() { "None" };
-
- ///
- /// Gets or sets a value indicating whether the server automatically accepts client certificates
- /// that are not in the trusted store. Should be in production.
- ///
- public bool AutoAcceptClientCertificates { get; set; } = true;
-
- ///
- /// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected.
- ///
- public bool RejectSHA1Certificates { get; set; } = true;
-
- ///
- /// Gets or sets the minimum RSA key size required for client certificates.
- ///
- public int MinimumCertificateKeySize { get; set; } = 2048;
-
- ///
- /// Gets or sets an optional override for the PKI root directory.
- /// When , defaults to %LOCALAPPDATA%\OPC Foundation\pki.
- ///
- public string? PkiRootPath { get; set; }
-
- ///
- /// Gets or sets an optional override for the server certificate subject name.
- /// When , defaults to CN={ServerName}, O=ZB MOM, DC=localhost.
- ///
- public string? CertificateSubject { get; set; }
-
- ///
- /// Gets or sets the lifetime of the auto-generated server certificate in months.
- /// Defaults to 60 months (5 years).
- ///
- public int CertificateLifetimeMonths { get; set; } = 60;
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/AlarmObjectFilter.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/AlarmObjectFilter.cs
deleted file mode 100644
index 3224f28..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/AlarmObjectFilter.cs
+++ /dev/null
@@ -1,215 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text.RegularExpressions;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Configuration;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Compiles and applies wildcard template patterns against Galaxy objects to decide which
- /// objects should contribute alarm conditions. The filter is pure data — no OPC UA, no DB —
- /// so it is fully unit-testable with synthetic hierarchies.
- ///
- ///
- /// Matching rules:
- ///
- /// - An object is included when any template name in its derivation chain matches
- /// any configured pattern.
- /// - Matching is case-insensitive and ignores the Galaxy leading $ prefix on
- /// both the chain entry and the user pattern, so TestMachine* matches the stored
- /// $TestMachine.
- /// - Inclusion propagates to every descendant of a matched object (containment subtree).
- /// - Each object is evaluated once — overlapping matches never produce duplicate
- /// inclusions (set semantics).
- ///
- /// Pattern syntax: literal text plus * wildcards (zero or more characters).
- /// Other regex metacharacters in the raw pattern are escaped and treated literally.
- ///
- public class AlarmObjectFilter
- {
- private static readonly ILogger Log = Serilog.Log.ForContext();
-
- private readonly List _patterns;
- private readonly List _rawPatterns;
- private readonly HashSet _matchedRawPatterns;
-
- ///
- /// Initializes a new alarm object filter from the supplied configuration section.
- ///
- /// The alarm filter configuration whose
- /// entries are parsed into regular expressions. Entries may themselves contain comma-separated patterns.
- public AlarmObjectFilter(AlarmFilterConfiguration? config)
- {
- _patterns = new List();
- _rawPatterns = new List();
- _matchedRawPatterns = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- if (config?.ObjectFilters == null)
- return;
-
- foreach (var entry in config.ObjectFilters)
- {
- if (string.IsNullOrWhiteSpace(entry))
- continue;
-
- foreach (var piece in entry.Split(','))
- {
- var trimmed = piece.Trim();
- if (trimmed.Length == 0)
- continue;
-
- try
- {
- var normalized = Normalize(trimmed);
- var regex = GlobToRegex(normalized);
- _patterns.Add(regex);
- _rawPatterns.Add(trimmed);
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Failed to compile alarm filter pattern {Pattern} — skipping", trimmed);
- }
- }
- }
- }
-
- ///
- /// Gets a value indicating whether the filter has any compiled patterns. When ,
- /// callers should treat alarm tracking as unfiltered (current behavior preserved).
- ///
- public bool Enabled => _patterns.Count > 0;
-
- ///
- /// Gets the number of compiled patterns the filter will evaluate against each object.
- ///
- public int PatternCount => _patterns.Count;
-
- ///
- /// Gets the raw pattern strings that did not match any object in the most recent call to
- /// . Useful for startup warnings about operator typos.
- ///
- public IReadOnlyList UnmatchedPatterns =>
- _rawPatterns.Where(p => !_matchedRawPatterns.Contains(p)).ToList();
-
- ///
- /// Gets the raw pattern strings exactly as supplied by the operator after comma-splitting
- /// and trimming. Surfaced on the status dashboard so operators can confirm the active filter.
- ///
- public IReadOnlyList RawPatterns => _rawPatterns;
-
- ///
- /// Returns when any template name in matches any
- /// compiled pattern. An empty chain never matches unless the operator explicitly supplied a pattern
- /// equal to * (which collapses to an empty-matching regex after normalization).
- ///
- /// The template derivation chain to test (own template first, ancestors after).
- public bool MatchesTemplateChain(IReadOnlyList? chain)
- {
- if (chain == null || chain.Count == 0 || _patterns.Count == 0)
- return false;
-
- for (var i = 0; i < _patterns.Count; i++)
- {
- var regex = _patterns[i];
- for (var j = 0; j < chain.Count; j++)
- {
- var entry = chain[j];
- if (string.IsNullOrEmpty(entry))
- continue;
- if (regex.IsMatch(Normalize(entry)))
- {
- _matchedRawPatterns.Add(_rawPatterns[i]);
- return true;
- }
- }
- }
-
- return false;
- }
-
- ///
- /// Walks the hierarchy top-down from each root and returns the set of gobject IDs whose alarms
- /// should be monitored, honoring both template matching and descendant propagation. Returns
- /// when the filter is disabled so callers can skip the containment check
- /// entirely.
- ///
- /// The full deployed Galaxy hierarchy, as returned by the repository service.
- /// The set of included gobject IDs, or when filtering is disabled.
- public HashSet? ResolveIncludedObjects(IReadOnlyList? hierarchy)
- {
- if (!Enabled)
- return null;
-
- _matchedRawPatterns.Clear();
- var included = new HashSet();
- if (hierarchy == null || hierarchy.Count == 0)
- return included;
-
- var byId = new Dictionary(hierarchy.Count);
- foreach (var obj in hierarchy)
- byId[obj.GobjectId] = obj;
-
- var childrenByParent = new Dictionary>();
- foreach (var obj in hierarchy)
- {
- var parentId = obj.ParentGobjectId;
- if (parentId != 0 && !byId.ContainsKey(parentId))
- parentId = 0; // orphan → treat as root
- if (!childrenByParent.TryGetValue(parentId, out var list))
- {
- list = new List();
- childrenByParent[parentId] = list;
- }
- list.Add(obj.GobjectId);
- }
-
- var roots = childrenByParent.TryGetValue(0, out var rootList)
- ? rootList
- : new List();
-
- var visited = new HashSet();
- var queue = new Queue<(int Id, bool ParentIncluded)>();
- foreach (var rootId in roots)
- queue.Enqueue((rootId, false));
-
- while (queue.Count > 0)
- {
- var (id, parentIncluded) = queue.Dequeue();
- if (!visited.Add(id))
- continue; // cycle defense
-
- if (!byId.TryGetValue(id, out var obj))
- continue;
-
- var nodeIncluded = parentIncluded || MatchesTemplateChain(obj.TemplateChain);
- if (nodeIncluded)
- included.Add(id);
-
- if (childrenByParent.TryGetValue(id, out var children))
- foreach (var childId in children)
- queue.Enqueue((childId, nodeIncluded));
- }
-
- return included;
- }
-
- private static Regex GlobToRegex(string normalized)
- {
- var segments = normalized.Split('*');
- var parts = segments.Select(Regex.Escape);
- var body = string.Join(".*", parts);
- return new Regex("^" + body + "$",
- RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
- }
-
- private static string Normalize(string value)
- {
- var trimmed = value.Trim();
- if (trimmed.StartsWith("$", StringComparison.Ordinal))
- return trimmed.Substring(1);
- return trimmed;
- }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionState.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionState.cs
deleted file mode 100644
index f0a9f44..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionState.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// MXAccess connection lifecycle states. (MXA-002)
- ///
- public enum ConnectionState
- {
- ///
- /// No active session exists to the Galaxy runtime.
- ///
- Disconnected,
-
- ///
- /// The bridge is opening a new MXAccess session to the runtime.
- ///
- Connecting,
-
- ///
- /// The bridge has an active MXAccess session and can service reads, writes, and subscriptions.
- ///
- Connected,
-
- ///
- /// The bridge is closing the current MXAccess session and draining runtime resources.
- ///
- Disconnecting,
-
- ///
- /// The bridge detected a connection fault that requires operator attention or recovery logic.
- ///
- Error,
-
- ///
- /// The bridge is attempting to restore service after a runtime communication failure.
- ///
- Reconnecting
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs
deleted file mode 100644
index 2a1cb9f..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using System;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Event args for connection state transitions. (MXA-002)
- ///
- public class ConnectionStateChangedEventArgs : EventArgs
- {
- ///
- /// Initializes a new instance of the class.
- ///
- /// The connection state being exited.
- /// The connection state being entered.
- /// Additional context about the transition, such as a connection fault or reconnect attempt.
- public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "")
- {
- PreviousState = previous;
- CurrentState = current;
- Message = message ?? "";
- }
-
- ///
- /// Gets the previous MXAccess connection state before the transition was raised.
- ///
- public ConnectionState PreviousState { get; }
-
- ///
- /// Gets the new MXAccess connection state that the bridge moved into.
- ///
- public ConnectionState CurrentState { get; }
-
- ///
- /// Gets an operator-facing message that explains why the connection state changed.
- ///
- public string Message { get; }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyAttributeInfo.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyAttributeInfo.cs
deleted file mode 100644
index 7865778..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyAttributeInfo.cs
+++ /dev/null
@@ -1,76 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// DTO matching attributes.sql result columns. (GR-002)
- ///
- public class GalaxyAttributeInfo
- {
- ///
- /// Gets or sets the Galaxy object identifier that owns the attribute.
- ///
- public int GobjectId { get; set; }
-
- ///
- /// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object.
- ///
- public string TagName { get; set; } = "";
-
- ///
- /// Gets or sets the attribute name as defined on the Galaxy template or instance.
- ///
- public string AttributeName { get; set; } = "";
-
- ///
- /// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes.
- ///
- public string FullTagReference { get; set; } = "";
-
- ///
- /// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA.
- ///
- public int MxDataType { get; set; }
-
- ///
- /// Gets or sets the human-readable Galaxy data type name returned by the repository query.
- ///
- public string DataTypeName { get; set; } = "";
-
- ///
- /// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node.
- ///
- public bool IsArray { get; set; }
-
- ///
- /// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array.
- ///
- public int? ArrayDimension { get; set; }
-
- ///
- /// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients.
- ///
- public string PrimitiveName { get; set; } = "";
-
- ///
- /// Gets or sets the source classification that explains whether the attribute comes from configuration, calculation,
- /// or runtime data.
- ///
- public string AttributeSource { get; set; } = "";
-
- ///
- /// Gets or sets the Galaxy security classification that determines OPC UA write access.
- /// 0=FreeAccess, 1=Operate (default), 2=SecuredWrite, 3=VerifiedWrite, 4=Tune, 5=Configure, 6=ViewOnly.
- ///
- public int SecurityClassification { get; set; } = 1;
-
- ///
- /// Gets or sets a value indicating whether the attribute has a HistoryExtension primitive and is historized by the
- /// Wonderware Historian.
- ///
- public bool IsHistorized { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the attribute has an AlarmExtension primitive and is an alarm.
- ///
- public bool IsAlarm { get; set; }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyObjectInfo.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyObjectInfo.cs
deleted file mode 100644
index be5a538..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyObjectInfo.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using System.Collections.Generic;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// DTO matching hierarchy.sql result columns. (GR-001)
- ///
- public class GalaxyObjectInfo
- {
- ///
- /// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows.
- ///
- public int GobjectId { get; set; }
-
- ///
- /// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree.
- ///
- public string TagName { get; set; } = "";
-
- ///
- /// Gets or sets the contained name shown for the object inside its parent area or object.
- ///
- public string ContainedName { get; set; } = "";
-
- ///
- /// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy.
- ///
- public string BrowseName { get; set; } = "";
-
- ///
- /// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship.
- ///
- public int ParentGobjectId { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object.
- ///
- public bool IsArea { get; set; }
-
- ///
- /// Gets or sets the template derivation chain for this object. Index 0 is the object's own template;
- /// subsequent entries walk up toward the most ancestral template before $Object. Populated by
- /// the recursive CTE in hierarchy.sql on gobject.derived_from_gobject_id. Used by
- /// to decide whether an object's alarms should be monitored.
- ///
- public List TemplateChain { get; set; } = new();
-
- ///
- /// Gets or sets the Galaxy template category id for this object. Category 1 is $WinPlatform,
- /// 3 is $AppEngine, 13 is $Area, 10 is $UserDefined, and so on. Populated from
- /// template_definition.category_id by hierarchy.sql and consumed by the runtime
- /// status probe manager to identify hosts that should receive a ScanState probe.
- ///
- public int CategoryId { get; set; }
-
- ///
- /// Gets or sets the Galaxy object id of this object's runtime host, populated from
- /// gobject.hosted_by_gobject_id. Walk this chain upward to find the nearest
- /// $WinPlatform or $AppEngine ancestor for subtree quality invalidation when
- /// a runtime host is reported Stopped. Zero for root objects that have no host.
- ///
- public int HostedByGobjectId { get; set; }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeState.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeState.cs
deleted file mode 100644
index 3e8fa57..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeState.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Runtime state of a deployed Galaxy runtime host ($WinPlatform or $AppEngine) as
- /// observed by the bridge via its ScanState probe.
- ///
- public enum GalaxyRuntimeState
- {
- ///
- /// Probe advised but no callback received yet. Transitions to
- /// on the first successful ScanState = true callback, or to
- /// once the unknown-resolution timeout elapses.
- ///
- Unknown,
-
- ///
- /// Last probe callback reported ScanState = true with a successful item status.
- /// The host is on scan and executing.
- ///
- Running,
-
- ///
- /// Last probe callback reported ScanState != true, or a failed item status, or
- /// the initial probe never resolved before the unknown timeout elapsed. The host is
- /// off scan or unreachable.
- ///
- Stopped
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeStatus.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeStatus.cs
deleted file mode 100644
index f859fe9..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeStatus.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-using System;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Point-in-time runtime state of a single Galaxy runtime host ($WinPlatform or $AppEngine)
- /// as tracked by the GalaxyRuntimeProbeManager. Surfaced on the status dashboard and
- /// consumed by HealthCheckService so operators can detect a stopped host before
- /// downstream clients notice the stale data.
- ///
- public sealed class GalaxyRuntimeStatus
- {
- ///
- /// Gets or sets the Galaxy tag_name of the host (e.g., DevPlatform or
- /// DevAppEngine).
- ///
- public string ObjectName { get; set; } = "";
-
- ///
- /// Gets or sets the Galaxy gobject_id of the host.
- ///
- public int GobjectId { get; set; }
-
- ///
- /// Gets or sets the Galaxy template category name — $WinPlatform or
- /// $AppEngine. Used by the dashboard to group hosts by kind.
- ///
- public string Kind { get; set; } = "";
-
- ///
- /// Gets or sets the current runtime state.
- ///
- public GalaxyRuntimeState State { get; set; }
-
- ///
- /// Gets or sets the UTC timestamp of the most recent probe callback, whether it
- /// reported success or failure. before the first callback.
- ///
- public DateTime? LastStateCallbackTime { get; set; }
-
- ///
- /// Gets or sets the UTC timestamp of the most recent transition.
- /// Backs the dashboard "Since" column. in the initial Unknown
- /// state before any transition.
- ///
- public DateTime? LastStateChangeTime { get; set; }
-
- ///
- /// Gets or sets the last ScanState value received from the probe, or
- /// before the first update or when the last callback carried
- /// a non-success item status (no value delivered).
- ///
- public bool? LastScanState { get; set; }
-
- ///
- /// Gets or sets the detail message from the most recent failure callback, cleared on
- /// the next successful ScanState = true delivery.
- ///
- public string? LastError { get; set; }
-
- ///
- /// Gets or sets the cumulative number of callbacks where ScanState = true.
- ///
- public long GoodUpdateCount { get; set; }
-
- ///
- /// Gets or sets the cumulative number of callbacks where ScanState != true
- /// or the item status reported failure.
- ///
- public long FailureCount { get; set; }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IGalaxyRepository.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IGalaxyRepository.cs
deleted file mode 100644
index 60440da..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IGalaxyRepository.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Interface for Galaxy repository database queries. (GR-001 through GR-004)
- ///
- public interface IGalaxyRepository
- {
- ///
- /// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree.
- ///
- /// A token that cancels the repository query.
- /// A list of Galaxy objects ordered for address-space construction.
- Task> GetHierarchyAsync(CancellationToken ct = default);
-
- ///
- /// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy.
- ///
- /// A token that cancels the repository query.
- /// A list of attribute definitions with MXAccess references and type metadata.
- Task> GetAttributesAsync(CancellationToken ct = default);
-
- ///
- /// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild.
- ///
- /// A token that cancels the repository query.
- /// The latest deploy timestamp, or when it cannot be determined.
- Task GetLastDeployTimeAsync(CancellationToken ct = default);
-
- ///
- /// Verifies that the service can reach the Galaxy repository before it attempts to build the address space.
- ///
- /// A token that cancels the connectivity check.
- /// when repository access succeeds; otherwise, .
- Task TestConnectionAsync(CancellationToken ct = default);
-
- ///
- /// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild.
- ///
- event Action? OnGalaxyChanged;
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxAccessClient.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxAccessClient.cs
deleted file mode 100644
index c5c89aa..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxAccessClient.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Abstraction over MXAccess COM client for tag read/write/subscribe operations.
- /// (MXA-001 through MXA-009, OPC-007, OPC-008, OPC-009)
- ///
- public interface IMxAccessClient : IDisposable
- {
- ///
- /// Gets the current runtime connectivity state for the bridge.
- ///
- ConnectionState State { get; }
-
- ///
- /// Gets the number of active runtime subscriptions currently being mirrored into OPC UA.
- ///
- int ActiveSubscriptionCount { get; }
-
- ///
- /// Gets the number of reconnect cycles attempted since the client was created.
- ///
- int ReconnectCount { get; }
-
- ///
- /// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
- ///
- event EventHandler? ConnectionStateChanged;
-
- ///
- /// Occurs when a subscribed Galaxy attribute publishes a new runtime value.
- ///
- event Action? OnTagValueChanged;
-
- ///
- /// Opens the MXAccess session required for runtime reads, writes, and subscriptions.
- ///
- /// A token that cancels the connection attempt.
- Task ConnectAsync(CancellationToken ct = default);
-
- ///
- /// Closes the MXAccess session and releases runtime resources.
- ///
- Task DisconnectAsync();
-
- ///
- /// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers.
- ///
- /// The fully qualified MXAccess reference for the target attribute.
- /// The callback to invoke when the runtime publishes a new value for the attribute.
- Task SubscribeAsync(string fullTagReference, Action callback);
-
- ///
- /// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer.
- ///
- /// The fully qualified MXAccess reference for the target attribute.
- Task UnsubscribeAsync(string fullTagReference);
-
- ///
- /// Reads the current runtime value for a Galaxy attribute.
- ///
- /// The fully qualified MXAccess reference for the target attribute.
- /// A token that cancels the read.
- /// The value, timestamp, and quality returned by the runtime.
- Task ReadAsync(string fullTagReference, CancellationToken ct = default);
-
- ///
- /// Writes a new runtime value to a writable Galaxy attribute.
- ///
- /// The fully qualified MXAccess reference for the target attribute.
- /// The value to write to the runtime.
- /// A token that cancels the write.
- /// when the write is accepted by the runtime; otherwise, .
- Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default);
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxProxy.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxProxy.cs
deleted file mode 100644
index e03ea54..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxProxy.cs
+++ /dev/null
@@ -1,99 +0,0 @@
-using ArchestrA.MxAccess;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Delegate matching LMXProxyServer.OnDataChange COM event signature.
- ///
- /// The runtime connection handle that raised the change.
- /// The runtime item handle for the attribute that changed.
- /// The new raw runtime value for the attribute.
- /// The OPC DA quality code supplied by the runtime.
- /// The timestamp object supplied by the runtime for the value.
- /// The MXAccess status payload associated with the callback.
- public delegate void MxDataChangeHandler(
- int hLMXServerHandle,
- int phItemHandle,
- object pvItemValue,
- int pwItemQuality,
- object pftItemTimeStamp,
- ref MXSTATUS_PROXY[] ItemStatus);
-
- ///
- /// Delegate matching LMXProxyServer.OnWriteComplete COM event signature.
- ///
- /// The runtime connection handle that processed the write.
- /// The runtime item handle that was written.
- /// The MXAccess status payload describing the write outcome.
- public delegate void MxWriteCompleteHandler(
- int hLMXServerHandle,
- int phItemHandle,
- ref MXSTATUS_PROXY[] ItemStatus);
-
- ///
- /// Abstraction over LMXProxyServer COM object to enable testing without the COM runtime. (MXA-001)
- ///
- public interface IMxProxy
- {
- ///
- /// Registers the bridge as an MXAccess client with the runtime proxy.
- ///
- /// The client identity reported to the runtime for diagnostics and session tracking.
- /// The runtime connection handle assigned to the client session.
- int Register(string clientName);
-
- ///
- /// Unregisters the bridge from the runtime proxy and releases the connection handle.
- ///
- /// The connection handle returned by .
- void Unregister(int handle);
-
- ///
- /// Adds a Galaxy attribute reference to the active runtime session.
- ///
- /// The runtime connection handle.
- /// The fully qualified attribute reference to resolve.
- /// The runtime item handle assigned to the attribute.
- int AddItem(int handle, string address);
-
- ///
- /// Removes a previously registered attribute from the runtime session.
- ///
- /// The runtime connection handle.
- /// The item handle returned by .
- void RemoveItem(int handle, int itemHandle);
-
- ///
- /// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge.
- ///
- /// The runtime connection handle.
- /// The item handle to monitor.
- void AdviseSupervisory(int handle, int itemHandle);
-
- ///
- /// Stops supervisory updates for an attribute.
- ///
- /// The runtime connection handle.
- /// The item handle to stop monitoring.
- void UnAdviseSupervisory(int handle, int itemHandle);
-
- ///
- /// Writes a new value to a runtime attribute through the COM proxy.
- ///
- /// The runtime connection handle.
- /// The item handle to write.
- /// The new value to push into the runtime.
- /// The Wonderware security classification applied to the write.
- void Write(int handle, int itemHandle, object value, int securityClassification);
-
- ///
- /// Occurs when the runtime pushes a data-change callback for a subscribed attribute.
- ///
- event MxDataChangeHandler? OnDataChange;
-
- ///
- /// Occurs when the runtime acknowledges completion of a write request.
- ///
- event MxWriteCompleteHandler? OnWriteComplete;
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IUserAuthenticationProvider.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IUserAuthenticationProvider.cs
deleted file mode 100644
index 7fead8c..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IUserAuthenticationProvider.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System.Collections.Generic;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP,
- /// etc.).
- ///
- public interface IUserAuthenticationProvider
- {
- ///
- /// Validates a username/password combination.
- ///
- bool ValidateCredentials(string username, string password);
- }
-
- ///
- /// Extended interface for providers that can resolve application-level roles for authenticated users.
- /// When the auth provider implements this interface, OnImpersonateUser uses the returned roles
- /// to control write and alarm-ack permissions.
- ///
- public interface IRoleProvider
- {
- ///
- /// Returns the set of application-level roles granted to the user.
- ///
- IReadOnlyList GetUserRoles(string username);
- }
-
- ///
- /// Well-known application-level role names used for permission enforcement.
- ///
- public static class AppRoles
- {
- public const string ReadOnly = "ReadOnly";
- public const string WriteOperate = "WriteOperate";
- public const string WriteTune = "WriteTune";
- public const string WriteConfigure = "WriteConfigure";
- public const string AlarmAck = "AlarmAck";
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LdapAuthenticationProvider.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LdapAuthenticationProvider.cs
deleted file mode 100644
index 1b25c75..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LdapAuthenticationProvider.cs
+++ /dev/null
@@ -1,148 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.DirectoryServices.Protocols;
-using System.Net;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Configuration;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Validates credentials via LDAP bind and resolves group membership to application roles.
- ///
- public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
- {
- private static readonly ILogger Log = Serilog.Log.ForContext();
-
- private readonly LdapConfiguration _config;
- private readonly Dictionary _groupToRole;
-
- public LdapAuthenticationProvider(LdapConfiguration config)
- {
- _config = config;
- _groupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase)
- {
- { config.ReadOnlyGroup, AppRoles.ReadOnly },
- { config.WriteOperateGroup, AppRoles.WriteOperate },
- { config.WriteTuneGroup, AppRoles.WriteTune },
- { config.WriteConfigureGroup, AppRoles.WriteConfigure },
- { config.AlarmAckGroup, AppRoles.AlarmAck }
- };
- }
-
- public IReadOnlyList GetUserRoles(string username)
- {
- try
- {
- using (var connection = CreateConnection())
- {
- // Bind with service account to search
- connection.Bind(new NetworkCredential(_config.ServiceAccountDn, _config.ServiceAccountPassword));
-
- var request = new SearchRequest(
- _config.BaseDN,
- $"(cn={EscapeLdapFilter(username)})",
- SearchScope.Subtree,
- "memberOf");
-
- var response = (SearchResponse)connection.SendRequest(request);
-
- if (response.Entries.Count == 0)
- {
- Log.Warning("LDAP search returned no entries for {Username}", username);
- return new[] { AppRoles.ReadOnly }; // safe fallback
- }
-
- var entry = response.Entries[0];
- var memberOf = entry.Attributes["memberOf"];
- if (memberOf == null || memberOf.Count == 0)
- {
- Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username);
- return new[] { AppRoles.ReadOnly };
- }
-
- var roles = new List();
- for (var i = 0; i < memberOf.Count; i++)
- {
- var dn = memberOf[i]?.ToString() ?? "";
- // Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...")
- var groupName = ExtractGroupName(dn);
- if (groupName != null && _groupToRole.TryGetValue(groupName, out var role)) roles.Add(role);
- }
-
- if (roles.Count == 0)
- {
- Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username);
- roles.Add(AppRoles.ReadOnly);
- }
-
- Log.Debug("LDAP roles for {Username}: [{Roles}]", username, string.Join(", ", roles));
- return roles;
- }
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username);
- return new[] { AppRoles.ReadOnly };
- }
- }
-
- public bool ValidateCredentials(string username, string password)
- {
- try
- {
- var bindDn = _config.BindDnTemplate.Replace("{username}", username);
- using (var connection = CreateConnection())
- {
- connection.Bind(new NetworkCredential(bindDn, password));
- }
-
- Log.Debug("LDAP bind succeeded for {Username}", username);
- return true;
- }
- catch (LdapException ex)
- {
- Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message);
- return false;
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "LDAP error during credential validation for {Username}", username);
- return false;
- }
- }
-
- private LdapConnection CreateConnection()
- {
- var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port);
- var connection = new LdapConnection(identifier)
- {
- AuthType = AuthType.Basic,
- Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds)
- };
- connection.SessionOptions.ProtocolVersion = 3;
- return connection;
- }
-
- private static string? ExtractGroupName(string dn)
- {
- // Parse "ou=ReadWrite,ou=groups,dc=..." or "cn=ReadWrite,..."
- if (string.IsNullOrEmpty(dn)) return null;
- var parts = dn.Split(',');
- if (parts.Length == 0) return null;
- var first = parts[0].Trim();
- var eqIdx = first.IndexOf('=');
- return eqIdx >= 0 ? first.Substring(eqIdx + 1) : null;
- }
-
- private static string EscapeLdapFilter(string input)
- {
- return input
- .Replace("\\", "\\5c")
- .Replace("*", "\\2a")
- .Replace("(", "\\28")
- .Replace(")", "\\29")
- .Replace("\0", "\\00");
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LmxRoleIds.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LmxRoleIds.cs
deleted file mode 100644
index 1b239b0..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LmxRoleIds.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Stable identifiers for custom OPC UA roles mapped from LDAP groups.
- /// The namespace URI is registered in the server namespace table at startup,
- /// and the string identifiers are resolved to runtime NodeIds before use.
- ///
- public static class LmxRoleIds
- {
- public const string NamespaceUri = "urn:zbmom:lmxopcua:roles";
-
- public const string ReadOnly = "Role.ReadOnly";
- public const string WriteOperate = "Role.WriteOperate";
- public const string WriteTune = "Role.WriteTune";
- public const string WriteConfigure = "Role.WriteConfigure";
- public const string AlarmAck = "Role.AlarmAck";
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxDataTypeMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxDataTypeMapper.cs
deleted file mode 100644
index 3e59b84..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxDataTypeMapper.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using System;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Maps Galaxy mx_data_type integers to OPC UA data types and CLR types. (OPC-005)
- /// See gr/data_type_mapping.md for full mapping table.
- ///
- public static class MxDataTypeMapper
- {
- ///
- /// Maps mx_data_type to OPC UA DataType NodeId numeric identifier.
- /// Unknown types default to String (i=12).
- ///
- /// The Galaxy MX data type code.
- /// The OPC UA built-in data type node identifier.
- public static uint MapToOpcUaDataType(int mxDataType)
- {
- return mxDataType switch
- {
- 1 => 1, // Boolean → i=1
- 2 => 6, // Integer → Int32 i=6
- 3 => 10, // Float → Float i=10
- 4 => 11, // Double → Double i=11
- 5 => 12, // String → String i=12
- 6 => 13, // Time → DateTime i=13
- 7 => 11, // ElapsedTime → Double i=11 (seconds)
- 8 => 12, // Reference → String i=12
- 13 => 6, // Enumeration → Int32 i=6
- 14 => 12, // Custom → String i=12
- 15 => 21, // InternationalizedString → LocalizedText i=21
- 16 => 12, // Custom → String i=12
- _ => 12 // Unknown → String i=12
- };
- }
-
- ///
- /// Maps mx_data_type to the corresponding CLR type.
- ///
- /// The Galaxy MX data type code.
- /// The CLR type used to represent runtime values for the MX type.
- public static Type MapToClrType(int mxDataType)
- {
- return mxDataType switch
- {
- 1 => typeof(bool),
- 2 => typeof(int),
- 3 => typeof(float),
- 4 => typeof(double),
- 5 => typeof(string),
- 6 => typeof(DateTime),
- 7 => typeof(double), // ElapsedTime as seconds
- 8 => typeof(string), // Reference as string
- 13 => typeof(int), // Enum backing integer
- 14 => typeof(string),
- 15 => typeof(string), // LocalizedText stored as string
- 16 => typeof(string),
- _ => typeof(string)
- };
- }
-
- ///
- /// Returns the OPC UA type name for a given mx_data_type.
- ///
- /// The Galaxy MX data type code.
- /// The OPC UA type name used in diagnostics.
- public static string GetOpcUaTypeName(int mxDataType)
- {
- return mxDataType switch
- {
- 1 => "Boolean",
- 2 => "Int32",
- 3 => "Float",
- 4 => "Double",
- 5 => "String",
- 6 => "DateTime",
- 7 => "Double",
- 8 => "String",
- 13 => "Int32",
- 14 => "String",
- 15 => "LocalizedText",
- 16 => "String",
- _ => "String"
- };
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxErrorCodes.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxErrorCodes.cs
deleted file mode 100644
index 5222ac2..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxErrorCodes.cs
+++ /dev/null
@@ -1,76 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009)
- ///
- public static class MxErrorCodes
- {
- ///
- /// The requested Galaxy attribute reference does not resolve in the runtime.
- ///
- public const int MX_E_InvalidReference = 1008;
-
- ///
- /// The supplied value does not match the attribute's configured data type.
- ///
- public const int MX_E_WrongDataType = 1012;
-
- ///
- /// The target attribute cannot be written because it is read-only or protected.
- ///
- public const int MX_E_NotWritable = 1013;
-
- ///
- /// The runtime did not complete the operation within the configured timeout.
- ///
- public const int MX_E_RequestTimedOut = 1014;
-
- ///
- /// Communication with the MXAccess runtime failed during the operation.
- ///
- public const int MX_E_CommFailure = 1015;
-
- ///
- /// The operation was attempted without an active MXAccess session.
- ///
- public const int MX_E_NotConnected = 1016;
-
- ///
- /// Converts a numeric MXAccess error code into an operator-facing message.
- ///
- /// The MXAccess error code returned by the runtime.
- /// A human-readable description of the runtime failure.
- public static string GetMessage(int errorCode)
- {
- return errorCode switch
- {
- 1008 => "Invalid reference: the tag address does not exist or is malformed",
- 1012 => "Wrong data type: the value type does not match the attribute's expected type",
- 1013 => "Not writable: the attribute is read-only or locked",
- 1014 => "Request timed out: the operation did not complete within the allowed time",
- 1015 => "Communication failure: lost connection to the runtime",
- 1016 => "Not connected: no active connection to the Galaxy runtime",
- _ => $"Unknown MXAccess error code: {errorCode}"
- };
- }
-
- ///
- /// Maps an MXAccess error code to the OPC quality state that should be exposed to clients.
- ///
- /// The MXAccess error code returned by the runtime.
- /// The quality classification that best represents the runtime failure.
- public static Quality MapToQuality(int errorCode)
- {
- return errorCode switch
- {
- 1008 => Quality.BadConfigError,
- 1012 => Quality.BadConfigError,
- 1013 => Quality.BadOutOfService,
- 1014 => Quality.BadCommFailure,
- 1015 => Quality.BadCommFailure,
- 1016 => Quality.BadNotConnected,
- _ => Quality.Bad
- };
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/PlatformInfo.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/PlatformInfo.cs
deleted file mode 100644
index 4426c31..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/PlatformInfo.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Maps a deployed Galaxy platform to the hostname where it executes.
- ///
- public class PlatformInfo
- {
- ///
- /// Gets or sets the gobject_id of the platform object in the Galaxy repository.
- ///
- public int GobjectId { get; set; }
-
- ///
- /// Gets or sets the hostname (node_name) where the platform is deployed.
- ///
- public string NodeName { get; set; } = "";
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Quality.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Quality.cs
deleted file mode 100644
index 7d62704..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Quality.cs
+++ /dev/null
@@ -1,122 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// OPC DA quality codes mapped from MXAccess quality values. (MXA-009, OPC-005)
- ///
- public enum Quality : byte
- {
- // Bad family (0-63)
- ///
- /// No valid process value is available.
- ///
- Bad = 0,
-
- ///
- /// The value is invalid because the Galaxy attribute definition or mapping is wrong.
- ///
- BadConfigError = 4,
-
- ///
- /// The bridge is not currently connected to the Galaxy runtime.
- ///
- BadNotConnected = 8,
-
- ///
- /// The runtime device or adapter failed while obtaining the value.
- ///
- BadDeviceFailure = 12,
-
- ///
- /// The underlying field source reported a bad sensor condition.
- ///
- BadSensorFailure = 16,
-
- ///
- /// Communication with the runtime failed while retrieving the value.
- ///
- BadCommFailure = 20,
-
- ///
- /// The attribute is intentionally unavailable for service, such as a locked or unwritable value.
- ///
- BadOutOfService = 24,
-
- ///
- /// The bridge is still waiting for the first usable value after startup or resubscription.
- ///
- BadWaitingForInitialData = 32,
-
- // Uncertain family (64-191)
- ///
- /// A value is available, but it should be treated cautiously.
- ///
- Uncertain = 64,
-
- ///
- /// The last usable value is being repeated because a newer one is unavailable.
- ///
- UncertainLastUsable = 68,
-
- ///
- /// The sensor or source is providing a value with reduced accuracy.
- ///
- UncertainSensorNotAccurate = 80,
-
- ///
- /// The value exceeds its engineered limits.
- ///
- UncertainEuExceeded = 84,
-
- ///
- /// The source is operating in a degraded or subnormal state.
- ///
- UncertainSubNormal = 88,
-
- // Good family (192+)
- ///
- /// The value is current and suitable for normal client use.
- ///
- Good = 192,
-
- ///
- /// The value is good but currently overridden locally rather than flowing from the live source.
- ///
- GoodLocalOverride = 216
- }
-
- ///
- /// Helper methods for reasoning about OPC quality families used by the bridge.
- ///
- public static class QualityExtensions
- {
- ///
- /// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients.
- ///
- /// The quality code to inspect.
- /// when the value is in the good quality range; otherwise, .
- public static bool IsGood(this Quality q)
- {
- return (byte)q >= 192;
- }
-
- ///
- /// Determines whether the quality represents an uncertain runtime value that should be treated cautiously.
- ///
- /// The quality code to inspect.
- /// when the value is in the uncertain range; otherwise, .
- public static bool IsUncertain(this Quality q)
- {
- return (byte)q >= 64 && (byte)q < 192;
- }
-
- ///
- /// Determines whether the quality represents a bad runtime value that should not be used as valid process data.
- ///
- /// The quality code to inspect.
- /// when the value is in the bad range; otherwise, .
- public static bool IsBad(this Quality q)
- {
- return (byte)q < 64;
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/QualityMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/QualityMapper.cs
deleted file mode 100644
index 45c5e1c..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/QualityMapper.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using System;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Maps MXAccess integer quality to domain Quality enum and OPC UA StatusCodes. (MXA-009, OPC-005)
- ///
- public static class QualityMapper
- {
- ///
- /// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality.
- /// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad.
- ///
- /// The raw MXAccess quality integer.
- /// The mapped bridge quality value.
- public static Quality MapFromMxAccessQuality(int mxQuality)
- {
- var b = (byte)(mxQuality & 0xFF);
-
- // Try exact match first
- if (Enum.IsDefined(typeof(Quality), b))
- return (Quality)b;
-
- // Fall back to category
- if (b >= 192) return Quality.Good;
- if (b >= 64) return Quality.Uncertain;
- return Quality.Bad;
- }
-
- ///
- /// Maps domain Quality to OPC UA StatusCode uint32.
- ///
- /// The bridge quality value.
- /// The OPC UA status code represented as a 32-bit unsigned integer.
- public static uint MapToOpcUaStatusCode(Quality quality)
- {
- return quality switch
- {
- Quality.Good => 0x00000000u, // Good
- Quality.GoodLocalOverride => 0x00D80000u, // Good_LocalOverride
- Quality.Uncertain => 0x40000000u, // Uncertain
- Quality.UncertainLastUsable => 0x40900000u,
- Quality.UncertainSensorNotAccurate => 0x40930000u,
- Quality.UncertainEuExceeded => 0x40940000u,
- Quality.UncertainSubNormal => 0x40950000u,
- Quality.Bad => 0x80000000u, // Bad
- Quality.BadConfigError => 0x80890000u,
- Quality.BadNotConnected => 0x808A0000u,
- Quality.BadDeviceFailure => 0x808B0000u,
- Quality.BadSensorFailure => 0x808C0000u,
- Quality.BadCommFailure => 0x80050000u,
- Quality.BadOutOfService => 0x808D0000u,
- Quality.BadWaitingForInitialData => 0x80320000u,
- _ => quality.IsGood() ? 0x00000000u :
- quality.IsUncertain() ? 0x40000000u :
- 0x80000000u
- };
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/SecurityClassificationMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/SecurityClassificationMapper.cs
deleted file mode 100644
index b5a51c1..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/SecurityClassificationMapper.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Maps Galaxy security classification values to OPC UA write access decisions.
- /// See gr/data_type_mapping.md for the full mapping table.
- ///
- public static class SecurityClassificationMapper
- {
- ///
- /// Determines whether an attribute with the given security classification should allow writes.
- ///
- /// The Galaxy security classification value.
- ///
- /// for FreeAccess (0), Operate (1), Tune (4), Configure (5);
- /// for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6).
- ///
- public static bool IsWritable(int securityClassification)
- {
- switch (securityClassification)
- {
- case 2: // SecuredWrite
- case 3: // VerifiedWrite
- case 6: // ViewOnly
- return false;
- default:
- return true;
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Vtq.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Vtq.cs
deleted file mode 100644
index 79ac529..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Vtq.cs
+++ /dev/null
@@ -1,96 +0,0 @@
-using System;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Domain
-{
- ///
- /// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007)
- ///
- public readonly struct Vtq : IEquatable
- {
- ///
- /// Gets the runtime value returned for the Galaxy attribute.
- ///
- public object? Value { get; }
-
- ///
- /// Gets the timestamp associated with the runtime value.
- ///
- public DateTime Timestamp { get; }
-
- ///
- /// Gets the quality classification that tells OPC UA clients whether the value is usable.
- ///
- public Quality Quality { get; }
-
- ///
- /// Initializes a new instance of the struct for a Galaxy attribute value.
- ///
- /// The runtime value returned by MXAccess.
- /// The timestamp assigned to the runtime value.
- /// The quality classification for the runtime value.
- public Vtq(object? value, DateTime timestamp, Quality quality)
- {
- Value = value;
- Timestamp = timestamp;
- Quality = quality;
- }
-
- ///
- /// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value.
- ///
- /// The runtime value to wrap.
- /// A VTQ carrying the provided value with the current UTC timestamp and good quality.
- public static Vtq Good(object? value)
- {
- return new Vtq(value, DateTime.UtcNow, Quality.Good);
- }
-
- ///
- /// Creates a bad-quality VTQ snapshot when no usable runtime value is available.
- ///
- /// The specific bad quality reason to expose to clients.
- /// A VTQ with no value, the current UTC timestamp, and the requested bad quality.
- public static Vtq Bad(Quality quality = Quality.Bad)
- {
- return new Vtq(null, DateTime.UtcNow, quality);
- }
-
- ///
- /// Creates an uncertain VTQ snapshot when the runtime value exists but should be treated cautiously.
- ///
- /// The runtime value to wrap.
- /// A VTQ carrying the provided value with the current UTC timestamp and uncertain quality.
- public static Vtq Uncertain(object? value)
- {
- return new Vtq(value, DateTime.UtcNow, Quality.Uncertain);
- }
-
- ///
- /// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
- ///
- /// The other VTQ snapshot to compare.
- /// when all fields match; otherwise, .
- public bool Equals(Vtq other)
- {
- return Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
- }
-
- ///
- public override bool Equals(object? obj)
- {
- return obj is Vtq other && Equals(other);
- }
-
- ///
- public override int GetHashCode()
- {
- return HashCode.Combine(Value, Timestamp, Quality);
- }
-
- ///
- public override string ToString()
- {
- return $"Vtq({Value}, {Timestamp:O}, {Quality})";
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xml b/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xml
deleted file mode 100644
index e70d0c2..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
- ArchestrA.MxAccess
-
-
-
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xsd b/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xsd
deleted file mode 100644
index f2dbece..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xsd
+++ /dev/null
@@ -1,176 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks
-
-
-
-
- A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.
-
-
-
-
- A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks
-
-
-
-
- A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.
-
-
-
-
- Obsolete, use UnmanagedWinX86Assemblies instead
-
-
-
-
- A list of unmanaged X86 (32 bit) assembly names to include, delimited with line breaks.
-
-
-
-
- Obsolete, use UnmanagedWinX64Assemblies instead.
-
-
-
-
- A list of unmanaged X64 (64 bit) assembly names to include, delimited with line breaks.
-
-
-
-
- A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with line breaks.
-
-
-
-
- The order of preloaded assemblies, delimited with line breaks.
-
-
-
-
-
- This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file.
-
-
-
-
- Controls if .pdbs for reference assemblies are also embedded.
-
-
-
-
- Controls if runtime assemblies are also embedded.
-
-
-
-
- Controls whether the runtime assemblies are embedded with their full path or only with their assembly name.
-
-
-
-
- Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.
-
-
-
-
- As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.
-
-
-
-
- The attach method no longer subscribes to the `AppDomain.AssemblyResolve` (.NET 4.x) and `AssemblyLoadContext.Resolving` (.NET 6.0+) events.
-
-
-
-
- Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code.
-
-
-
-
- Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior.
-
-
-
-
- A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |
-
-
-
-
- A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.
-
-
-
-
- A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with |
-
-
-
-
- A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |.
-
-
-
-
- Obsolete, use UnmanagedWinX86Assemblies instead
-
-
-
-
- A list of unmanaged X86 (32 bit) assembly names to include, delimited with |.
-
-
-
-
- Obsolete, use UnmanagedWinX64Assemblies instead
-
-
-
-
- A list of unmanaged X64 (64 bit) assembly names to include, delimited with |.
-
-
-
-
- A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with |.
-
-
-
-
- The order of preloaded assemblies, delimited with |.
-
-
-
-
-
-
-
- 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.
-
-
-
-
- A comma-separated list of error codes that can be safely ignored in assembly verification.
-
-
-
-
- 'false' to turn off automatic generation of the XML Schema file.
-
-
-
-
-
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs b/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs
deleted file mode 100644
index f9e53ab..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs
+++ /dev/null
@@ -1,124 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
-{
- ///
- /// Polls the Galaxy database for deployment changes and fires OnGalaxyChanged. (GR-003, GR-004)
- ///
- public class ChangeDetectionService : IDisposable
- {
- private static readonly ILogger Log = Serilog.Log.ForContext();
- private readonly int _intervalSeconds;
-
- private readonly IGalaxyRepository _repository;
- private CancellationTokenSource? _cts;
- private Task? _pollTask;
-
- ///
- /// Initializes a new change detector for Galaxy deploy timestamps.
- ///
- /// The repository used to query the latest deploy timestamp.
- /// The polling interval, in seconds, between deploy checks.
- /// An optional deploy timestamp already known at service startup.
- public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds,
- DateTime? initialDeployTime = null)
- {
- _repository = repository;
- _intervalSeconds = intervalSeconds;
- LastKnownDeployTime = initialDeployTime;
- }
-
- ///
- /// Gets the last deploy timestamp observed by the polling loop.
- ///
- public DateTime? LastKnownDeployTime { get; private set; }
-
- ///
- /// Stops the polling loop and disposes the underlying cancellation resources.
- ///
- public void Dispose()
- {
- Stop();
- _cts?.Dispose();
- }
-
- ///
- /// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt.
- ///
- public event Action? OnGalaxyChanged;
-
- ///
- /// Starts the background polling loop that watches for Galaxy deploy changes.
- ///
- public void Start()
- {
- if (_cts != null)
- Stop();
-
- _cts = new CancellationTokenSource();
- _pollTask = Task.Run(() => PollLoopAsync(_cts.Token));
- Log.Information("Change detection started (interval={Interval}s)", _intervalSeconds);
- }
-
- ///
- /// Stops the background polling loop.
- ///
- public void Stop()
- {
- _cts?.Cancel();
- try { _pollTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
- _pollTask = null;
- Log.Information("Change detection stopped");
- }
-
- private async Task PollLoopAsync(CancellationToken ct)
- {
- // If no initial deploy time was provided, first poll triggers unconditionally
- var firstPoll = LastKnownDeployTime == null;
-
- while (!ct.IsCancellationRequested)
- {
- try
- {
- var deployTime = await _repository.GetLastDeployTimeAsync(ct);
-
- if (firstPoll)
- {
- firstPoll = false;
- LastKnownDeployTime = deployTime;
- Log.Information("Initial deploy time: {DeployTime}", deployTime);
- OnGalaxyChanged?.Invoke();
- }
- else if (deployTime != LastKnownDeployTime)
- {
- Log.Information("Galaxy deployment change detected: {Previous} → {Current}",
- LastKnownDeployTime, deployTime);
- LastKnownDeployTime = deployTime;
- OnGalaxyChanged?.Invoke();
- }
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Change detection poll failed, will retry next interval");
- }
-
- try
- {
- await Task.Delay(TimeSpan.FromSeconds(_intervalSeconds), ct);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs b/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs
deleted file mode 100644
index 4ca0e15..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs
+++ /dev/null
@@ -1,529 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Data.SqlClient;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Configuration;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
-{
- ///
- /// Implements IGalaxyRepository using SQL queries against the Galaxy ZB database. (GR-001 through GR-007)
- ///
- public class GalaxyRepositoryService : IGalaxyRepository
- {
- private static readonly ILogger Log = Serilog.Log.ForContext();
-
- private readonly GalaxyRepositoryConfiguration _config;
-
- ///
- /// When filtering is active, caches the set of
- /// gobject_ids that passed the hierarchy filter so can apply the same scope.
- /// Populated by and consumed by .
- ///
- private HashSet? _scopeFilteredGobjectIds;
-
- ///
- /// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
- ///
- /// The repository connection, timeout, and attribute-selection settings.
- public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
- {
- _config = config;
- }
-
- ///
- /// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
- ///
- public event Action? OnGalaxyChanged;
-
- ///
- /// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree.
- ///
- /// A token that cancels the database query.
- /// The deployed Galaxy objects that should appear in the namespace.
- public async Task> GetHierarchyAsync(CancellationToken ct = default)
- {
- var results = new List();
-
- using var conn = new SqlConnection(_config.ConnectionString);
- await conn.OpenAsync(ct);
-
- using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
- using var reader = await cmd.ExecuteReaderAsync(ct);
-
- while (await reader.ReadAsync(ct))
- {
- var templateChainRaw = reader.IsDBNull(8) ? "" : reader.GetString(8);
- var templateChain = string.IsNullOrEmpty(templateChainRaw)
- ? new List()
- : templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
- .Select(s => s.Trim())
- .Where(s => s.Length > 0)
- .ToList();
-
- results.Add(new GalaxyObjectInfo
- {
- GobjectId = Convert.ToInt32(reader.GetValue(0)),
- TagName = reader.GetString(1),
- ContainedName = reader.IsDBNull(2) ? "" : reader.GetString(2),
- BrowseName = reader.GetString(3),
- ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
- IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
- CategoryId = Convert.ToInt32(reader.GetValue(6)),
- HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
- TemplateChain = templateChain
- });
- }
-
- if (results.Count == 0)
- Log.Warning("GetHierarchyAsync returned zero rows");
- else
- Log.Information("GetHierarchyAsync returned {Count} objects", results.Count);
-
- if (_config.Scope == GalaxyScope.LocalPlatform)
- {
- var platforms = await GetPlatformsAsync(ct);
- var platformName = string.IsNullOrWhiteSpace(_config.PlatformName)
- ? Environment.MachineName
- : _config.PlatformName;
- var (filtered, gobjectIds) = PlatformScopeFilter.Filter(results, platforms, platformName);
- _scopeFilteredGobjectIds = gobjectIds;
- return filtered;
- }
-
- _scopeFilteredGobjectIds = null;
- return results;
- }
-
- ///
- /// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes.
- ///
- /// A token that cancels the database query.
- /// The attribute rows required to build runtime tag mappings and variable metadata.
- public async Task> GetAttributesAsync(CancellationToken ct = default)
- {
- var results = new List();
- var extended = _config.ExtendedAttributes;
- var sql = extended ? ExtendedAttributesSql : AttributesSql;
-
- using var conn = new SqlConnection(_config.ConnectionString);
- await conn.OpenAsync(ct);
-
- using var cmd = new SqlCommand(sql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
- using var reader = await cmd.ExecuteReaderAsync(ct);
-
- while (await reader.ReadAsync(ct))
- results.Add(extended ? ReadExtendedAttribute(reader) : ReadStandardAttribute(reader));
-
- Log.Information("GetAttributesAsync returned {Count} attributes (extended={Extended})", results.Count,
- extended);
-
- if (_config.Scope == GalaxyScope.LocalPlatform && _scopeFilteredGobjectIds != null)
- return PlatformScopeFilter.FilterAttributes(results, _scopeFilteredGobjectIds);
-
- return results;
- }
-
- ///
- /// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale.
- ///
- /// A token that cancels the database query.
- /// The most recent deploy timestamp, or when none is available.
- public async Task GetLastDeployTimeAsync(CancellationToken ct = default)
- {
- using var conn = new SqlConnection(_config.ConnectionString);
- await conn.OpenAsync(ct);
-
- using var cmd = new SqlCommand(ChangeDetectionSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
- var result = await cmd.ExecuteScalarAsync(ct);
-
- return result is DateTime dt ? dt : null;
- }
-
- ///
- /// Executes a lightweight query to confirm that the repository database is reachable.
- ///
- /// A token that cancels the connectivity check.
- /// when the query succeeds; otherwise, .
- public async Task TestConnectionAsync(CancellationToken ct = default)
- {
- try
- {
- using var conn = new SqlConnection(_config.ConnectionString);
- await conn.OpenAsync(ct);
-
- using var cmd = new SqlCommand(TestConnectionSql, conn)
- { CommandTimeout = _config.CommandTimeoutSeconds };
- await cmd.ExecuteScalarAsync(ct);
-
- Log.Information("Galaxy repository database connection successful");
- return true;
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Galaxy repository database connection failed");
- return false;
- }
- }
-
- ///
- /// Queries the platform table for deployed platform-to-hostname mappings used by
- /// filtering.
- ///
- private async Task> GetPlatformsAsync(CancellationToken ct = default)
- {
- var results = new List();
-
- using var conn = new SqlConnection(_config.ConnectionString);
- await conn.OpenAsync(ct);
-
- using var cmd = new SqlCommand(PlatformLookupSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
- using var reader = await cmd.ExecuteReaderAsync(ct);
-
- while (await reader.ReadAsync(ct))
- {
- results.Add(new PlatformInfo
- {
- GobjectId = Convert.ToInt32(reader.GetValue(0)),
- NodeName = reader.IsDBNull(1) ? "" : reader.GetString(1)
- });
- }
-
- Log.Information("GetPlatformsAsync returned {Count} platform(s)", results.Count);
- return results;
- }
-
- ///
- /// Reads a row from the standard attributes query (12 columns).
- /// Columns: gobject_id, tag_name, attribute_name, full_tag_reference, mx_data_type,
- /// data_type_name, is_array, array_dimension, mx_attribute_category,
- /// security_classification, is_historized, is_alarm
- ///
- private static GalaxyAttributeInfo ReadStandardAttribute(SqlDataReader reader)
- {
- return new GalaxyAttributeInfo
- {
- GobjectId = Convert.ToInt32(reader.GetValue(0)),
- TagName = reader.GetString(1),
- AttributeName = reader.GetString(2),
- FullTagReference = reader.GetString(3),
- MxDataType = Convert.ToInt32(reader.GetValue(4)),
- DataTypeName = reader.IsDBNull(5) ? "" : reader.GetString(5),
- IsArray = Convert.ToBoolean(reader.GetValue(6)),
- ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)),
- SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
- IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
- IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1
- };
- }
-
- ///
- /// Reads a row from the extended attributes query (14 columns).
- /// Columns: gobject_id, tag_name, primitive_name, attribute_name, full_tag_reference,
- /// mx_data_type, data_type_name, is_array, array_dimension,
- /// mx_attribute_category, security_classification, is_historized, is_alarm, attribute_source
- ///
- private static GalaxyAttributeInfo ReadExtendedAttribute(SqlDataReader reader)
- {
- return new GalaxyAttributeInfo
- {
- GobjectId = Convert.ToInt32(reader.GetValue(0)),
- TagName = reader.GetString(1),
- PrimitiveName = reader.IsDBNull(2) ? "" : reader.GetString(2),
- AttributeName = reader.GetString(3),
- FullTagReference = reader.GetString(4),
- MxDataType = Convert.ToInt32(reader.GetValue(5)),
- DataTypeName = reader.IsDBNull(6) ? "" : reader.GetString(6),
- IsArray = Convert.ToBoolean(reader.GetValue(7)),
- ArrayDimension = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)),
- SecurityClassification = Convert.ToInt32(reader.GetValue(10)),
- IsHistorized = Convert.ToInt32(reader.GetValue(11)) == 1,
- IsAlarm = Convert.ToInt32(reader.GetValue(12)) == 1,
- AttributeSource = reader.IsDBNull(13) ? "" : reader.GetString(13)
- };
- }
-
- ///
- /// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy.
- ///
- public void RaiseGalaxyChanged()
- {
- OnGalaxyChanged?.Invoke();
- }
-
- #region SQL Queries (GR-006: const string, no dynamic SQL)
-
- private const string HierarchySql = @"
-;WITH template_chain AS (
- SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
- t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
- FROM gobject g
- INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
- WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
- UNION ALL
- SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
- FROM template_chain tc
- INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
- WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
-)
-SELECT DISTINCT
- g.gobject_id,
- g.tag_name,
- g.contained_name,
- CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
- THEN g.tag_name
- ELSE g.contained_name
- END AS browse_name,
- CASE WHEN g.contained_by_gobject_id = 0
- THEN g.area_gobject_id
- ELSE g.contained_by_gobject_id
- END AS parent_gobject_id,
- CASE WHEN td.category_id = 13
- THEN 1
- ELSE 0
- END AS is_area,
- td.category_id AS category_id,
- g.hosted_by_gobject_id AS hosted_by_gobject_id,
- ISNULL(
- STUFF((
- SELECT '|' + tc.template_tag_name
- FROM template_chain tc
- WHERE tc.instance_gobject_id = g.gobject_id
- ORDER BY tc.depth
- FOR XML PATH('')
- ), 1, 1, ''),
- ''
- ) AS template_chain
-FROM gobject g
-INNER JOIN template_definition td
- ON g.template_definition_id = td.template_definition_id
-WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
- AND g.is_template = 0
- AND g.deployed_package_id <> 0
-ORDER BY parent_gobject_id, g.tag_name";
-
- private const string AttributesSql = @"
-;WITH deployed_package_chain AS (
- SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
- FROM gobject g
- INNER JOIN package p ON p.package_id = g.deployed_package_id
- WHERE g.is_template = 0 AND g.deployed_package_id <> 0
- UNION ALL
- SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
- FROM deployed_package_chain dpc
- INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
- WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
-)
-SELECT gobject_id, tag_name, attribute_name, full_tag_reference,
- mx_data_type, data_type_name, is_array, array_dimension,
- mx_attribute_category, security_classification, is_historized, is_alarm
-FROM (
- SELECT
- dpc.gobject_id,
- g.tag_name,
- da.attribute_name,
- g.tag_name + '.' + da.attribute_name
- + CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
- AS full_tag_reference,
- da.mx_data_type,
- dt.description AS data_type_name,
- da.is_array,
- CASE WHEN da.is_array = 1
- THEN CONVERT(int, CONVERT(varbinary(2),
- SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
- ELSE NULL
- END AS array_dimension,
- da.mx_attribute_category,
- da.security_classification,
- CASE WHEN EXISTS (
- SELECT 1 FROM deployed_package_chain dpc2
- INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
- INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
- WHERE dpc2.gobject_id = dpc.gobject_id
- ) THEN 1 ELSE 0 END AS is_historized,
- CASE WHEN EXISTS (
- SELECT 1 FROM deployed_package_chain dpc2
- INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
- INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
- WHERE dpc2.gobject_id = dpc.gobject_id
- ) THEN 1 ELSE 0 END AS is_alarm,
- ROW_NUMBER() OVER (
- PARTITION BY dpc.gobject_id, da.attribute_name
- ORDER BY dpc.depth
- ) AS rn
- FROM deployed_package_chain dpc
- INNER JOIN dynamic_attribute da
- ON da.package_id = dpc.package_id
- INNER JOIN gobject g
- ON g.gobject_id = dpc.gobject_id
- INNER JOIN template_definition td
- ON td.template_definition_id = g.template_definition_id
- LEFT JOIN data_type dt
- ON dt.mx_data_type = da.mx_data_type
- WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
- AND da.attribute_name NOT LIKE '[_]%'
- AND da.attribute_name NOT LIKE '%.Description'
- AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
-) ranked
-WHERE rn = 1
-ORDER BY tag_name, attribute_name";
-
- private const string ExtendedAttributesSql = @"
-;WITH deployed_package_chain AS (
- SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
- FROM gobject g
- INNER JOIN package p ON p.package_id = g.deployed_package_id
- WHERE g.is_template = 0 AND g.deployed_package_id <> 0
- UNION ALL
- SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
- FROM deployed_package_chain dpc
- INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
- WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
-),
-ranked_dynamic AS (
- SELECT
- dpc.gobject_id,
- g.tag_name,
- da.attribute_name,
- g.tag_name + '.' + da.attribute_name
- + CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
- AS full_tag_reference,
- da.mx_data_type,
- dt.description AS data_type_name,
- da.is_array,
- CASE WHEN da.is_array = 1
- THEN CONVERT(int, CONVERT(varbinary(2),
- SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
- ELSE NULL
- END AS array_dimension,
- da.mx_attribute_category,
- da.security_classification,
- CASE WHEN EXISTS (
- SELECT 1 FROM deployed_package_chain dpc2
- INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
- INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
- WHERE dpc2.gobject_id = dpc.gobject_id
- ) THEN 1 ELSE 0 END AS is_historized,
- CASE WHEN EXISTS (
- SELECT 1 FROM deployed_package_chain dpc2
- INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
- INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
- WHERE dpc2.gobject_id = dpc.gobject_id
- ) THEN 1 ELSE 0 END AS is_alarm,
- ROW_NUMBER() OVER (
- PARTITION BY dpc.gobject_id, da.attribute_name
- ORDER BY dpc.depth
- ) AS rn
- FROM deployed_package_chain dpc
- INNER JOIN dynamic_attribute da
- ON da.package_id = dpc.package_id
- INNER JOIN gobject g
- ON g.gobject_id = dpc.gobject_id
- INNER JOIN template_definition td
- ON td.template_definition_id = g.template_definition_id
- LEFT JOIN data_type dt
- ON dt.mx_data_type = da.mx_data_type
- WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
- AND da.attribute_name NOT LIKE '[_]%'
- AND da.attribute_name NOT LIKE '%.Description'
- AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
-)
-SELECT
- gobject_id,
- tag_name,
- primitive_name,
- attribute_name,
- full_tag_reference,
- mx_data_type,
- data_type_name,
- is_array,
- array_dimension,
- mx_attribute_category,
- security_classification,
- is_historized,
- is_alarm,
- attribute_source
-FROM (
- SELECT
- g.gobject_id,
- g.tag_name,
- pi.primitive_name,
- ad.attribute_name,
- CASE WHEN pi.primitive_name = ''
- THEN g.tag_name + '.' + ad.attribute_name
- ELSE g.tag_name + '.' + pi.primitive_name + '.' + ad.attribute_name
- END + CASE WHEN ad.is_array = 1 THEN '[]' ELSE '' END
- AS full_tag_reference,
- ad.mx_data_type,
- dt.description AS data_type_name,
- ad.is_array,
- CASE WHEN ad.is_array = 1
- THEN CONVERT(int, CONVERT(varbinary(2),
- SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2))
- ELSE NULL
- END AS array_dimension,
- ad.mx_attribute_category,
- ad.security_classification,
- CAST(0 AS int) AS is_historized,
- CAST(0 AS int) AS is_alarm,
- 'primitive' AS attribute_source
- FROM gobject g
- INNER JOIN instance i
- ON i.gobject_id = g.gobject_id
- INNER JOIN template_definition td
- ON td.template_definition_id = g.template_definition_id
- AND td.runtime_clsid <> '{00000000-0000-0000-0000-000000000000}'
- INNER JOIN package p
- ON p.package_id = g.deployed_package_id
- INNER JOIN primitive_instance pi
- ON pi.package_id = p.package_id
- AND pi.property_bitmask & 0x10 <> 0x10
- INNER JOIN attribute_definition ad
- ON ad.primitive_definition_id = pi.primitive_definition_id
- AND ad.attribute_name NOT LIKE '[_]%'
- AND ad.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
- LEFT JOIN data_type dt
- ON dt.mx_data_type = ad.mx_data_type
- WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
- AND g.is_template = 0
- AND g.deployed_package_id <> 0
-
- UNION ALL
-
- SELECT
- gobject_id,
- tag_name,
- '' AS primitive_name,
- attribute_name,
- full_tag_reference,
- mx_data_type,
- data_type_name,
- is_array,
- array_dimension,
- mx_attribute_category,
- security_classification,
- is_historized,
- is_alarm,
- 'dynamic' AS attribute_source
- FROM ranked_dynamic
- WHERE rn = 1
-) all_attributes
-ORDER BY tag_name, primitive_name, attribute_name";
-
- private const string PlatformLookupSql = @"
-SELECT p.platform_gobject_id, p.node_name
-FROM platform p
-INNER JOIN gobject g ON g.gobject_id = p.platform_gobject_id
-WHERE g.is_template = 0 AND g.deployed_package_id <> 0";
-
- private const string ChangeDetectionSql = "SELECT time_of_last_deploy FROM galaxy";
-
- private const string TestConnectionSql = "SELECT 1";
-
- #endregion
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs b/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs
deleted file mode 100644
index 62fa1f1..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using System;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
-{
- ///
- /// POCO for dashboard: Galaxy repository status info. (DASH-009)
- ///
- public class GalaxyRepositoryStats
- {
- ///
- /// Gets or sets the Galaxy name currently being represented by the bridge.
- ///
- public string GalaxyName { get; set; } = "";
-
- ///
- /// Gets or sets a value indicating whether the Galaxy repository database is reachable.
- ///
- public bool DbConnected { get; set; }
-
- ///
- /// Gets or sets the latest deploy timestamp read from the Galaxy repository.
- ///
- public DateTime? LastDeployTime { get; set; }
-
- ///
- /// Gets or sets the number of Galaxy objects currently published into the OPC UA address space.
- ///
- public int ObjectCount { get; set; }
-
- ///
- /// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space.
- ///
- public int AttributeCount { get; set; }
-
- ///
- /// Gets or sets the UTC time when the address space was last rebuilt from repository data.
- ///
- public DateTime? LastRebuildTime { get; set; }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs b/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs
deleted file mode 100644
index 4d7ab01..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs
+++ /dev/null
@@ -1,124 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
-{
- ///
- /// Filters a Galaxy object hierarchy to retain only objects hosted by a specific platform
- /// and the structural areas needed to keep the browse tree connected.
- ///
- public static class PlatformScopeFilter
- {
- private static readonly ILogger Log = Serilog.Log.ForContext(typeof(PlatformScopeFilter));
-
- private const int CategoryWinPlatform = 1;
- private const int CategoryAppEngine = 3;
-
- ///
- /// Filters the hierarchy to objects hosted by the platform whose node_name matches
- /// , plus ancestor areas that keep the tree connected.
- ///
- /// The full Galaxy object hierarchy.
- /// Deployed platform-to-hostname mappings from the platform table.
- /// The target hostname to match (case-insensitive).
- ///
- /// The filtered hierarchy and the set of included gobject_ids (for attribute filtering).
- /// When no matching platform is found, returns an empty list and empty set.
- ///
- public static (List Hierarchy, HashSet GobjectIds) Filter(
- List hierarchy,
- List platforms,
- string platformName)
- {
- // Find the platform gobject_id that matches the target hostname.
- var matchingPlatform = platforms.FirstOrDefault(
- p => string.Equals(p.NodeName, platformName, StringComparison.OrdinalIgnoreCase));
-
- if (matchingPlatform == null)
- {
- Log.Warning(
- "Scope filter found no deployed platform matching node name '{PlatformName}'; " +
- "available platforms: [{Available}]",
- platformName,
- string.Join(", ", platforms.Select(p => $"{p.NodeName} (gobject_id={p.GobjectId})")));
- return (new List(), new HashSet());
- }
-
- var platformGobjectId = matchingPlatform.GobjectId;
- Log.Information(
- "Scope filter targeting platform '{PlatformName}' (gobject_id={GobjectId})",
- platformName, platformGobjectId);
-
- // Build a lookup for the hierarchy by gobject_id.
- var byId = hierarchy.ToDictionary(o => o.GobjectId);
-
- // Step 1: Collect all host gobject_ids under this platform.
- // Walk outward from the platform to find AppEngines (and any deeper hosting objects).
- var hostIds = new HashSet { platformGobjectId };
- bool changed;
- do
- {
- changed = false;
- foreach (var obj in hierarchy)
- {
- if (hostIds.Contains(obj.GobjectId))
- continue;
- if (obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId)
- && (obj.CategoryId == CategoryAppEngine || obj.CategoryId == CategoryWinPlatform))
- {
- hostIds.Add(obj.GobjectId);
- changed = true;
- }
- }
- } while (changed);
-
- // Step 2: Include all non-area objects hosted by any host in the set, plus the hosts themselves.
- var includedIds = new HashSet(hostIds);
- foreach (var obj in hierarchy)
- {
- if (includedIds.Contains(obj.GobjectId))
- continue;
- if (!obj.IsArea && obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId))
- includedIds.Add(obj.GobjectId);
- }
-
- // Step 3: Walk ParentGobjectId chains upward to include ancestor areas so the tree stays connected.
- var toWalk = new Queue(includedIds);
- while (toWalk.Count > 0)
- {
- var id = toWalk.Dequeue();
- if (!byId.TryGetValue(id, out var obj))
- continue;
- var parentId = obj.ParentGobjectId;
- if (parentId != 0 && byId.ContainsKey(parentId) && includedIds.Add(parentId))
- toWalk.Enqueue(parentId);
- }
-
- // Step 4: Return the filtered hierarchy preserving original order.
- var filtered = hierarchy.Where(o => includedIds.Contains(o.GobjectId)).ToList();
-
- Log.Information(
- "Scope filter retained {FilteredCount} of {TotalCount} objects for platform '{PlatformName}'",
- filtered.Count, hierarchy.Count, platformName);
-
- return (filtered, includedIds);
- }
-
- ///
- /// Filters attributes to retain only those belonging to objects in the given set.
- ///
- public static List FilterAttributes(
- List attributes,
- HashSet gobjectIds)
- {
- var filtered = attributes.Where(a => gobjectIds.Contains(a.GobjectId)).ToList();
- Log.Information(
- "Scope filter retained {FilteredCount} of {TotalCount} attributes",
- filtered.Count, attributes.Count);
- return filtered;
- }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianAggregateMap.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianAggregateMap.cs
deleted file mode 100644
index 0405917..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianAggregateMap.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using Opc.Ua;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Historian
-{
- ///
- /// Maps OPC UA aggregate NodeIds to the Wonderware Historian AnalogSummary column names
- /// consumed by the historian plugin. Kept in Host so HistoryReadProcessed can validate
- /// aggregate support without requiring the plugin to be loaded.
- ///
- public static class HistorianAggregateMap
- {
- public static string? MapAggregateToColumn(NodeId aggregateId)
- {
- if (aggregateId == ObjectIds.AggregateFunction_Average)
- return "Average";
- if (aggregateId == ObjectIds.AggregateFunction_Minimum)
- return "Minimum";
- if (aggregateId == ObjectIds.AggregateFunction_Maximum)
- return "Maximum";
- if (aggregateId == ObjectIds.AggregateFunction_Count)
- return "ValueCount";
- if (aggregateId == ObjectIds.AggregateFunction_Start)
- return "First";
- if (aggregateId == ObjectIds.AggregateFunction_End)
- return "Last";
- if (aggregateId == ObjectIds.AggregateFunction_StandardDeviationPopulation)
- return "StdDev";
- return null;
- }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianClusterNodeState.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianClusterNodeState.cs
deleted file mode 100644
index efa655a..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianClusterNodeState.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using System;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Historian
-{
- ///
- /// Point-in-time state of a single historian cluster node. One entry per configured node is
- /// surfaced inside so the status dashboard can render
- /// per-node health and operators can see which nodes are in cooldown.
- ///
- public sealed class HistorianClusterNodeState
- {
- ///
- /// Gets or sets the configured node hostname exactly as it appears in
- /// HistorianConfiguration.ServerNames.
- ///
- public string Name { get; set; } = "";
-
- ///
- /// Gets or sets a value indicating whether the node is currently eligible for new connection
- /// attempts. means the node is in its post-failure cooldown window
- /// and the picker is skipping it.
- ///
- public bool IsHealthy { get; set; }
-
- ///
- /// Gets or sets the UTC timestamp at which the node's cooldown expires, or
- /// when the node is not in cooldown.
- ///
- public DateTime? CooldownUntil { get; set; }
-
- ///
- /// Gets or sets the number of times this node has transitioned from healthy to failed
- /// since startup. Does not decrement on recovery.
- ///
- public int FailureCount { get; set; }
-
- ///
- /// Gets or sets the message from the most recent failure, or when
- /// the node has never failed.
- ///
- public string? LastError { get; set; }
-
- ///
- /// Gets or sets the UTC timestamp of the most recent failure, or
- /// when the node has never failed.
- ///
- public DateTime? LastFailureTime { get; set; }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianEventDto.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianEventDto.cs
deleted file mode 100644
index f1dfa11..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianEventDto.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Historian
-{
- ///
- /// SDK-free representation of a Historian event record exposed by the historian plugin.
- /// Prevents ArchestrA types from leaking into the Host assembly.
- ///
- public sealed class HistorianEventDto
- {
- public Guid Id { get; set; }
- public string? Source { get; set; }
- public DateTime EventTime { get; set; }
- public DateTime ReceivedTime { get; set; }
- public string? DisplayText { get; set; }
- public ushort Severity { get; set; }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianHealthSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianHealthSnapshot.cs
deleted file mode 100644
index c37cb9b..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianHealthSnapshot.cs
+++ /dev/null
@@ -1,97 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Historian
-{
- ///
- /// Point-in-time runtime health of the historian plugin, surfaced to the status dashboard
- /// and health check service. Fills the gap between the load-time plugin status
- /// () and actual query behavior so operators
- /// can detect silent query degradation.
- ///
- public sealed class HistorianHealthSnapshot
- {
- ///
- /// Gets or sets the total number of historian read operations attempted since startup
- /// across all read paths (raw, aggregate, at-time, events).
- ///
- public long TotalQueries { get; set; }
-
- ///
- /// Gets or sets the total number of read operations that completed without an exception
- /// being caught by the plugin's error handler. Includes empty result sets as successes —
- /// the counter reflects "the SDK call returned" not "the SDK call returned data".
- ///
- public long TotalSuccesses { get; set; }
-
- ///
- /// Gets or sets the total number of read operations that raised an exception. Each failure
- /// also resets and closes the underlying SDK connection via the existing reconnect path.
- ///
- public long TotalFailures { get; set; }
-
- ///
- /// Gets or sets the number of consecutive failures since the last success. Latches until
- /// a successful query clears it. The health check service uses this as a degradation signal.
- ///
- public int ConsecutiveFailures { get; set; }
-
- ///
- /// Gets or sets the UTC timestamp of the last successful read, or
- /// when no query has succeeded since startup.
- ///
- public DateTime? LastSuccessTime { get; set; }
-
- ///
- /// Gets or sets the UTC timestamp of the last failure, or when no
- /// query has failed since startup.
- ///
- public DateTime? LastFailureTime { get; set; }
-
- ///
- /// Gets or sets the exception message from the most recent failure. Cleared on the next
- /// successful query.
- ///
- public string? LastError { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the plugin currently holds an open SDK
- /// connection for the process (historical values) path.
- ///
- public bool ProcessConnectionOpen { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the plugin currently holds an open SDK
- /// connection for the event (alarm history) path.
- ///
- public bool EventConnectionOpen { get; set; }
-
- ///
- /// Gets or sets the node the plugin is currently connected to for the process path,
- /// or when no connection is open.
- ///
- public string? ActiveProcessNode { get; set; }
-
- ///
- /// Gets or sets the node the plugin is currently connected to for the event path,
- /// or when no event connection is open.
- ///
- public string? ActiveEventNode { get; set; }
-
- ///
- /// Gets or sets the total number of configured historian cluster nodes. A value of 1
- /// reflects a legacy single-node deployment.
- ///
- public int NodeCount { get; set; }
-
- ///
- /// Gets or sets the number of configured nodes that are currently healthy (not in cooldown).
- ///
- public int HealthyNodeCount { get; set; }
-
- ///
- /// Gets or sets the per-node cluster state in configuration order.
- ///
- public List Nodes { get; set; } = new();
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianPluginLoader.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianPluginLoader.cs
deleted file mode 100644
index e4e13f4..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianPluginLoader.cs
+++ /dev/null
@@ -1,180 +0,0 @@
-using System;
-using System.IO;
-using System.Reflection;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Configuration;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Historian
-{
- ///
- /// Result of the most recent historian plugin load attempt.
- ///
- public enum HistorianPluginStatus
- {
- /// Historian.Enabled is false; TryLoad was not called.
- Disabled,
- /// Plugin DLL was not present in the Historian/ subfolder.
- NotFound,
- /// Plugin file exists but could not be loaded or instantiated.
- LoadFailed,
- /// Plugin loaded and an IHistorianDataSource was constructed.
- Loaded
- }
-
- ///
- /// Structured outcome of a or
- /// call, used by the status dashboard.
- ///
- public sealed class HistorianPluginOutcome
- {
- public HistorianPluginOutcome(HistorianPluginStatus status, string pluginPath, string? error)
- {
- Status = status;
- PluginPath = pluginPath;
- Error = error;
- }
-
- public HistorianPluginStatus Status { get; }
- public string PluginPath { get; }
- public string? Error { get; }
- }
-
- ///
- /// Loads the Wonderware historian plugin assembly from the Historian/ subfolder next to
- /// the host executable. Used so the aahClientManaged SDK is not needed on hosts that run
- /// with Historian.Enabled=false.
- ///
- public static class HistorianPluginLoader
- {
- private const string PluginSubfolder = "Historian";
- private const string PluginAssemblyName = "ZB.MOM.WW.OtOpcUa.Historian.Aveva";
- private const string PluginEntryType = "ZB.MOM.WW.OtOpcUa.Historian.Aveva.AvevaHistorianPluginEntry";
- private const string PluginEntryMethod = "Create";
-
- private static readonly ILogger Log = Serilog.Log.ForContext(typeof(HistorianPluginLoader));
- private static readonly object ResolverGate = new object();
- private static bool _resolverInstalled;
- private static string? _resolvedProbeDirectory;
-
- ///
- /// Gets the outcome of the most recent load attempt (or
- /// if the loader has never been invoked). The dashboard reads this to distinguish "disabled",
- /// "plugin missing", and "plugin crashed".
- ///
- public static HistorianPluginOutcome LastOutcome { get; private set; }
- = new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
-
- ///
- /// Records that the historian plugin is disabled by configuration. Called by
- /// OpcUaService when Historian.Enabled=false so the status dashboard can
- /// report the exact reason history is unavailable.
- ///
- public static void MarkDisabled()
- {
- LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
- }
-
- ///
- /// Attempts to load the historian plugin and construct an .
- /// Returns null on any failure so the server can continue with history unsupported. The
- /// specific reason is published on .
- ///
- public static IHistorianDataSource? TryLoad(HistorianConfiguration config)
- {
- var pluginDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, PluginSubfolder);
- var pluginPath = Path.Combine(pluginDirectory, PluginAssemblyName + ".dll");
-
- if (!File.Exists(pluginPath))
- {
- Log.Warning(
- "Historian plugin not found at {PluginPath} — history read operations will return BadHistoryOperationUnsupported",
- pluginPath);
- LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.NotFound, pluginPath, null);
- return null;
- }
-
- EnsureAssemblyResolverInstalled(pluginDirectory);
-
- try
- {
- var assembly = Assembly.LoadFrom(pluginPath);
- var entryType = assembly.GetType(PluginEntryType, throwOnError: false);
- if (entryType == null)
- {
- Log.Warning("Historian plugin {PluginPath} does not expose {EntryType}", pluginPath, PluginEntryType);
- LastOutcome = new HistorianPluginOutcome(
- HistorianPluginStatus.LoadFailed, pluginPath,
- $"Plugin assembly does not expose entry type {PluginEntryType}");
- return null;
- }
-
- var create = entryType.GetMethod(PluginEntryMethod, BindingFlags.Public | BindingFlags.Static);
- if (create == null)
- {
- Log.Warning("Historian plugin entry type {EntryType} missing static {Method}", PluginEntryType, PluginEntryMethod);
- LastOutcome = new HistorianPluginOutcome(
- HistorianPluginStatus.LoadFailed, pluginPath,
- $"Plugin entry type {PluginEntryType} is missing a public static {PluginEntryMethod} method");
- return null;
- }
-
- var result = create.Invoke(null, new object[] { config });
- if (result is IHistorianDataSource dataSource)
- {
- Log.Information("Historian plugin loaded from {PluginPath}", pluginPath);
- LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Loaded, pluginPath, null);
- return dataSource;
- }
-
- Log.Warning("Historian plugin {PluginPath} returned an object that does not implement IHistorianDataSource", pluginPath);
- LastOutcome = new HistorianPluginOutcome(
- HistorianPluginStatus.LoadFailed, pluginPath,
- "Plugin entry method returned an object that does not implement IHistorianDataSource");
- return null;
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Failed to load historian plugin from {PluginPath} — history disabled", pluginPath);
- LastOutcome = new HistorianPluginOutcome(
- HistorianPluginStatus.LoadFailed, pluginPath,
- ex.GetBaseException().Message);
- return null;
- }
- }
-
- private static void EnsureAssemblyResolverInstalled(string pluginDirectory)
- {
- lock (ResolverGate)
- {
- _resolvedProbeDirectory = pluginDirectory;
- if (_resolverInstalled)
- return;
-
- AppDomain.CurrentDomain.AssemblyResolve += ResolveFromPluginDirectory;
- _resolverInstalled = true;
- }
- }
-
- private static Assembly? ResolveFromPluginDirectory(object? sender, ResolveEventArgs args)
- {
- var probeDirectory = _resolvedProbeDirectory;
- if (string.IsNullOrEmpty(probeDirectory))
- return null;
-
- var requested = new AssemblyName(args.Name);
- var candidate = Path.Combine(probeDirectory!, requested.Name + ".dll");
- if (!File.Exists(candidate))
- return null;
-
- try
- {
- return Assembly.LoadFrom(candidate);
- }
- catch (Exception ex)
- {
- Log.Debug(ex, "Historian plugin resolver failed to load {Candidate}", candidate);
- return null;
- }
- }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistoryContinuationPoint.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistoryContinuationPoint.cs
deleted file mode 100644
index ae7d553..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistoryContinuationPoint.cs
+++ /dev/null
@@ -1,97 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using Opc.Ua;
-using Serilog;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Historian
-{
- ///
- /// Manages continuation points for OPC UA HistoryRead requests that return
- /// more data than the per-request limit allows.
- ///
- internal sealed class HistoryContinuationPointManager
- {
- private static readonly ILogger Log = Serilog.Log.ForContext();
-
- private readonly ConcurrentDictionary _store = new();
- private readonly TimeSpan _timeout;
-
- public HistoryContinuationPointManager() : this(TimeSpan.FromMinutes(5)) { }
-
- internal HistoryContinuationPointManager(TimeSpan timeout)
- {
- _timeout = timeout;
- }
-
- ///
- /// Stores remaining data values and returns a continuation point identifier.
- ///
- public byte[] Store(List remaining)
- {
- PurgeExpired();
- var id = Guid.NewGuid();
- _store[id] = new StoredContinuation(remaining, DateTime.UtcNow);
- Log.Debug("Stored history continuation point {Id} with {Count} remaining values", id, remaining.Count);
- return id.ToByteArray();
- }
-
- ///
- /// Retrieves and removes the remaining data values for a continuation point.
- /// Returns null if the continuation point is invalid or expired.
- ///
- public List? Retrieve(byte[] continuationPoint)
- {
- PurgeExpired();
- if (continuationPoint == null || continuationPoint.Length != 16)
- return null;
-
- var id = new Guid(continuationPoint);
- if (!_store.TryRemove(id, out var stored))
- return null;
-
- if (DateTime.UtcNow - stored.CreatedAt > _timeout)
- {
- Log.Debug("History continuation point {Id} expired", id);
- return null;
- }
-
- return stored.Values;
- }
-
- ///
- /// Releases a continuation point without retrieving its data.
- ///
- public void Release(byte[] continuationPoint)
- {
- PurgeExpired();
- if (continuationPoint == null || continuationPoint.Length != 16)
- return;
-
- var id = new Guid(continuationPoint);
- _store.TryRemove(id, out _);
- }
-
- private void PurgeExpired()
- {
- var cutoff = DateTime.UtcNow - _timeout;
- foreach (var kvp in _store)
- {
- if (kvp.Value.CreatedAt < cutoff)
- _store.TryRemove(kvp.Key, out _);
- }
- }
-
- private sealed class StoredContinuation
- {
- public StoredContinuation(List values, DateTime createdAt)
- {
- Values = values;
- CreatedAt = createdAt;
- }
-
- public List Values { get; }
- public DateTime CreatedAt { get; }
- }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/IHistorianDataSource.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/IHistorianDataSource.cs
deleted file mode 100644
index 1a61745..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/IHistorianDataSource.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using Opc.Ua;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Historian
-{
- ///
- /// OPC UA-typed surface for the historian plugin. Host consumers depend only on this
- /// interface so the Wonderware Historian SDK assemblies are not required unless the
- /// plugin is loaded at runtime.
- ///
- public interface IHistorianDataSource : IDisposable
- {
- Task> ReadRawAsync(
- string tagName, DateTime startTime, DateTime endTime, int maxValues,
- CancellationToken ct = default);
-
- Task> ReadAggregateAsync(
- string tagName, DateTime startTime, DateTime endTime,
- double intervalMs, string aggregateColumn,
- CancellationToken ct = default);
-
- Task> ReadAtTimeAsync(
- string tagName, DateTime[] timestamps,
- CancellationToken ct = default);
-
- Task> ReadEventsAsync(
- string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
- CancellationToken ct = default);
-
- ///
- /// Returns a runtime snapshot of query success/failure counters and connection state.
- /// Consumed by the status dashboard and health check service so operators can detect
- /// silent query degradation that the load-time plugin status can't catch.
- ///
- HistorianHealthSnapshot GetHealthSnapshot();
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Metrics/PerformanceMetrics.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Metrics/PerformanceMetrics.cs
deleted file mode 100644
index 8cbec84..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/Metrics/PerformanceMetrics.cs
+++ /dev/null
@@ -1,265 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Linq;
-using System.Threading;
-using Serilog;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Metrics
-{
- ///
- /// Disposable scope returned by . (MXA-008)
- ///
- public interface ITimingScope : IDisposable
- {
- ///
- /// Marks whether the timed bridge operation completed successfully.
- ///
- /// A value indicating whether the measured operation succeeded.
- void SetSuccess(bool success);
- }
-
- ///
- /// Statistics snapshot for a single operation type.
- ///
- public class MetricsStatistics
- {
- ///
- /// Gets or sets the total number of recorded executions for the operation.
- ///
- public long TotalCount { get; set; }
-
- ///
- /// Gets or sets the number of recorded executions that completed successfully.
- ///
- public long SuccessCount { get; set; }
-
- ///
- /// Gets or sets the ratio of successful executions to total executions.
- ///
- public double SuccessRate { get; set; }
-
- ///
- /// Gets or sets the mean execution time in milliseconds across the recorded sample.
- ///
- public double AverageMilliseconds { get; set; }
-
- ///
- /// Gets or sets the fastest recorded execution time in milliseconds.
- ///
- public double MinMilliseconds { get; set; }
-
- ///
- /// Gets or sets the slowest recorded execution time in milliseconds.
- ///
- public double MaxMilliseconds { get; set; }
-
- ///
- /// Gets or sets the 95th percentile execution time in milliseconds.
- ///
- public double Percentile95Milliseconds { get; set; }
- }
-
- ///
- /// Per-operation timing and success tracking with a 1000-entry rolling buffer. (MXA-008)
- ///
- public class OperationMetrics
- {
- private readonly List _durations = new();
- private readonly object _lock = new();
- private double _maxMilliseconds;
- private double _minMilliseconds = double.MaxValue;
- private long _successCount;
- private long _totalCount;
- private double _totalMilliseconds;
-
- ///
- /// Records the outcome and duration of a single bridge operation invocation.
- ///
- /// The elapsed time for the operation.
- /// A value indicating whether the operation completed successfully.
- public void Record(TimeSpan duration, bool success)
- {
- lock (_lock)
- {
- _totalCount++;
- if (success) _successCount++;
-
- var ms = duration.TotalMilliseconds;
- _durations.Add(ms);
- _totalMilliseconds += ms;
-
- if (ms < _minMilliseconds) _minMilliseconds = ms;
- if (ms > _maxMilliseconds) _maxMilliseconds = ms;
-
- if (_durations.Count > 1000) _durations.RemoveAt(0);
- }
- }
-
- ///
- /// Creates a snapshot of the current statistics for this operation type.
- ///
- /// A statistics snapshot suitable for logs, status reporting, and tests.
- public MetricsStatistics GetStatistics()
- {
- lock (_lock)
- {
- if (_totalCount == 0)
- return new MetricsStatistics();
-
- var sorted = _durations.OrderBy(d => d).ToList();
- var p95Index = Math.Max(0, (int)Math.Ceiling(sorted.Count * 0.95) - 1);
-
- return new MetricsStatistics
- {
- TotalCount = _totalCount,
- SuccessCount = _successCount,
- SuccessRate = (double)_successCount / _totalCount,
- AverageMilliseconds = _totalMilliseconds / _totalCount,
- MinMilliseconds = _minMilliseconds,
- MaxMilliseconds = _maxMilliseconds,
- Percentile95Milliseconds = sorted[p95Index]
- };
- }
- }
- }
-
- ///
- /// Tracks per-operation performance metrics with periodic logging. (MXA-008)
- ///
- public class PerformanceMetrics : IDisposable
- {
- private static readonly ILogger Logger = Log.ForContext();
-
- private readonly ConcurrentDictionary
- _metrics = new(StringComparer.OrdinalIgnoreCase);
-
- private readonly Timer _reportingTimer;
- private bool _disposed;
-
- ///
- /// Initializes a new metrics collector and starts periodic performance reporting.
- ///
- public PerformanceMetrics()
- {
- _reportingTimer = new Timer(ReportMetrics, null,
- TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
- }
-
- ///
- /// Stops periodic reporting and emits a final metrics snapshot.
- ///
- public void Dispose()
- {
- if (_disposed) return;
- _disposed = true;
- _reportingTimer.Dispose();
- ReportMetrics(null);
- }
-
- ///
- /// Records a completed bridge operation under the specified metrics bucket.
- ///
- /// The logical operation name, such as read, write, or subscribe.
- /// The elapsed time for the operation.
- /// A value indicating whether the operation completed successfully.
- public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
- {
- var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
- metrics.Record(duration, success);
- }
-
- ///
- /// Starts timing a bridge operation and returns a disposable scope that records the result when disposed.
- ///
- /// The logical operation name to record.
- /// A timing scope that reports elapsed time back into this collector.
- public ITimingScope BeginOperation(string operationName)
- {
- return new TimingScope(this, operationName);
- }
-
- ///
- /// Retrieves the raw metrics bucket for a named operation.
- ///
- /// The logical operation name to look up.
- /// The metrics bucket when present; otherwise, .
- public OperationMetrics? GetMetrics(string operationName)
- {
- return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
- }
-
- ///
- /// Produces a statistics snapshot for all recorded bridge operations.
- ///
- /// A dictionary keyed by operation name containing current metrics statistics.
- public Dictionary GetStatistics()
- {
- var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var kvp in _metrics)
- result[kvp.Key] = kvp.Value.GetStatistics();
- return result;
- }
-
- private void ReportMetrics(object? state)
- {
- foreach (var kvp in _metrics)
- {
- var stats = kvp.Value.GetStatistics();
- if (stats.TotalCount == 0) continue;
-
- Logger.Information(
- "Metrics: {Operation} — Count={Count}, SuccessRate={SuccessRate:P1}, " +
- "AvgMs={AverageMs:F1}, MinMs={MinMs:F1}, MaxMs={MaxMs:F1}, P95Ms={P95Ms:F1}",
- kvp.Key, stats.TotalCount, stats.SuccessRate,
- stats.AverageMilliseconds, stats.MinMilliseconds,
- stats.MaxMilliseconds, stats.Percentile95Milliseconds);
- }
- }
-
- ///
- /// Timing scope that records one operation result into the owning metrics collector.
- ///
- private class TimingScope : ITimingScope
- {
- private readonly PerformanceMetrics _metrics;
- private readonly string _operationName;
- private readonly Stopwatch _stopwatch;
- private bool _disposed;
- private bool _success = true;
-
- ///
- /// Initializes a timing scope for a named bridge operation.
- ///
- /// The metrics collector that should receive the result.
- /// The logical operation name being timed.
- public TimingScope(PerformanceMetrics metrics, string operationName)
- {
- _metrics = metrics;
- _operationName = operationName;
- _stopwatch = Stopwatch.StartNew();
- }
-
- ///
- /// Marks whether the timed operation should be recorded as successful.
- ///
- /// A value indicating whether the operation succeeded.
- public void SetSuccess(bool success)
- {
- _success = success;
- }
-
- ///
- /// Stops timing and records the operation result once.
- ///
- public void Dispose()
- {
- if (_disposed) return;
- _disposed = true;
- _stopwatch.Stop();
- _metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success);
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs
deleted file mode 100644
index f1741a0..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs
+++ /dev/null
@@ -1,472 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
-{
- ///
- /// Advises <ObjectName>.ScanState on every deployed $WinPlatform and
- /// $AppEngine, tracks their runtime state (Unknown / Running / Stopped), and notifies
- /// the owning node manager on Running↔Stopped transitions so it can proactively flip every
- /// OPC UA variable hosted by that object to BadOutOfService (and clear on recovery).
- ///
- ///
- /// State machine semantics are documented in runtimestatus.md. Key facts:
- ///
- /// - ScanState is delivered on-change only — no periodic heartbeat. A stably
- /// Running host may go hours without a callback.
- /// - Running → Stopped is driven by explicit error callbacks or ScanState = false,
- /// NEVER by starvation. The only starvation check applies to the initial Unknown state.
- /// - When the MxAccess transport is disconnected, returns every
- /// entry with regardless of the underlying state,
- /// because we can't observe anything through a dead transport.
- /// - The stop/start callbacks fire synchronously from whichever thread delivered the
- /// probe update. The manager releases its own lock before invoking them to avoid
- /// lock-inversion deadlocks with the node manager's Lock.
- ///
- ///
- public sealed class GalaxyRuntimeProbeManager : IDisposable
- {
- private static readonly ILogger Log = Serilog.Log.ForContext();
-
- private const int CategoryWinPlatform = 1;
- private const int CategoryAppEngine = 3;
- private const string KindWinPlatform = "$WinPlatform";
- private const string KindAppEngine = "$AppEngine";
- private const string ProbeAttribute = ".ScanState";
-
- private readonly IMxAccessClient _client;
- private readonly TimeSpan _unknownTimeout;
- private readonly Action? _onHostStopped;
- private readonly Action? _onHostRunning;
- private readonly Func _clock;
-
- // Key: probe tag reference (e.g. "DevAppEngine.ScanState").
- // Value: the current runtime status for that host, kept in sync on every probe callback
- // and queried via GetSnapshot for dashboard rendering.
- private readonly Dictionary _byProbe =
- new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- // Reverse index: gobject_id -> probe tag, so Sync() can diff new/removed hosts efficiently.
- private readonly Dictionary _probeByGobjectId = new Dictionary();
-
- private readonly object _lock = new object();
- private bool _disposed;
-
- ///
- /// Initializes a new probe manager. and
- /// are invoked synchronously on Running↔Stopped
- /// transitions so the owning node manager can invalidate / restore the hosted subtree.
- ///
- public GalaxyRuntimeProbeManager(
- IMxAccessClient client,
- int unknownTimeoutSeconds,
- Action? onHostStopped = null,
- Action? onHostRunning = null)
- : this(client, unknownTimeoutSeconds, onHostStopped, onHostRunning, () => DateTime.UtcNow)
- {
- }
-
- internal GalaxyRuntimeProbeManager(
- IMxAccessClient client,
- int unknownTimeoutSeconds,
- Action? onHostStopped,
- Action? onHostRunning,
- Func clock)
- {
- _client = client ?? throw new ArgumentNullException(nameof(client));
- _unknownTimeout = TimeSpan.FromSeconds(Math.Max(1, unknownTimeoutSeconds));
- _onHostStopped = onHostStopped;
- _onHostRunning = onHostRunning;
- _clock = clock ?? throw new ArgumentNullException(nameof(clock));
- }
-
- ///
- /// Gets the number of active probe subscriptions. Surfaced on the dashboard Subscriptions
- /// panel so operators can see bridge-owned probe count separately from the total.
- ///
- public int ActiveProbeCount
- {
- get
- {
- lock (_lock)
- return _byProbe.Count;
- }
- }
-
- ///
- /// Returns when the galaxy runtime host identified by
- /// is currently in the
- /// state. Used by the node manager's Read path to short-circuit on-demand reads of tags
- /// hosted by a known-stopped runtime object, preventing MxAccess from serving stale
- /// cached values as Good. Unlike this check uses the
- /// underlying state directly — transport-disconnected hosts will NOT report Stopped here
- /// (they report their last-known state), because connection-loss is handled by the
- /// normal MxAccess error paths and we don't want this method to double-flag.
- ///
- public bool IsHostStopped(int gobjectId)
- {
- lock (_lock)
- {
- if (_probeByGobjectId.TryGetValue(gobjectId, out var probe)
- && _byProbe.TryGetValue(probe, out var status))
- {
- return status.State == GalaxyRuntimeState.Stopped;
- }
- }
- return false;
- }
-
- ///
- /// Returns a point-in-time clone of the runtime status for the host identified by
- /// , or when no probe is registered
- /// for that object. Used by the node manager to populate the synthetic $RuntimeState
- /// child variables on each host object. Uses the underlying state directly (not the
- /// transport-gated rewrite), matching .
- ///
- public GalaxyRuntimeStatus? GetHostStatus(int gobjectId)
- {
- lock (_lock)
- {
- if (_probeByGobjectId.TryGetValue(gobjectId, out var probe)
- && _byProbe.TryGetValue(probe, out var status))
- {
- return Clone(status, forceUnknown: false);
- }
- }
- return null;
- }
-
- ///
- /// Diffs the supplied hierarchy against the active probe set, advising new hosts and
- /// unadvising removed ones. The hierarchy is filtered to runtime host categories
- /// ($WinPlatform, $AppEngine) — non-host rows are ignored. Idempotent: a second call
- /// with the same hierarchy performs no Advise / Unadvise work.
- ///
- ///
- /// Sync is synchronous on MxAccess: is
- /// awaited for each new host, so for a galaxy with N runtime hosts the call blocks for
- /// ~N round-trips. This is acceptable because it only runs during address-space build
- /// and rebuild, not on the hot path.
- ///
- public async Task SyncAsync(IReadOnlyList hierarchy)
- {
- if (_disposed || hierarchy == null)
- return;
-
- // Filter to runtime hosts and project to the expected probe tag name.
- var desired = new Dictionary();
- foreach (var obj in hierarchy)
- {
- if (obj.CategoryId != CategoryWinPlatform && obj.CategoryId != CategoryAppEngine)
- continue;
- if (string.IsNullOrWhiteSpace(obj.TagName))
- continue;
- var probe = obj.TagName + ProbeAttribute;
- var kind = obj.CategoryId == CategoryWinPlatform ? KindWinPlatform : KindAppEngine;
- desired[obj.GobjectId] = (probe, kind, obj);
- }
-
- // Compute diffs under lock, release lock before issuing SDK calls (which can block).
- // toSubscribe carries the gobject id alongside the probe name so the rollback path on
- // subscribe failure can unwind both dictionaries without a reverse lookup.
- List<(int GobjectId, string Probe)> toSubscribe;
- List toUnsubscribe;
- lock (_lock)
- {
- toSubscribe = new List<(int, string)>();
- toUnsubscribe = new List();
-
- foreach (var kvp in desired)
- {
- if (_probeByGobjectId.TryGetValue(kvp.Key, out var existingProbe))
- {
- // Already tracked: ensure the status entry is aligned (tag rename path is
- // intentionally not supported — if the probe changed, treat it as remove+add).
- if (!string.Equals(existingProbe, kvp.Value.Probe, StringComparison.OrdinalIgnoreCase))
- {
- toUnsubscribe.Add(existingProbe);
- _byProbe.Remove(existingProbe);
- _probeByGobjectId.Remove(kvp.Key);
-
- toSubscribe.Add((kvp.Key, kvp.Value.Probe));
- _byProbe[kvp.Value.Probe] = MakeInitialStatus(kvp.Value.Obj, kvp.Value.Kind);
- _probeByGobjectId[kvp.Key] = kvp.Value.Probe;
- }
- }
- else
- {
- toSubscribe.Add((kvp.Key, kvp.Value.Probe));
- _byProbe[kvp.Value.Probe] = MakeInitialStatus(kvp.Value.Obj, kvp.Value.Kind);
- _probeByGobjectId[kvp.Key] = kvp.Value.Probe;
- }
- }
-
- // Remove hosts that are no longer in the desired set.
- var toRemove = _probeByGobjectId.Keys.Where(id => !desired.ContainsKey(id)).ToList();
- foreach (var id in toRemove)
- {
- var probe = _probeByGobjectId[id];
- toUnsubscribe.Add(probe);
- _byProbe.Remove(probe);
- _probeByGobjectId.Remove(id);
- }
- }
-
- // Apply the diff outside the lock.
- foreach (var (gobjectId, probe) in toSubscribe)
- {
- try
- {
- await _client.SubscribeAsync(probe, OnProbeValueChanged);
- Log.Information("Galaxy runtime probe advised: {Probe}", probe);
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Failed to advise galaxy runtime probe {Probe}", probe);
-
- // Roll back the pending entry so Tick() can't later transition a never-advised
- // probe from Unknown to Stopped and fan out a false-negative host-down signal.
- // A concurrent SyncAsync may have re-added the same gobject under a new probe
- // name, so compare against the captured probe string before removing.
- lock (_lock)
- {
- if (_probeByGobjectId.TryGetValue(gobjectId, out var current)
- && string.Equals(current, probe, StringComparison.OrdinalIgnoreCase))
- {
- _probeByGobjectId.Remove(gobjectId);
- }
- _byProbe.Remove(probe);
- }
- }
- }
-
- foreach (var probe in toUnsubscribe)
- {
- try
- {
- await _client.UnsubscribeAsync(probe);
- }
- catch (Exception ex)
- {
- Log.Debug(ex, "Failed to unadvise galaxy runtime probe {Probe} during sync", probe);
- }
- }
- }
-
- ///
- /// Routes an OnTagValueChanged callback to the probe state machine. Returns
- /// when matches a bridge-owned probe
- /// (in which case the owning node manager should skip its normal variable-update path).
- ///
- public bool HandleProbeUpdate(string tagRef, Vtq vtq)
- {
- if (_disposed || string.IsNullOrEmpty(tagRef))
- return false;
-
- GalaxyRuntimeStatus? status;
- int fromToGobjectId = 0;
- GalaxyRuntimeState? transitionTo = null;
-
- lock (_lock)
- {
- if (!_byProbe.TryGetValue(tagRef, out status))
- return false; // not a probe — let the caller handle it normally
-
- var now = _clock();
- var isRunning = vtq.Quality.IsGood() && vtq.Value is bool b && b;
- status.LastStateCallbackTime = now;
- status.LastScanState = vtq.Value as bool?;
-
- if (isRunning)
- {
- status.GoodUpdateCount++;
- status.LastError = null;
- if (status.State != GalaxyRuntimeState.Running)
- {
- // Only fire the host-running callback on a true Stopped → Running
- // recovery. Unknown → Running happens once at startup for every host
- // and is not a recovery — firing ClearHostVariablesBadQuality there
- // would wipe Bad status set by the concurrently-stopping other host
- // on variables that span both lists.
- var wasStopped = status.State == GalaxyRuntimeState.Stopped;
- status.State = GalaxyRuntimeState.Running;
- status.LastStateChangeTime = now;
- if (wasStopped)
- {
- transitionTo = GalaxyRuntimeState.Running;
- fromToGobjectId = status.GobjectId;
- }
- }
- }
- else
- {
- status.FailureCount++;
- status.LastError = BuildErrorDetail(vtq);
- if (status.State != GalaxyRuntimeState.Stopped)
- {
- status.State = GalaxyRuntimeState.Stopped;
- status.LastStateChangeTime = now;
- transitionTo = GalaxyRuntimeState.Stopped;
- fromToGobjectId = status.GobjectId;
- }
- }
- }
-
- // Invoke transition callbacks outside the lock to avoid inverting the node manager's
- // lock order when it subsequently takes its own Lock to flip hosted variables.
- if (transitionTo == GalaxyRuntimeState.Stopped)
- {
- Log.Information("Galaxy runtime {Probe} transitioned Running → Stopped ({Err})",
- tagRef, status?.LastError ?? "(no detail)");
- try { _onHostStopped?.Invoke(fromToGobjectId); }
- catch (Exception ex) { Log.Warning(ex, "onHostStopped callback threw for {Probe}", tagRef); }
- }
- else if (transitionTo == GalaxyRuntimeState.Running)
- {
- Log.Information("Galaxy runtime {Probe} transitioned → Running", tagRef);
- try { _onHostRunning?.Invoke(fromToGobjectId); }
- catch (Exception ex) { Log.Warning(ex, "onHostRunning callback threw for {Probe}", tagRef); }
- }
-
- return true;
- }
-
- ///
- /// Periodic tick — flips Unknown entries to Stopped once their registration has been
- /// outstanding for longer than the configured timeout without ever receiving a first
- /// callback. Does nothing to Running or Stopped entries.
- ///
- public void Tick()
- {
- if (_disposed)
- return;
-
- var transitions = new List();
- lock (_lock)
- {
- var now = _clock();
- foreach (var entry in _byProbe.Values)
- {
- if (entry.State != GalaxyRuntimeState.Unknown)
- continue;
-
- // LastStateChangeTime is set at creation to "now" so the timeout is measured
- // from when the probe was advised.
- if (entry.LastStateChangeTime.HasValue
- && now - entry.LastStateChangeTime.Value > _unknownTimeout)
- {
- entry.State = GalaxyRuntimeState.Stopped;
- entry.LastStateChangeTime = now;
- entry.FailureCount++;
- entry.LastError = "Probe never received an initial callback within the unknown-resolution timeout";
- transitions.Add(entry.GobjectId);
- }
- }
- }
-
- foreach (var gobjectId in transitions)
- {
- Log.Warning("Galaxy runtime gobject {GobjectId} timed out in Unknown state → Stopped", gobjectId);
- try { _onHostStopped?.Invoke(gobjectId); }
- catch (Exception ex) { Log.Warning(ex, "onHostStopped callback threw during tick for {GobjectId}", gobjectId); }
- }
- }
-
- ///
- /// Returns a read-only snapshot of every tracked host. When the MxAccess transport is
- /// disconnected, every entry is rewritten to Unknown on the way out so operators aren't
- /// misled by cached per-host state — the Connection panel is the primary signal in that
- /// case. The underlying _byProbe map is not modified.
- ///
- public IReadOnlyList GetSnapshot()
- {
- var transportDown = _client.State != ConnectionState.Connected;
-
- lock (_lock)
- {
- var result = new List(_byProbe.Count);
- foreach (var entry in _byProbe.Values)
- result.Add(Clone(entry, forceUnknown: transportDown));
- // Stable ordering by name so dashboard rows don't jitter between refreshes.
- result.Sort((a, b) => string.CompareOrdinal(a.ObjectName, b.ObjectName));
- return result;
- }
- }
-
- ///
- public void Dispose()
- {
- List probes;
- lock (_lock)
- {
- if (_disposed)
- return;
- _disposed = true;
- probes = _byProbe.Keys.ToList();
- _byProbe.Clear();
- _probeByGobjectId.Clear();
- }
-
- foreach (var probe in probes)
- {
- try
- {
- _client.UnsubscribeAsync(probe).GetAwaiter().GetResult();
- }
- catch (Exception ex)
- {
- Log.Debug(ex, "Failed to unadvise galaxy runtime probe {Probe} during Dispose", probe);
- }
- }
- }
-
- private void OnProbeValueChanged(string tagRef, Vtq vtq)
- {
- HandleProbeUpdate(tagRef, vtq);
- }
-
- private GalaxyRuntimeStatus MakeInitialStatus(GalaxyObjectInfo obj, string kind)
- {
- return new GalaxyRuntimeStatus
- {
- ObjectName = obj.TagName,
- GobjectId = obj.GobjectId,
- Kind = kind,
- State = GalaxyRuntimeState.Unknown,
- LastStateChangeTime = _clock()
- };
- }
-
- private static GalaxyRuntimeStatus Clone(GalaxyRuntimeStatus src, bool forceUnknown)
- {
- return new GalaxyRuntimeStatus
- {
- ObjectName = src.ObjectName,
- GobjectId = src.GobjectId,
- Kind = src.Kind,
- State = forceUnknown ? GalaxyRuntimeState.Unknown : src.State,
- LastStateCallbackTime = src.LastStateCallbackTime,
- LastStateChangeTime = src.LastStateChangeTime,
- LastScanState = src.LastScanState,
- LastError = forceUnknown ? null : src.LastError,
- GoodUpdateCount = src.GoodUpdateCount,
- FailureCount = src.FailureCount
- };
- }
-
- private static string BuildErrorDetail(Vtq vtq)
- {
- if (vtq.Quality.IsBad())
- return $"bad quality ({vtq.Quality})";
- if (vtq.Quality.IsUncertain())
- return $"uncertain quality ({vtq.Quality})";
- if (vtq.Value is bool b && !b)
- return "ScanState = false (OffScan)";
- return $"unexpected value: {vtq.Value ?? "(null)"}";
- }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Connection.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Connection.cs
deleted file mode 100644
index 6875a65..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Connection.cs
+++ /dev/null
@@ -1,149 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
-{
- public sealed partial class MxAccessClient
- {
- ///
- /// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription.
- ///
- /// A token that cancels the connection attempt.
- public async Task ConnectAsync(CancellationToken ct = default)
- {
- if (_state == ConnectionState.Connected) return;
-
- SetState(ConnectionState.Connecting);
- try
- {
- _connectionHandle = await _staThread.RunAsync(() =>
- {
- AttachProxyEvents();
- return _proxy.Register(_config.ClientName);
- });
-
- Log.Information("MxAccess registered with handle {Handle}", _connectionHandle);
- SetState(ConnectionState.Connected);
-
- // Replay stored subscriptions
- await ReplayStoredSubscriptionsAsync();
-
- // Start probe if configured
- if (!string.IsNullOrWhiteSpace(_config.ProbeTag))
- {
- _probeTag = _config.ProbeTag;
- _lastProbeValueTime = DateTime.UtcNow;
- await SubscribeInternalAsync(_probeTag!);
- Log.Information("Probe tag subscribed: {ProbeTag}", _probeTag);
- }
- }
- catch (Exception ex)
- {
- try
- {
- await _staThread.RunAsync(DetachProxyEvents);
- }
- catch (Exception cleanupEx)
- {
- Log.Warning(cleanupEx, "Failed to detach proxy events after connection failure");
- }
-
- Log.Error(ex, "MxAccess connection failed");
- SetState(ConnectionState.Error, ex.Message);
- throw;
- }
- }
-
- ///
- /// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations.
- ///
- public async Task DisconnectAsync()
- {
- if (_state == ConnectionState.Disconnected) return;
-
- SetState(ConnectionState.Disconnecting);
- try
- {
- await _staThread.RunAsync(() =>
- {
- // UnAdvise + RemoveItem for all active subscriptions
- foreach (var kvp in _addressToHandle)
- try
- {
- _proxy.UnAdviseSupervisory(_connectionHandle, kvp.Value);
- _proxy.RemoveItem(_connectionHandle, kvp.Value);
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Error cleaning up subscription for {Address}", kvp.Key);
- }
-
- // Unwire events before unregister
- DetachProxyEvents();
-
- // Unregister
- try
- {
- _proxy.Unregister(_connectionHandle);
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Error during Unregister");
- }
- });
-
- _handleToAddress.Clear();
- _addressToHandle.Clear();
- _pendingReadsByAddress.Clear();
- _pendingWrites.Clear();
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Error during disconnect");
- }
- finally
- {
- SetState(ConnectionState.Disconnected);
- }
- }
-
- ///
- /// Attempts to recover from a runtime fault by disconnecting and reconnecting the client.
- ///
- public async Task ReconnectAsync()
- {
- SetState(ConnectionState.Reconnecting);
- Interlocked.Increment(ref _reconnectCount);
- Log.Information("MxAccess reconnect attempt #{Count}", _reconnectCount);
-
- try
- {
- await DisconnectAsync();
- await ConnectAsync();
- }
- catch (Exception ex)
- {
- Log.Error(ex, "Reconnect failed");
- SetState(ConnectionState.Error, ex.Message);
- }
- }
-
- private void AttachProxyEvents()
- {
- if (_proxyEventsAttached) return;
- _proxy.OnDataChange += HandleOnDataChange;
- _proxy.OnWriteComplete += HandleOnWriteComplete;
- _proxyEventsAttached = true;
- }
-
- private void DetachProxyEvents()
- {
- if (!_proxyEventsAttached) return;
- _proxy.OnDataChange -= HandleOnDataChange;
- _proxy.OnWriteComplete -= HandleOnWriteComplete;
- _proxyEventsAttached = false;
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs
deleted file mode 100644
index 588a627..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs
+++ /dev/null
@@ -1,97 +0,0 @@
-using System;
-using ArchestrA.MxAccess;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
-{
- public sealed partial class MxAccessClient
- {
- ///
- /// COM event handler for MxAccess OnDataChange events.
- /// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface.
- ///
- private void HandleOnDataChange(
- int hLMXServerHandle,
- int phItemHandle,
- object pvItemValue,
- int pwItemQuality,
- object pftItemTimeStamp,
- ref MXSTATUS_PROXY[] ItemStatus)
- {
- try
- {
- if (!_handleToAddress.TryGetValue(phItemHandle, out var address))
- {
- Log.Debug("OnDataChange for unknown handle {Handle}", phItemHandle);
- return;
- }
-
- var quality = QualityMapper.MapFromMxAccessQuality(pwItemQuality);
-
- // Check MXSTATUS_PROXY — if success is false, use more specific quality
- if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0)
- quality = MxErrorCodes.MapToQuality(ItemStatus[0].detail);
-
- var timestamp = ConvertTimestamp(pftItemTimeStamp);
- var vtq = new Vtq(pvItemValue, timestamp, quality);
-
- // Update probe timestamp
- if (string.Equals(address, _probeTag, StringComparison.OrdinalIgnoreCase))
- _lastProbeValueTime = DateTime.UtcNow;
-
- // Invoke stored subscription callback
- if (_storedSubscriptions.TryGetValue(address, out var callback)) callback(address, vtq);
-
- if (_pendingReadsByAddress.TryGetValue(address, out var pendingReads))
- foreach (var pendingRead in pendingReads.Values)
- pendingRead.TrySetResult(vtq);
-
- // Global handler
- OnTagValueChanged?.Invoke(address, vtq);
- }
- catch (Exception ex)
- {
- Log.Error(ex, "Error processing OnDataChange for handle {Handle}", phItemHandle);
- }
- }
-
- ///
- /// COM event handler for MxAccess OnWriteComplete events.
- ///
- private void HandleOnWriteComplete(
- int hLMXServerHandle,
- int phItemHandle,
- ref MXSTATUS_PROXY[] ItemStatus)
- {
- try
- {
- if (_pendingWrites.TryRemove(phItemHandle, out var tcs))
- {
- var success = ItemStatus == null || ItemStatus.Length == 0 || ItemStatus[0].success != 0;
- if (success)
- {
- tcs.TrySetResult(true);
- }
- else
- {
- var detail = ItemStatus![0].detail;
- var message = MxErrorCodes.GetMessage(detail);
- Log.Warning("Write failed for handle {Handle}: {Message}", phItemHandle, message);
- tcs.TrySetResult(false);
- }
- }
- }
- catch (Exception ex)
- {
- Log.Error(ex, "Error processing OnWriteComplete for handle {Handle}", phItemHandle);
- }
- }
-
- private static DateTime ConvertTimestamp(object pftItemTimeStamp)
- {
- if (pftItemTimeStamp is DateTime dt)
- return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime();
- return DateTime.UtcNow;
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs
deleted file mode 100644
index 3f3fd84..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
-{
- public sealed partial class MxAccessClient
- {
- private Task? _monitorTask;
-
- ///
- /// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness.
- ///
- public void StartMonitor()
- {
- if (_monitorCts != null)
- StopMonitor();
-
- _monitorCts = new CancellationTokenSource();
- _monitorTask = Task.Run(() => MonitorLoopAsync(_monitorCts.Token));
- Log.Information("MxAccess monitor started (interval={Interval}s)", _config.MonitorIntervalSeconds);
- }
-
- ///
- /// Stops the background monitor loop.
- ///
- public void StopMonitor()
- {
- _monitorCts?.Cancel();
- try { _monitorTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
- _monitorTask = null;
- }
-
- private async Task MonitorLoopAsync(CancellationToken ct)
- {
- while (!ct.IsCancellationRequested)
- {
- try
- {
- await Task.Delay(TimeSpan.FromSeconds(_config.MonitorIntervalSeconds), ct);
- }
- catch (OperationCanceledException)
- {
- break;
- }
-
- try
- {
- if ((_state == ConnectionState.Disconnected || _state == ConnectionState.Error) &&
- _config.AutoReconnect)
- {
- Log.Information("Monitor: connection lost (state={State}), attempting reconnect", _state);
- await ReconnectAsync();
- continue;
- }
-
- if (_state == ConnectionState.Connected && _probeTag != null)
- {
- var elapsed = DateTime.UtcNow - _lastProbeValueTime;
- if (elapsed.TotalSeconds > _config.ProbeStaleThresholdSeconds)
- {
- Log.Warning("Monitor: probe stale ({Elapsed:F0}s > {Threshold}s), forcing reconnect",
- elapsed.TotalSeconds, _config.ProbeStaleThresholdSeconds);
- await ReconnectAsync();
- }
- }
- }
- catch (Exception ex)
- {
- Log.Error(ex, "Monitor loop error");
- }
- }
-
- Log.Information("MxAccess monitor stopped");
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs
deleted file mode 100644
index 313c49b..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs
+++ /dev/null
@@ -1,166 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Threading;
-using System.Threading.Tasks;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
-{
- public sealed partial class MxAccessClient
- {
- ///
- /// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback.
- ///
- /// The fully qualified Galaxy tag reference to read.
- /// A token that cancels the read.
- /// The resulting VTQ value or a bad-quality fallback on timeout or failure.
- public async Task ReadAsync(string fullTagReference, CancellationToken ct = default)
- {
- if (_state != ConnectionState.Connected)
- return Vtq.Bad(Quality.BadNotConnected);
-
- await _operationSemaphore.WaitAsync(ct);
- try
- {
- using var scope = _metrics.BeginOperation("Read");
- var tcs = new TaskCompletionSource();
-
- var itemHandle = await _staThread.RunAsync(() =>
- {
- var h = _proxy.AddItem(_connectionHandle, fullTagReference);
- _proxy.AdviseSupervisory(_connectionHandle, h);
- return h;
- });
-
- var pendingReads = _pendingReadsByAddress.GetOrAdd(fullTagReference,
- _ => new ConcurrentDictionary>());
- pendingReads[itemHandle] = tcs;
- _handleToAddress[itemHandle] = fullTagReference;
-
- try
- {
- using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- cts.CancelAfter(TimeSpan.FromSeconds(_config.ReadTimeoutSeconds));
- cts.Token.Register(() => tcs.TrySetResult(Vtq.Bad(Quality.BadCommFailure)));
-
- var result = await tcs.Task;
- if (result.Quality != Quality.Good)
- scope.SetSuccess(false);
-
- return result;
- }
- catch
- {
- scope.SetSuccess(false);
- return Vtq.Bad(Quality.BadCommFailure);
- }
- finally
- {
- if (_pendingReadsByAddress.TryGetValue(fullTagReference, out var reads))
- {
- reads.TryRemove(itemHandle, out _);
- if (reads.IsEmpty)
- _pendingReadsByAddress.TryRemove(fullTagReference, out _);
- }
-
- _handleToAddress.TryRemove(itemHandle, out _);
-
- try
- {
- await _staThread.RunAsync(() =>
- {
- _proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
- _proxy.RemoveItem(_connectionHandle, itemHandle);
- });
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Error cleaning up read subscription for {Address}", fullTagReference);
- }
- }
- }
- finally
- {
- _operationSemaphore.Release();
- }
- }
-
- ///
- /// Writes a value to a Galaxy tag and waits for the runtime write-complete callback.
- ///
- /// The fully qualified Galaxy tag reference to write.
- /// The value to send to the runtime.
- /// A token that cancels the write.
- /// when the runtime acknowledges success; otherwise, .
- public async Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
- {
- if (_state != ConnectionState.Connected) return false;
-
- await _operationSemaphore.WaitAsync(ct);
- try
- {
- using var scope = _metrics.BeginOperation("Write");
-
- var itemHandle = await _staThread.RunAsync(() =>
- {
- var h = _proxy.AddItem(_connectionHandle, fullTagReference);
- _proxy.AdviseSupervisory(_connectionHandle, h);
- return h;
- });
-
- _handleToAddress[itemHandle] = fullTagReference;
-
- var tcs = new TaskCompletionSource();
- _pendingWrites[itemHandle] = tcs;
-
- try
- {
- await _staThread.RunAsync(() => _proxy.Write(_connectionHandle, itemHandle, value, -1));
-
- using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds));
- cts.Token.Register(() =>
- {
- Log.Warning("Write timed out for {Address} after {Timeout}s", fullTagReference,
- _config.WriteTimeoutSeconds);
- tcs.TrySetResult(false);
- });
-
- var success = await tcs.Task;
- if (!success)
- scope.SetSuccess(false);
-
- return success;
- }
- catch (Exception ex)
- {
- scope.SetSuccess(false);
- Log.Error(ex, "Write failed for {Address}", fullTagReference);
- return false;
- }
- finally
- {
- _pendingWrites.TryRemove(itemHandle, out _);
- _handleToAddress.TryRemove(itemHandle, out _);
-
- try
- {
- await _staThread.RunAsync(() =>
- {
- _proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
- _proxy.RemoveItem(_connectionHandle, itemHandle);
- });
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Error cleaning up write subscription for {Address}", fullTagReference);
- }
- }
- }
- finally
- {
- _operationSemaphore.Release();
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs
deleted file mode 100644
index 304bcb4..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs
+++ /dev/null
@@ -1,107 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
-{
- public sealed partial class MxAccessClient
- {
- ///
- /// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected.
- ///
- /// The fully qualified Galaxy tag reference to monitor.
- /// The callback that should receive runtime value changes.
- public async Task SubscribeAsync(string fullTagReference, Action callback)
- {
- _storedSubscriptions[fullTagReference] = callback;
- if (_state != ConnectionState.Connected) return;
- if (_addressToHandle.ContainsKey(fullTagReference)) return;
-
- await SubscribeInternalAsync(fullTagReference);
- }
-
- ///
- /// Removes a persistent subscription callback and tears down the runtime item when appropriate.
- ///
- /// The fully qualified Galaxy tag reference to stop monitoring.
- public async Task UnsubscribeAsync(string fullTagReference)
- {
- _storedSubscriptions.TryRemove(fullTagReference, out _);
-
- // Don't unsubscribe the probe tag
- if (string.Equals(fullTagReference, _probeTag, StringComparison.OrdinalIgnoreCase))
- return;
-
- if (_addressToHandle.TryRemove(fullTagReference, out var itemHandle))
- {
- _handleToAddress.TryRemove(itemHandle, out _);
-
- if (_state == ConnectionState.Connected)
- await _staThread.RunAsync(() =>
- {
- try
- {
- _proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
- _proxy.RemoveItem(_connectionHandle, itemHandle);
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Error unsubscribing {Address}", fullTagReference);
- }
- });
- }
- }
-
- private async Task SubscribeInternalAsync(string address)
- {
- if (_addressToHandle.ContainsKey(address))
- return;
-
- using var scope = _metrics.BeginOperation("Subscribe");
- try
- {
- var itemHandle = await _staThread.RunAsync(() =>
- {
- var h = _proxy.AddItem(_connectionHandle, address);
- _proxy.AdviseSupervisory(_connectionHandle, h);
- return h;
- });
-
- var registeredHandle = _addressToHandle.GetOrAdd(address, itemHandle);
- if (registeredHandle != itemHandle)
- {
- await _staThread.RunAsync(() =>
- {
- _proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
- _proxy.RemoveItem(_connectionHandle, itemHandle);
- });
- return;
- }
-
- _handleToAddress[itemHandle] = address;
- Log.Debug("Subscribed to {Address} (handle={Handle})", address, itemHandle);
- }
- catch (Exception ex)
- {
- scope.SetSuccess(false);
- Log.Error(ex, "Failed to subscribe to {Address}", address);
- throw;
- }
- }
-
- private async Task ReplayStoredSubscriptionsAsync()
- {
- foreach (var kvp in _storedSubscriptions)
- try
- {
- await SubscribeInternalAsync(kvp.Key);
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Failed to replay subscription for {Address}", kvp.Key);
- }
-
- Log.Information("Replayed {Count} stored subscriptions", _storedSubscriptions.Count);
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.cs
deleted file mode 100644
index 32815a3..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.cs
+++ /dev/null
@@ -1,125 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Threading;
-using System.Threading.Tasks;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Configuration;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-using ZB.MOM.WW.OtOpcUa.Host.Metrics;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
-{
- ///
- /// Core MXAccess client implementing IMxAccessClient via IMxProxy abstraction.
- /// Split across partial classes: Connection, Subscription, ReadWrite, EventHandlers, Monitor.
- /// (MXA-001 through MXA-009)
- ///
- public sealed partial class MxAccessClient : IMxAccessClient
- {
- private static readonly ILogger Log = Serilog.Log.ForContext();
- private readonly ConcurrentDictionary _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
- private readonly MxAccessConfiguration _config;
-
- // Handle mappings
- private readonly ConcurrentDictionary _handleToAddress = new();
- private readonly PerformanceMetrics _metrics;
- private readonly SemaphoreSlim _operationSemaphore;
-
- private readonly ConcurrentDictionary>>
- _pendingReadsByAddress
- = new(StringComparer.OrdinalIgnoreCase);
-
- // Pending writes
- private readonly ConcurrentDictionary> _pendingWrites = new();
-
- private readonly IMxProxy _proxy;
-
- private readonly StaComThread _staThread;
-
- // Subscription storage
- private readonly ConcurrentDictionary> _storedSubscriptions
- = new(StringComparer.OrdinalIgnoreCase);
-
- private int _connectionHandle;
- private DateTime _lastProbeValueTime = DateTime.UtcNow;
- private CancellationTokenSource? _monitorCts;
-
- // Probe
- private string? _probeTag;
- private bool _proxyEventsAttached;
- private int _reconnectCount;
- private volatile ConnectionState _state = ConnectionState.Disconnected;
-
- ///
- /// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
- ///
- /// The STA thread used to marshal COM interactions.
- /// The COM proxy abstraction used to talk to the runtime.
- /// The runtime timeout, throttling, and reconnect settings.
- /// The metrics collector used to time MXAccess operations.
- public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config,
- PerformanceMetrics metrics)
- {
- _staThread = staThread;
- _proxy = proxy;
- _config = config;
- _metrics = metrics;
- _operationSemaphore = new SemaphoreSlim(config.MaxConcurrentOperations, config.MaxConcurrentOperations);
- }
-
- ///
- /// Gets the current runtime connection state for the MXAccess client.
- ///
- public ConnectionState State => _state;
-
- ///
- /// Gets the number of active tag subscriptions currently maintained against the runtime.
- ///
- public int ActiveSubscriptionCount => _storedSubscriptions.Count;
-
- ///
- /// Gets the number of reconnect attempts performed since the client was created.
- ///
- public int ReconnectCount => _reconnectCount;
-
- ///
- /// Occurs when the MXAccess connection state changes.
- ///
- public event EventHandler? ConnectionStateChanged;
-
- ///
- /// Occurs when a subscribed runtime tag publishes a new value.
- ///
- public event Action? OnTagValueChanged;
-
- ///
- /// Cancels monitoring and disconnects the runtime session before releasing local resources.
- ///
- public void Dispose()
- {
- try
- {
- _monitorCts?.Cancel();
- DisconnectAsync().GetAwaiter().GetResult();
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Error during MxAccessClient dispose");
- }
- finally
- {
- _operationSemaphore.Dispose();
- _monitorCts?.Dispose();
- }
- }
-
- private void SetState(ConnectionState newState, string message = "")
- {
- var previous = _state;
- if (previous == newState) return;
- _state = newState;
- Log.Information("MxAccess state: {Previous} → {Current} {Message}", previous, newState, message);
- ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message));
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxProxyAdapter.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxProxyAdapter.cs
deleted file mode 100644
index 6d4a6e5..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxProxyAdapter.cs
+++ /dev/null
@@ -1,130 +0,0 @@
-using System;
-using System.Runtime.InteropServices;
-using ArchestrA.MxAccess;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
-{
- ///
- /// Wraps the real ArchestrA.MxAccess.LMXProxyServer COM object, forwarding calls to IMxProxy.
- /// Uses strongly-typed interop — same pattern as the reference LmxProxy implementation. (MXA-001)
- ///
- public sealed class MxProxyAdapter : IMxProxy
- {
- private LMXProxyServer? _lmxProxy;
-
- ///
- /// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute.
- ///
- public event MxDataChangeHandler? OnDataChange;
-
- ///
- /// Occurs when the COM proxy confirms completion of a write request.
- ///
- public event MxWriteCompleteHandler? OnWriteComplete;
-
- ///
- /// Creates and registers the COM proxy session that backs live MXAccess operations.
- ///
- /// The client name reported to the Wonderware runtime.
- /// The runtime connection handle assigned by the COM server.
- public int Register(string clientName)
- {
- _lmxProxy = new LMXProxyServer();
-
- _lmxProxy.OnDataChange += ProxyOnDataChange;
- _lmxProxy.OnWriteComplete += ProxyOnWriteComplete;
-
- var handle = _lmxProxy.Register(clientName);
- if (handle <= 0)
- throw new InvalidOperationException($"LMXProxyServer.Register returned invalid handle: {handle}");
-
- return handle;
- }
-
- ///
- /// Unregisters the COM proxy session and releases the underlying COM object.
- ///
- /// The runtime connection handle returned by .
- public void Unregister(int handle)
- {
- if (_lmxProxy != null)
- try
- {
- _lmxProxy.OnDataChange -= ProxyOnDataChange;
- _lmxProxy.OnWriteComplete -= ProxyOnWriteComplete;
- _lmxProxy.Unregister(handle);
- }
- finally
- {
- Marshal.ReleaseComObject(_lmxProxy);
- _lmxProxy = null;
- }
- }
-
- ///
- /// Resolves a Galaxy attribute reference into a runtime item handle through the COM proxy.
- ///
- /// The runtime connection handle.
- /// The fully qualified Galaxy attribute reference.
- /// The item handle assigned by the COM proxy.
- public int AddItem(int handle, string address)
- {
- return _lmxProxy!.AddItem(handle, address);
- }
-
- ///
- /// Removes an item handle from the active COM proxy session.
- ///
- /// The runtime connection handle.
- /// The item handle to remove.
- public void RemoveItem(int handle, int itemHandle)
- {
- _lmxProxy!.RemoveItem(handle, itemHandle);
- }
-
- ///
- /// Enables supervisory callbacks for the specified runtime item.
- ///
- /// The runtime connection handle.
- /// The item handle to monitor.
- public void AdviseSupervisory(int handle, int itemHandle)
- {
- _lmxProxy!.AdviseSupervisory(handle, itemHandle);
- }
-
- ///
- /// Disables supervisory callbacks for the specified runtime item.
- ///
- /// The runtime connection handle.
- /// The item handle to stop monitoring.
- public void UnAdviseSupervisory(int handle, int itemHandle)
- {
- _lmxProxy!.UnAdvise(handle, itemHandle);
- }
-
- ///
- /// Writes a value to the specified runtime item through the COM proxy.
- ///
- /// The runtime connection handle.
- /// The item handle to write.
- /// The value to send to the runtime.
- /// The Wonderware security classification applied to the write.
- public void Write(int handle, int itemHandle, object value, int securityClassification)
- {
- _lmxProxy!.Write(handle, itemHandle, value, securityClassification);
- }
-
- private void ProxyOnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
- int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
- {
- OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp,
- ref ItemStatus);
- }
-
- private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
- {
- OnWriteComplete?.Invoke(hLMXServerHandle, phItemHandle, ref ItemStatus);
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/StaComThread.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/StaComThread.cs
deleted file mode 100644
index ca7d797..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/StaComThread.cs
+++ /dev/null
@@ -1,309 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Runtime.InteropServices;
-using System.Threading;
-using System.Threading.Tasks;
-using Serilog;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
-{
- ///
- /// Dedicated STA thread with a raw Win32 message pump for COM interop.
- /// All MxAccess COM objects must be created and called on this thread. (MXA-001)
- ///
- public sealed class StaComThread : IDisposable
- {
- private const uint WM_APP = 0x8000;
- private const uint PM_NOREMOVE = 0x0000;
-
- private static readonly ILogger Log = Serilog.Log.ForContext();
- private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5);
- private readonly TaskCompletionSource _ready = new();
-
- private readonly Thread _thread;
- private readonly ConcurrentQueue _workItems = new();
- private long _appMessages;
- private long _dispatchedMessages;
- private bool _disposed;
- private DateTime _lastLogTime;
- private volatile uint _nativeThreadId;
- private volatile bool _pumpExited;
-
- private long _totalMessages;
- private long _workItemsExecuted;
-
- ///
- /// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
- ///
- public StaComThread()
- {
- _thread = new Thread(ThreadEntry)
- {
- Name = "MxAccess-STA",
- IsBackground = true
- };
- _thread.SetApartmentState(ApartmentState.STA);
- }
-
- ///
- /// Gets a value indicating whether the STA thread is running and able to accept work.
- ///
- public bool IsRunning => _nativeThreadId != 0 && !_disposed && !_pumpExited;
-
- ///
- /// Stops the STA thread and releases the message-pump resources used for COM interop.
- ///
- public void Dispose()
- {
- if (_disposed) return;
- _disposed = true;
-
- try
- {
- if (_nativeThreadId != 0 && !_pumpExited)
- PostThreadMessage(_nativeThreadId, WM_APP + 1, IntPtr.Zero, IntPtr.Zero);
- _thread.Join(TimeSpan.FromSeconds(5));
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Error shutting down STA COM thread");
- }
-
- DrainAndFaultQueue();
- Log.Information("STA COM thread stopped");
- }
-
- ///
- /// Starts the STA thread and waits until its message pump is ready for COM work.
- ///
- public void Start()
- {
- _thread.Start();
- _ready.Task.GetAwaiter().GetResult();
- Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
- }
-
- ///
- /// Queues an action to execute on the STA thread.
- ///
- /// The work item to execute on the STA thread.
- /// A task that completes when the action has finished executing.
- public Task RunAsync(Action action)
- {
- if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
- if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited");
-
- var tcs = new TaskCompletionSource();
- _workItems.Enqueue(new WorkItem
- {
- Execute = () =>
- {
- try
- {
- action();
- tcs.TrySetResult(true);
- }
- catch (Exception ex)
- {
- tcs.TrySetException(ex);
- }
- },
- Fault = ex => tcs.TrySetException(ex)
- });
-
- if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero))
- {
- _pumpExited = true;
- DrainAndFaultQueue();
- }
-
- return tcs.Task;
- }
-
- ///
- /// Queues a function to execute on the STA thread and returns its result.
- ///
- /// The result type produced by the function.
- /// The work item to execute on the STA thread.
- /// A task that completes with the function result.
- public Task RunAsync(Func func)
- {
- if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
- if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited");
-
- var tcs = new TaskCompletionSource();
- _workItems.Enqueue(new WorkItem
- {
- Execute = () =>
- {
- try
- {
- tcs.TrySetResult(func());
- }
- catch (Exception ex)
- {
- tcs.TrySetException(ex);
- }
- },
- Fault = ex => tcs.TrySetException(ex)
- });
-
- if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero))
- {
- _pumpExited = true;
- DrainAndFaultQueue();
- }
-
- return tcs.Task;
- }
-
- private void ThreadEntry()
- {
- try
- {
- _nativeThreadId = GetCurrentThreadId();
-
- MSG msg;
- PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_NOREMOVE);
-
- _ready.TrySetResult(true);
- _lastLogTime = DateTime.UtcNow;
-
- Log.Debug("STA message pump entering loop");
-
- while (GetMessage(out msg, IntPtr.Zero, 0, 0) > 0)
- {
- _totalMessages++;
-
- if (msg.message == WM_APP)
- {
- _appMessages++;
- DrainQueue();
- }
- else if (msg.message == WM_APP + 1)
- {
- DrainQueue();
- PostQuitMessage(0);
- }
- else
- {
- _dispatchedMessages++;
- TranslateMessage(ref msg);
- DispatchMessage(ref msg);
- }
-
- LogPumpStatsIfDue();
- }
-
- Log.Information(
- "STA message pump exited (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})",
- _totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted);
- }
- catch (Exception ex)
- {
- Log.Error(ex, "STA COM thread crashed");
- _ready.TrySetException(ex);
- }
- finally
- {
- _pumpExited = true;
- DrainAndFaultQueue();
- }
- }
-
- private void DrainQueue()
- {
- while (_workItems.TryDequeue(out var workItem))
- {
- _workItemsExecuted++;
- try
- {
- workItem.Execute();
- }
- catch (Exception ex)
- {
- Log.Error(ex, "Unhandled exception in STA work item");
- }
- }
- }
-
- private void DrainAndFaultQueue()
- {
- var faultException = new InvalidOperationException("STA COM thread pump has exited");
- while (_workItems.TryDequeue(out var workItem))
- {
- try
- {
- workItem.Fault(faultException);
- }
- catch
- {
- // Faulting a TCS should not throw, but guard against it
- }
- }
- }
-
- private void LogPumpStatsIfDue()
- {
- var now = DateTime.UtcNow;
- if (now - _lastLogTime < PumpLogInterval) return;
- Log.Debug(
- "STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}",
- _totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted, _workItems.Count);
- _lastLogTime = now;
- }
-
- private sealed class WorkItem
- {
- public Action Execute { get; set; }
- public Action Fault { get; set; }
- }
-
- #region Win32 PInvoke
-
- [StructLayout(LayoutKind.Sequential)]
- private struct MSG
- {
- public IntPtr hwnd;
- public uint message;
- public IntPtr wParam;
- public IntPtr lParam;
- public uint time;
- public POINT pt;
- }
-
- [StructLayout(LayoutKind.Sequential)]
- private struct POINT
- {
- public int x;
- public int y;
- }
-
- [DllImport("user32.dll")]
- private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
-
- [DllImport("user32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool TranslateMessage(ref MSG lpMsg);
-
- [DllImport("user32.dll")]
- private static extern IntPtr DispatchMessage(ref MSG lpMsg);
-
- [DllImport("user32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam);
-
- [DllImport("user32.dll")]
- private static extern void PostQuitMessage(int nExitCode);
-
- [DllImport("user32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax,
- uint wRemoveMsg);
-
- [DllImport("kernel32.dll")]
- private static extern uint GetCurrentThreadId();
-
- #endregion
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceBuilder.cs
deleted file mode 100644
index be1a286..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceBuilder.cs
+++ /dev/null
@@ -1,224 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
-{
- ///
- /// Builds the tag reference mappings from Galaxy hierarchy and attributes.
- /// Testable without an OPC UA server. (OPC-002, OPC-003, OPC-004)
- ///
- public class AddressSpaceBuilder
- {
- private static readonly ILogger Log = Serilog.Log.ForContext();
-
- ///
- /// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes
- /// nodes.
- ///
- /// The Galaxy object hierarchy returned by the repository.
- /// The Galaxy attribute rows associated with the hierarchy.
- /// An address-space model containing roots, variables, and tag-reference mappings.
- public static AddressSpaceModel Build(List hierarchy, List attributes)
- {
- var model = new AddressSpaceModel();
- var objectMap = hierarchy.ToDictionary(h => h.GobjectId);
-
- var attrsByObject = attributes
- .GroupBy(a => a.GobjectId)
- .ToDictionary(g => g.Key, g => g.ToList());
-
- // Build parent→children map
- var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
- .ToDictionary(g => g.Key, g => g.ToList());
-
- // Find root objects (parent not in hierarchy)
- var knownIds = new HashSet(hierarchy.Select(h => h.GobjectId));
-
- foreach (var obj in hierarchy)
- {
- var nodeInfo = BuildNodeInfo(obj, attrsByObject, childrenByParent, model);
-
- if (!knownIds.Contains(obj.ParentGobjectId))
- model.RootNodes.Add(nodeInfo);
- }
-
- Log.Information("Address space model: {Objects} objects, {Variables} variables, {Mappings} tag refs",
- model.ObjectCount, model.VariableCount, model.NodeIdToTagReference.Count);
-
- return model;
- }
-
- private static NodeInfo BuildNodeInfo(GalaxyObjectInfo obj,
- Dictionary> attrsByObject,
- Dictionary> childrenByParent,
- AddressSpaceModel model)
- {
- var node = new NodeInfo
- {
- GobjectId = obj.GobjectId,
- TagName = obj.TagName,
- BrowseName = obj.BrowseName,
- ParentGobjectId = obj.ParentGobjectId,
- IsArea = obj.IsArea
- };
-
- if (!obj.IsArea)
- model.ObjectCount++;
-
- if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs))
- foreach (var attr in attrs)
- {
- node.Attributes.Add(new AttributeNodeInfo
- {
- AttributeName = attr.AttributeName,
- FullTagReference = attr.FullTagReference,
- MxDataType = attr.MxDataType,
- IsArray = attr.IsArray,
- ArrayDimension = attr.ArrayDimension,
- PrimitiveName = attr.PrimitiveName ?? "",
- SecurityClassification = attr.SecurityClassification,
- IsHistorized = attr.IsHistorized,
- IsAlarm = attr.IsAlarm
- });
-
- model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference;
- model.VariableCount++;
- }
-
- return node;
- }
-
- 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;
- }
-
- ///
- /// Node info for the address space tree.
- ///
- public class NodeInfo
- {
- ///
- /// Gets or sets the Galaxy object identifier represented by this address-space node.
- ///
- public int GobjectId { get; set; }
-
- ///
- /// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata.
- ///
- public string TagName { get; set; } = "";
-
- ///
- /// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node.
- ///
- public string BrowseName { get; set; } = "";
-
- ///
- /// Gets or sets the parent Galaxy object identifier used to assemble the tree.
- ///
- public int ParentGobjectId { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the node represents a Galaxy area folder.
- ///
- public bool IsArea { get; set; }
-
- ///
- /// Gets or sets the attribute nodes published beneath this object.
- ///
- public List Attributes { get; set; } = new();
-
- ///
- /// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy.
- ///
- public List Children { get; set; } = new();
- }
-
- ///
- /// Lightweight description of an attribute node that will become an OPC UA variable.
- ///
- public class AttributeNodeInfo
- {
- ///
- /// Gets or sets the Galaxy attribute name published under the object.
- ///
- public string AttributeName { get; set; } = "";
-
- ///
- /// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions.
- ///
- public string FullTagReference { get; set; } = "";
-
- ///
- /// Gets or sets the Galaxy data type code used to pick the OPC UA variable type.
- ///
- public int MxDataType { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the attribute is modeled as an array.
- ///
- public bool IsArray { get; set; }
-
- ///
- /// Gets or sets the declared array length when the attribute is a fixed-size array.
- ///
- public int? ArrayDimension { get; set; }
-
- ///
- /// Gets or sets the primitive name that groups the attribute under a sub-object node.
- /// Empty for root-level attributes.
- ///
- public string PrimitiveName { get; set; } = "";
-
- ///
- /// Gets or sets the Galaxy security classification that determines OPC UA write access.
- ///
- public int SecurityClassification { get; set; } = 1;
-
- ///
- /// Gets or sets a value indicating whether the attribute is historized.
- ///
- public bool IsHistorized { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the attribute is an alarm.
- ///
- public bool IsAlarm { get; set; }
- }
-
- ///
- /// Result of building the address space model.
- ///
- public class AddressSpaceModel
- {
- ///
- /// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace.
- ///
- public List RootNodes { get; set; } = new();
-
- ///
- /// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
- ///
- public Dictionary NodeIdToTagReference { get; set; } =
- new(StringComparer.OrdinalIgnoreCase);
-
- ///
- /// Gets or sets the number of non-area Galaxy objects included in the model.
- ///
- public int ObjectCount { get; set; }
-
- ///
- /// Gets or sets the number of variable nodes created from Galaxy attributes.
- ///
- public int VariableCount { get; set; }
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceDiff.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceDiff.cs
deleted file mode 100644
index d91167a..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceDiff.cs
+++ /dev/null
@@ -1,132 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
-{
- ///
- /// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes.
- ///
- public static class AddressSpaceDiff
- {
- ///
- /// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference.
- ///
- /// The previously published Galaxy object hierarchy snapshot.
- /// The previously published Galaxy attribute snapshot keyed to the old hierarchy.
- /// The latest Galaxy object hierarchy snapshot pulled from the repository.
- /// The latest Galaxy attribute snapshot that should be reflected in the OPC UA namespace.
- public static HashSet FindChangedGobjectIds(
- List oldHierarchy, List oldAttributes,
- List newHierarchy, List newAttributes)
- {
- var changed = new HashSet();
-
- var oldObjects = oldHierarchy.ToDictionary(h => h.GobjectId);
- var newObjects = newHierarchy.ToDictionary(h => h.GobjectId);
-
- // Added objects
- foreach (var id in newObjects.Keys)
- if (!oldObjects.ContainsKey(id))
- changed.Add(id);
-
- // Removed objects
- foreach (var id in oldObjects.Keys)
- if (!newObjects.ContainsKey(id))
- changed.Add(id);
-
- // Modified objects
- foreach (var kvp in newObjects)
- if (oldObjects.TryGetValue(kvp.Key, out var oldObj) && !ObjectsEqual(oldObj, kvp.Value))
- changed.Add(kvp.Key);
-
- // Attribute changes — group by gobject_id and compare
- var oldAttrsByObj = oldAttributes.GroupBy(a => a.GobjectId)
- .ToDictionary(g => g.Key, g => g.ToList());
- var newAttrsByObj = newAttributes.GroupBy(a => a.GobjectId)
- .ToDictionary(g => g.Key, g => g.ToList());
-
- // All gobject_ids that have attributes in either old or new
- var allAttrGobjectIds = new HashSet(oldAttrsByObj.Keys);
- allAttrGobjectIds.UnionWith(newAttrsByObj.Keys);
-
- foreach (var id in allAttrGobjectIds)
- {
- if (changed.Contains(id))
- continue;
-
- oldAttrsByObj.TryGetValue(id, out var oldAttrs);
- newAttrsByObj.TryGetValue(id, out var newAttrs);
-
- if (!AttributeSetsEqual(oldAttrs, newAttrs))
- changed.Add(id);
- }
-
- return changed;
- }
-
- ///
- /// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy.
- ///
- /// The root Galaxy objects that were detected as changed between snapshots.
- /// The hierarchy used to include descendant objects whose OPC UA nodes must also be rebuilt.
- public static HashSet ExpandToSubtrees(HashSet changed, List hierarchy)
- {
- var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
- .ToDictionary(g => g.Key, g => g.Select(h => h.GobjectId).ToList());
-
- var expanded = new HashSet(changed);
- var queue = new Queue(changed);
-
- while (queue.Count > 0)
- {
- var id = queue.Dequeue();
- if (childrenByParent.TryGetValue(id, out var children))
- foreach (var childId in children)
- if (expanded.Add(childId))
- queue.Enqueue(childId);
- }
-
- return expanded;
- }
-
- private static bool ObjectsEqual(GalaxyObjectInfo a, GalaxyObjectInfo b)
- {
- return a.TagName == b.TagName
- && a.BrowseName == b.BrowseName
- && a.ContainedName == b.ContainedName
- && a.ParentGobjectId == b.ParentGobjectId
- && a.IsArea == b.IsArea;
- }
-
- private static bool AttributeSetsEqual(List? a, List? b)
- {
- if (a == null && b == null) return true;
- if (a == null || b == null) return false;
- if (a.Count != b.Count) return false;
-
- // Sort by a stable key and compare pairwise
- var sortedA = a.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
- var sortedB = b.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
-
- for (var i = 0; i < sortedA.Count; i++)
- if (!AttributesEqual(sortedA[i], sortedB[i]))
- return false;
-
- return true;
- }
-
- private static bool AttributesEqual(GalaxyAttributeInfo a, GalaxyAttributeInfo b)
- {
- return a.AttributeName == b.AttributeName
- && a.FullTagReference == b.FullTagReference
- && a.MxDataType == b.MxDataType
- && a.IsArray == b.IsArray
- && a.ArrayDimension == b.ArrayDimension
- && a.PrimitiveName == b.PrimitiveName
- && a.SecurityClassification == b.SecurityClassification
- && a.IsHistorized == b.IsHistorized
- && a.IsAlarm == b.IsAlarm;
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/DataValueConverter.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/DataValueConverter.cs
deleted file mode 100644
index 06b02f9..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/DataValueConverter.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-using System;
-using Opc.Ua;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
-{
- ///
- /// Converts between domain Vtq and OPC UA DataValue. Handles all data_type_mapping.md types. (OPC-005, OPC-007)
- ///
- public static class DataValueConverter
- {
- ///
- /// Converts a bridge VTQ snapshot into an OPC UA data value.
- ///
- /// The VTQ snapshot to convert.
- /// An OPC UA data value suitable for reads and subscriptions.
- public static DataValue FromVtq(Vtq vtq)
- {
- var statusCode = new StatusCode(QualityMapper.MapToOpcUaStatusCode(vtq.Quality));
-
- var dataValue = new DataValue
- {
- Value = ConvertToOpcUaValue(vtq.Value),
- StatusCode = statusCode,
- SourceTimestamp = vtq.Timestamp.Kind == DateTimeKind.Utc
- ? vtq.Timestamp
- : vtq.Timestamp.ToUniversalTime(),
- ServerTimestamp = DateTime.UtcNow
- };
-
- return dataValue;
- }
-
- ///
- /// Converts an OPC UA data value back into a bridge VTQ snapshot.
- ///
- /// The OPC UA data value to convert.
- /// A VTQ snapshot containing the converted value, timestamp, and derived quality.
- public static Vtq ToVtq(DataValue dataValue)
- {
- var quality = MapStatusCodeToQuality(dataValue.StatusCode);
- var timestamp = dataValue.SourceTimestamp != DateTime.MinValue
- ? dataValue.SourceTimestamp
- : DateTime.UtcNow;
-
- return new Vtq(dataValue.Value, timestamp, quality);
- }
-
- private static object? ConvertToOpcUaValue(object? value)
- {
- if (value == null) return null;
-
- return value switch
- {
- bool _ => value,
- int _ => value,
- float _ => value,
- double _ => value,
- string _ => value,
- DateTime dt => dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(),
- TimeSpan ts => ts.TotalSeconds, // ElapsedTime → Double seconds
- short s => (int)s,
- long l => l,
- byte b => (int)b,
- bool[] _ => value,
- int[] _ => value,
- float[] _ => value,
- double[] _ => value,
- string[] _ => value,
- DateTime[] _ => value,
- _ => value.ToString()
- };
- }
-
- private static Quality MapStatusCodeToQuality(StatusCode statusCode)
- {
- var code = statusCode.Code;
- if (StatusCode.IsGood(statusCode)) return Quality.Good;
- if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
-
- return code switch
- {
- StatusCodes.BadNotConnected => Quality.BadNotConnected,
- StatusCodes.BadCommunicationError => Quality.BadCommFailure,
- StatusCodes.BadConfigurationError => Quality.BadConfigError,
- StatusCodes.BadOutOfService => Quality.BadOutOfService,
- StatusCodes.BadWaitingForInitialData => Quality.BadWaitingForInitialData,
- _ => Quality.Bad
- };
- }
- }
-}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxNodeManager.cs
deleted file mode 100644
index 4e675e0..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxNodeManager.cs
+++ /dev/null
@@ -1,2924 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Opc.Ua;
-using Opc.Ua.Server;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Host.Domain;
-using ZB.MOM.WW.OtOpcUa.Host.Historian;
-using ZB.MOM.WW.OtOpcUa.Host.Metrics;
-using ZB.MOM.WW.OtOpcUa.Host.MxAccess;
-using ZB.MOM.WW.OtOpcUa.Host.Utilities;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
-{
- ///
- /// Custom node manager that builds the OPC UA address space from Galaxy hierarchy data.
- /// (OPC-002 through OPC-013)
- ///
- public class LmxNodeManager : CustomNodeManager2
- {
- private static readonly ILogger Log = Serilog.Log.ForContext();
- private readonly Dictionary _alarmAckedTags = new(StringComparer.OrdinalIgnoreCase);
- private readonly NodeId? _alarmAckRoleId;
-
- // Alarm tracking: maps InAlarm tag reference → alarm source info
- private readonly Dictionary _alarmInAlarmTags = new(StringComparer.OrdinalIgnoreCase);
- // Reverse lookups: priority/description tag reference → alarm info for cache updates
- private readonly Dictionary _alarmPriorityTags = new(StringComparer.OrdinalIgnoreCase);
- private readonly Dictionary _alarmDescTags = new(StringComparer.OrdinalIgnoreCase);
- private readonly bool _alarmTrackingEnabled;
- private readonly AlarmObjectFilter? _alarmObjectFilter;
- private int _alarmFilterIncludedObjectCount;
- private readonly bool _anonymousCanWrite;
-
- // Host → list of OPC UA variable nodes transitively hosted by that host. Populated during
- // BuildAddressSpace by walking each variable's owning object's hosted_by_gobject_id chain
- // up to the nearest $WinPlatform or $AppEngine. A variable that lives under a nested host
- // (e.g. a user object under an Engine under a Platform) appears in BOTH the Engine's and
- // the Platform's list. Used by MarkHostVariablesBadQuality / ClearHostVariablesBadQuality
- // when the galaxy runtime probe reports a host transition.
- private readonly Dictionary> _hostedVariables =
- new Dictionary>();
-
- // Tag reference → list of owning host gobject_ids (typically Engine + Platform). Populated
- // alongside _hostedVariables during BuildAddressSpace. Used by the Read path to short-circuit
- // on-demand reads of tags under a Stopped runtime host — preventing MxAccess from returning
- // stale "Good" cached values. Multiple tag refs on the same Galaxy object share the same
- // host-id list by reference (safe because the list is read-only after build).
- private readonly Dictionary> _hostIdsByTagRef =
- new Dictionary>(StringComparer.OrdinalIgnoreCase);
-
- // Runtime status probe manager — null when MxAccessConfiguration.RuntimeStatusProbesEnabled
- // is false. Built at construction time and synced to the hierarchy on every BuildAddressSpace.
- private readonly GalaxyRuntimeProbeManager? _galaxyRuntimeProbeManager;
-
- // Queue of host runtime state transitions deferred from the probe callback (which runs on
- // the MxAccess STA thread) to the dispatch thread, where the node manager Lock can be taken
- // safely. Enqueue → signal dispatch → dispatch thread drains and calls Mark/Clear under Lock.
- // Required because invoking Mark/Clear directly from the STA callback deadlocks against any
- // worker thread currently inside Read waiting for an MxAccess round-trip.
- private readonly ConcurrentQueue<(int GobjectId, bool Stopped)> _pendingHostStateChanges =
- new ConcurrentQueue<(int, bool)>();
-
- // Synthetic $-prefixed OPC UA child variables exposed under each $WinPlatform / $AppEngine
- // object so clients can subscribe to runtime state changes without polling the dashboard.
- // Populated during BuildAddressSpace and updated from the dispatch-thread queue drain
- // alongside Mark/Clear, using the same deadlock-safe path.
- private readonly Dictionary _runtimeStatusNodes =
- new Dictionary();
-
- private sealed class HostRuntimeStatusNodes
- {
- public BaseDataVariableState RuntimeState = null!;
- public BaseDataVariableState LastCallbackTime = null!;
- public BaseDataVariableState LastScanState = null!;
- public BaseDataVariableState LastStateChangeTime = null!;
- public BaseDataVariableState FailureCount = null!;
- public BaseDataVariableState LastError = null!;
- }
- private readonly AutoResetEvent _dataChangeSignal = new(false);
- private readonly Dictionary> _gobjectToTagRefs = new();
- private readonly HistoryContinuationPointManager _historyContinuations = new();
- private readonly IHistorianDataSource? _historianDataSource;
- private readonly PerformanceMetrics _metrics;
-
- private readonly IMxAccessClient _mxAccessClient;
- private readonly string _namespaceUri;
-
- // NodeId → full_tag_reference for read/write resolution
- private readonly Dictionary _nodeIdToTagReference = new(StringComparer.OrdinalIgnoreCase);
-
- // Incremental sync: persistent node map and reverse lookup
- private readonly Dictionary _nodeMap = new();
-
- // Data change dispatch queue: decouples MXAccess STA callbacks from OPC UA framework Lock
- private readonly ConcurrentDictionary _pendingDataChanges = new(StringComparer.OrdinalIgnoreCase);
-
- // Ref-counted MXAccess subscriptions
- private readonly Dictionary _subscriptionRefCounts = new(StringComparer.OrdinalIgnoreCase);
- private readonly Dictionary _tagMetadata = new(StringComparer.OrdinalIgnoreCase);
-
- private readonly Dictionary _tagToVariableNode =
- new(StringComparer.OrdinalIgnoreCase);
-
- private readonly NodeId? _writeConfigureRoleId;
- private readonly NodeId? _writeOperateRoleId;
- private readonly NodeId? _writeTuneRoleId;
- private readonly TimeSpan _mxAccessRequestTimeout;
- private readonly TimeSpan _historianRequestTimeout;
- private long _dispatchCycleCount;
- private long _suppressedUpdatesCount;
- private volatile bool _dispatchDisposed;
- private volatile bool _dispatchRunning;
- private Thread? _dispatchThread;
-
- private IDictionary>? _externalReferences;
- private List? _lastAttributes;
- private List? _lastHierarchy;
- private DateTime _lastMetricsReportTime = DateTime.UtcNow;
- private long _lastReportedMxChangeEvents;
- private long _totalDispatchBatchSize;
-
- // Dispatch queue metrics
- private long _totalMxChangeEvents;
-
- // Alarm instrumentation counters
- private long _alarmTransitionCount;
- private long _alarmAckEventCount;
- private long _alarmAckWriteFailures;
-
- // Background subscribe tracking: every fire-and-forget SubscribeAsync for alarm auto-subscribe
- // and transferred-subscription restore is registered here so shutdown can drain pending work
- // with a bounded timeout, and so tests can observe pending count without races.
- private readonly ConcurrentDictionary _pendingBackgroundSubscribes =
- new ConcurrentDictionary();
- private long _backgroundSubscribeCounter;
-
- ///
- /// Initializes a new node manager for the Galaxy-backed OPC UA namespace.
- ///
- /// The hosting OPC UA server internals.
- /// The OPC UA application configuration for the host.
- /// The namespace URI that identifies the Galaxy model to clients.
- /// The runtime client used to service reads, writes, and subscriptions.
- /// The metrics collector used to track node manager activity.
- /// The optional historian adapter used to satisfy OPC UA history read requests.
- /// Enables alarm-condition state generation for Galaxy attributes modeled as alarms.
- /// Optional template-based object filter. When supplied and enabled, only Galaxy
- /// objects whose template derivation chain matches a pattern (and their descendants) contribute alarm conditions.
- /// A or disabled filter preserves the current unfiltered behavior.
- public LmxNodeManager(
- IServerInternal server,
- ApplicationConfiguration configuration,
- string namespaceUri,
- IMxAccessClient mxAccessClient,
- PerformanceMetrics metrics,
- IHistorianDataSource? historianDataSource = null,
- bool alarmTrackingEnabled = false,
- bool anonymousCanWrite = true,
- NodeId? writeOperateRoleId = null,
- NodeId? writeTuneRoleId = null,
- NodeId? writeConfigureRoleId = null,
- NodeId? alarmAckRoleId = null,
- AlarmObjectFilter? alarmObjectFilter = null,
- bool runtimeStatusProbesEnabled = false,
- int runtimeStatusUnknownTimeoutSeconds = 15,
- int mxAccessRequestTimeoutSeconds = 30,
- int historianRequestTimeoutSeconds = 60)
- : base(server, configuration, namespaceUri)
- {
- _namespaceUri = namespaceUri;
- _mxAccessClient = mxAccessClient;
- _metrics = metrics;
- _historianDataSource = historianDataSource;
- _alarmTrackingEnabled = alarmTrackingEnabled;
- _alarmObjectFilter = alarmObjectFilter;
- _anonymousCanWrite = anonymousCanWrite;
- _writeOperateRoleId = writeOperateRoleId;
- _writeTuneRoleId = writeTuneRoleId;
- _writeConfigureRoleId = writeConfigureRoleId;
- _alarmAckRoleId = alarmAckRoleId;
- _mxAccessRequestTimeout = TimeSpan.FromSeconds(Math.Max(1, mxAccessRequestTimeoutSeconds));
- _historianRequestTimeout = TimeSpan.FromSeconds(Math.Max(1, historianRequestTimeoutSeconds));
-
- if (runtimeStatusProbesEnabled)
- {
- // Probe transition callbacks are deferred through a concurrent queue onto the
- // dispatch thread — they cannot run synchronously from the STA callback thread
- // because MarkHostVariablesBadQuality needs the node manager Lock, which may be
- // held by a worker thread waiting on an MxAccess round-trip.
- _galaxyRuntimeProbeManager = new GalaxyRuntimeProbeManager(
- _mxAccessClient,
- runtimeStatusUnknownTimeoutSeconds,
- gobjectId =>
- {
- _pendingHostStateChanges.Enqueue((gobjectId, true));
- try { _dataChangeSignal.Set(); } catch (ObjectDisposedException) { }
- },
- gobjectId =>
- {
- _pendingHostStateChanges.Enqueue((gobjectId, false));
- try { _dataChangeSignal.Set(); } catch (ObjectDisposedException) { }
- });
- }
-
- // Wire up data change delivery
- _mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
-
- // Start background dispatch thread
- StartDispatchThread();
- }
-
- ///
- /// Gets the mapping from OPC UA node identifiers to the Galaxy tag references used for runtime I/O.
- ///
- public IReadOnlyDictionary NodeIdToTagReference => _nodeIdToTagReference;
-
- ///
- /// Gets the number of variable nodes currently published from Galaxy attributes.
- ///
- public int VariableNodeCount { get; private set; }
-
- ///
- /// Gets the number of non-area object nodes currently published from the Galaxy hierarchy.
- ///
- public int ObjectNodeCount { get; private set; }
-
- ///
- /// Gets the total number of MXAccess data change events received since startup.
- ///
- public long TotalMxChangeEvents => Interlocked.Read(ref _totalMxChangeEvents);
-
- ///
- /// Gets the number of items currently waiting in the dispatch queue.
- ///
- public int PendingDataChangeCount => _pendingDataChanges.Count;
-
- ///
- /// Gets the most recently computed MXAccess data change events per second.
- ///
- public double MxChangeEventsPerSecond { get; private set; }
-
- ///
- /// Gets the most recently computed average dispatch batch size (proxy for queue depth under load).
- ///
- public double AverageDispatchBatchSize { get; private set; }
-
- ///
- /// Gets a value indicating whether alarm condition tracking is enabled for this node manager.
- ///
- public bool AlarmTrackingEnabled => _alarmTrackingEnabled;
-
- ///
- /// Gets a value indicating whether the template-based alarm object filter is enabled.
- ///
- public bool AlarmFilterEnabled => _alarmObjectFilter?.Enabled ?? false;
-
- ///
- /// Gets the number of compiled alarm filter patterns.
- ///
- public int AlarmFilterPatternCount => _alarmObjectFilter?.PatternCount ?? 0;
-
- ///
- /// Gets the number of Galaxy objects included by the alarm filter during the most recent address-space build.
- ///
- public int AlarmFilterIncludedObjectCount => _alarmFilterIncludedObjectCount;
-
- ///
- /// Gets the raw alarm filter patterns exactly as configured, for display on the status dashboard.
- /// Returns an empty list when no filter is active.
- ///
- public IReadOnlyList AlarmFilterPatterns =>
- _alarmObjectFilter?.RawPatterns ?? Array.Empty();
-
- ///
- /// Gets a snapshot of the runtime host states (Platforms + AppEngines). Returns an empty
- /// list when runtime status probing is disabled. The snapshot respects MxAccess transport
- /// state — when the client is disconnected, every entry is returned as
- /// .
- ///
- public IReadOnlyList RuntimeStatuses =>
- _galaxyRuntimeProbeManager?.GetSnapshot() ?? (IReadOnlyList)Array.Empty();
-
- ///
- /// Gets the number of bridge-owned runtime status probe subscriptions. Surfaced on the
- /// dashboard Subscriptions panel to distinguish probe overhead from client subscriptions.
- ///
- public int ActiveRuntimeProbeCount => _galaxyRuntimeProbeManager?.ActiveProbeCount ?? 0;
-
- ///
- /// Gets the runtime historian health snapshot, or when the historian
- /// plugin is not loaded. Surfaced on the status dashboard so operators can detect query
- /// failures that the load-time plugin status cannot catch.
- ///
- public HistorianHealthSnapshot? HistorianHealth => _historianDataSource?.GetHealthSnapshot();
-
- ///
- /// Gets the number of distinct alarm conditions currently tracked (one per alarm attribute).
- ///
- public int AlarmConditionCount => _alarmInAlarmTags.Count;
-
- ///
- /// Gets the number of alarms currently in the InAlarm=true state.
- ///
- public int ActiveAlarmCount => CountActiveAlarms();
-
- ///
- /// Gets the total number of InAlarm transition events observed in the dispatch loop since startup.
- ///
- public long AlarmTransitionCount => Interlocked.Read(ref _alarmTransitionCount);
-
- ///
- /// Gets the total number of alarm acknowledgement transition events observed since startup.
- ///
- public long AlarmAckEventCount => Interlocked.Read(ref _alarmAckEventCount);
-
- ///
- /// Gets the total number of MXAccess AckMsg writes that failed while processing alarm acknowledges.
- ///
- public long AlarmAckWriteFailures => Interlocked.Read(ref _alarmAckWriteFailures);
-
- private int CountActiveAlarms()
- {
- var count = 0;
- lock (Lock)
- {
- foreach (var info in _alarmInAlarmTags.Values)
- if (info.LastInAlarm) count++;
- }
- return count;
- }
-
- ///
- public override void CreateAddressSpace(IDictionary> externalReferences)
- {
- lock (Lock)
- {
- _externalReferences = externalReferences;
- base.CreateAddressSpace(externalReferences);
- }
- }
-
- ///
- /// Builds the address space from Galaxy hierarchy and attributes data. (OPC-002, OPC-003)
- ///
- /// The Galaxy object hierarchy that defines folders and objects in the namespace.
- /// The Galaxy attributes that become OPC UA variable nodes.
- public void BuildAddressSpace(List hierarchy, List attributes)
- {
- lock (Lock)
- {
- _nodeIdToTagReference.Clear();
- _tagToVariableNode.Clear();
- _tagMetadata.Clear();
- _alarmInAlarmTags.Clear();
- _alarmAckedTags.Clear();
- _alarmPriorityTags.Clear();
- _alarmDescTags.Clear();
- _nodeMap.Clear();
- _gobjectToTagRefs.Clear();
- _hostedVariables.Clear();
- _hostIdsByTagRef.Clear();
- _runtimeStatusNodes.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
- {
- 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;
-
- // Attach bridge-owned $RuntimeState / $LastCallbackTime / ... synthetic child
- // variables so OPC UA clients can subscribe to host state changes without
- // polling the dashboard. Only $WinPlatform (1) and $AppEngine (3) get them.
- if (_galaxyRuntimeProbeManager != null
- && (obj.CategoryId == 1 || obj.CategoryId == 3)
- && node is BaseObjectState hostObj)
- {
- _runtimeStatusNodes[obj.GobjectId] =
- CreateHostRuntimeStatusNodes(hostObj, obj.TagName);
- }
-
- // 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(
- 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(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)
- {
- var includedIds = ResolveAlarmFilterIncludedIds(sorted);
- foreach (var obj in sorted)
- {
- if (obj.IsArea) continue;
- if (includedIds != null && !includedIds.Contains(obj.GobjectId)) 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;
- condition.OnConfirm = OnAlarmConfirm;
- condition.OnAddComment = OnAlarmAddComment;
- condition.OnEnableDisable = OnAlarmEnableDisable;
- condition.OnShelve = OnAlarmShelve;
- condition.OnTimedUnshelve = OnAlarmTimedUnshelve;
-
- // 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;
- if (!string.IsNullOrEmpty(alarmInfo.PriorityTagReference))
- _alarmPriorityTags[alarmInfo.PriorityTagReference] = alarmInfo;
- if (!string.IsNullOrEmpty(alarmInfo.DescAttrNameTagReference))
- _alarmDescTags[alarmInfo.DescAttrNameTagReference] = alarmInfo;
- hasAlarms = true;
- }
-
- // Enable EventNotifier on this node and all ancestors so alarm events propagate
- if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode))
- EnableEventNotifierUpChain(objNode);
- }
- }
-
- // Auto-subscribe to InAlarm tags so we detect alarm transitions
- if (_alarmTrackingEnabled)
- SubscribeAlarmTags();
-
- BuildHostedVariablesMap(hierarchy);
-
- // Sync the galaxy runtime probe set against the rebuilt hierarchy. This runs
- // synchronously on the calling thread and issues AdviseSupervisory per host —
- // expected 500ms-1s additional startup latency for a large multi-host galaxy.
- // Bounded by _mxAccessRequestTimeout so a hung probe sync cannot park the address
- // space rebuild indefinitely; on timeout we log a warning and continue with the
- // partial probe set (probe sync is advisory, not required for address space correctness).
- if (_galaxyRuntimeProbeManager != null)
- {
- try
- {
- SyncOverAsync.WaitSync(
- _galaxyRuntimeProbeManager.SyncAsync(hierarchy),
- _mxAccessRequestTimeout,
- "GalaxyRuntimeProbeManager.SyncAsync");
- }
- catch (TimeoutException ex)
- {
- Log.Warning(ex, "Runtime probe sync exceeded {Timeout}s; continuing with partial probe set",
- _mxAccessRequestTimeout.TotalSeconds);
- }
- }
-
- _lastHierarchy = new List(hierarchy);
- _lastAttributes = new List(attributes);
-
- Log.Information(
- "Address space built: {Objects} objects, {Variables} variables, {Mappings} tag references, {Alarms} alarm tags, {Hosts} runtime hosts",
- ObjectNodeCount, VariableNodeCount, _nodeIdToTagReference.Count, _alarmInAlarmTags.Count,
- _hostedVariables.Count);
- }
- }
-
- ///
- /// Resolves the alarm object filter against the given hierarchy, updates the published include count,
- /// emits a one-line summary log when the filter is active, and warns about patterns that matched nothing.
- /// Returns when no filter is configured so the alarm loop continues unfiltered.
- ///
- private HashSet? ResolveAlarmFilterIncludedIds(IReadOnlyList sorted)
- {
- if (_alarmObjectFilter == null || !_alarmObjectFilter.Enabled)
- {
- _alarmFilterIncludedObjectCount = 0;
- return null;
- }
-
- var includedIds = _alarmObjectFilter.ResolveIncludedObjects(sorted);
- _alarmFilterIncludedObjectCount = includedIds?.Count ?? 0;
-
- Log.Information(
- "Alarm filter: {IncludedCount} of {TotalCount} objects included ({PatternCount} pattern(s))",
- _alarmFilterIncludedObjectCount, sorted.Count, _alarmObjectFilter.PatternCount);
-
- foreach (var unmatched in _alarmObjectFilter.UnmatchedPatterns)
- Log.Warning("Alarm filter pattern matched zero objects: {Pattern}", unmatched);
-
- return includedIds;
- }
-
- ///
- /// Builds the _hostedVariables dictionary from the completed address space. For each
- /// Galaxy object, walks its HostedByGobjectId chain up to the nearest $WinPlatform
- /// or $AppEngine and appends every variable the object owns to that host's list. An
- /// object under an Engine under a Platform appears in BOTH lists so stopping the Platform
- /// invalidates every descendant Engine's variables as well.
- ///
- private void BuildHostedVariablesMap(List hierarchy)
- {
- _hostedVariables.Clear();
- _hostIdsByTagRef.Clear();
- if (hierarchy == null || hierarchy.Count == 0)
- return;
-
- var byId = new Dictionary(hierarchy.Count);
- foreach (var obj in hierarchy)
- byId[obj.GobjectId] = obj;
-
- foreach (var obj in hierarchy)
- {
- if (!_gobjectToTagRefs.TryGetValue(obj.GobjectId, out var tagRefs) || tagRefs.Count == 0)
- continue;
-
- // Collect every variable node owned by this object from the tag→variable map.
- var ownedVariables = new List(tagRefs.Count);
- foreach (var tagRef in tagRefs)
- if (_tagToVariableNode.TryGetValue(tagRef, out var v))
- ownedVariables.Add(v);
-
- if (ownedVariables.Count == 0)
- continue;
-
- // Walk HostedByGobjectId up the chain, collecting every Platform/Engine encountered.
- // Visited set defends against cycles in misconfigured galaxies. Every tag ref owned
- // by this object shares the same ancestorHosts list by reference.
- var ancestorHosts = new List();
- var visited = new HashSet();
- var cursor = obj;
- var depth = 0;
- while (cursor != null && depth < 32 && visited.Add(cursor.GobjectId))
- {
- if (cursor.CategoryId == 1 || cursor.CategoryId == 3)
- ancestorHosts.Add(cursor.GobjectId);
-
- if (cursor.HostedByGobjectId == 0 ||
- !byId.TryGetValue(cursor.HostedByGobjectId, out var next))
- break;
- cursor = next;
- depth++;
- }
-
- if (ancestorHosts.Count == 0)
- continue;
-
- // Append this object's variables to each host's hosted-variables list.
- foreach (var hostId in ancestorHosts)
- {
- if (!_hostedVariables.TryGetValue(hostId, out var list))
- {
- list = new List();
- _hostedVariables[hostId] = list;
- }
- list.AddRange(ownedVariables);
- }
-
- // Register reverse lookup for the Read-path short-circuit.
- foreach (var tagRef in tagRefs)
- _hostIdsByTagRef[tagRef] = ancestorHosts;
- }
- }
-
- ///
- /// Flips every OPC UA variable hosted by the given Galaxy runtime object (Platform or
- /// AppEngine) to . Invoked by the runtime probe
- /// manager's Running → Stopped callback. Safe to call with an unknown gobject id — no-op.
- ///
- /// The runtime host's gobject_id.
- public void MarkHostVariablesBadQuality(int gobjectId)
- {
- List? variables;
- lock (Lock)
- {
- if (!_hostedVariables.TryGetValue(gobjectId, out variables))
- return;
-
- var now = DateTime.UtcNow;
- foreach (var variable in variables)
- {
- variable.StatusCode = StatusCodes.BadOutOfService;
- variable.Timestamp = now;
- variable.ClearChangeMasks(SystemContext, false);
- }
- }
-
- Log.Information(
- "Marked {Count} variable(s) BadOutOfService for stopped host gobject_id={GobjectId}",
- variables.Count, gobjectId);
- }
-
- ///
- /// Creates the six $-prefixed synthetic child variables on a host object so OPC UA
- /// clients can subscribe to runtime state changes without polling the dashboard. All
- /// nodes are read-only and their values are refreshed by
- /// from the dispatch-thread queue drain whenever the host transitions.
- ///
- private HostRuntimeStatusNodes CreateHostRuntimeStatusNodes(BaseObjectState hostNode, string hostTagName)
- {
- var nodes = new HostRuntimeStatusNodes
- {
- RuntimeState = CreateSyntheticVariable(hostNode, hostTagName, "$RuntimeState", DataTypeIds.String, "Unknown"),
- LastCallbackTime = CreateSyntheticVariable(hostNode, hostTagName, "$LastCallbackTime", DataTypeIds.DateTime, DateTime.MinValue),
- LastScanState = CreateSyntheticVariable(hostNode, hostTagName, "$LastScanState", DataTypeIds.Boolean, false),
- LastStateChangeTime = CreateSyntheticVariable(hostNode, hostTagName, "$LastStateChangeTime", DataTypeIds.DateTime, DateTime.MinValue),
- FailureCount = CreateSyntheticVariable(hostNode, hostTagName, "$FailureCount", DataTypeIds.Int64, 0L),
- LastError = CreateSyntheticVariable(hostNode, hostTagName, "$LastError", DataTypeIds.String, "")
- };
- return nodes;
- }
-
- private BaseDataVariableState CreateSyntheticVariable(
- BaseObjectState parent, string parentTagName, string browseName, NodeId dataType, object initialValue)
- {
- var v = CreateVariable(parent, browseName, browseName, dataType, ValueRanks.Scalar);
- v.NodeId = new NodeId(parentTagName + "." + browseName, NamespaceIndex);
- v.Value = initialValue;
- v.StatusCode = StatusCodes.Good;
- v.Timestamp = DateTime.UtcNow;
- v.AccessLevel = AccessLevels.CurrentRead;
- v.UserAccessLevel = AccessLevels.CurrentRead;
- AddPredefinedNode(SystemContext, v);
- return v;
- }
-
- ///
- /// Refreshes the six synthetic child variables on a host from the probe manager's
- /// current snapshot for that host. Called from the dispatch-thread queue drain after
- /// Mark/Clear so the state values propagate to subscribed clients in the same publish
- /// cycle. Takes the node manager internally.
- ///
- private void UpdateHostRuntimeStatusNodes(int gobjectId)
- {
- if (_galaxyRuntimeProbeManager == null)
- return;
-
- HostRuntimeStatusNodes? nodes;
- lock (Lock)
- {
- if (!_runtimeStatusNodes.TryGetValue(gobjectId, out nodes))
- return;
- }
-
- var status = _galaxyRuntimeProbeManager.GetHostStatus(gobjectId);
- if (status == null)
- return;
-
- lock (Lock)
- {
- var now = DateTime.UtcNow;
- SetSynthetic(nodes.RuntimeState, status.State.ToString(), now);
- SetSynthetic(nodes.LastCallbackTime, status.LastStateCallbackTime ?? DateTime.MinValue, now);
- SetSynthetic(nodes.LastScanState, status.LastScanState ?? false, now);
- SetSynthetic(nodes.LastStateChangeTime, status.LastStateChangeTime ?? DateTime.MinValue, now);
- SetSynthetic(nodes.FailureCount, status.FailureCount, now);
- SetSynthetic(nodes.LastError, status.LastError ?? "", now);
- }
- }
-
- private void SetSynthetic(BaseDataVariableState variable, object value, DateTime now)
- {
- variable.Value = value;
- variable.StatusCode = StatusCodes.Good;
- variable.Timestamp = now;
- variable.ClearChangeMasks(SystemContext, false);
- }
-
- ///
- /// Resets every OPC UA variable hosted by the given Galaxy runtime object to
- /// . Invoked by the runtime probe manager's Stopped → Running
- /// callback. Values are left as-is; subsequent MxAccess on-change updates will refresh them
- /// as tags change naturally.
- ///
- /// The runtime host's gobject_id.
- public void ClearHostVariablesBadQuality(int gobjectId)
- {
- var clearedCount = 0;
- var skippedCount = 0;
- lock (Lock)
- {
- var now = DateTime.UtcNow;
- // Iterate the full tag → host-list map so we can skip variables whose other
- // ancestor hosts are still Stopped. Mass-clearing _hostedVariables[gobjectId]
- // would wipe Bad status set by a concurrently-stopped sibling host (e.g.
- // recovering DevPlatform must not clear variables that also live under a
- // still-stopped DevAppEngine).
- foreach (var kv in _hostIdsByTagRef)
- {
- var hostIds = kv.Value;
- if (!hostIds.Contains(gobjectId))
- continue;
-
- var anotherStopped = false;
- for (var i = 0; i < hostIds.Count; i++)
- {
- if (hostIds[i] == gobjectId)
- continue;
- if (_galaxyRuntimeProbeManager != null &&
- _galaxyRuntimeProbeManager.IsHostStopped(hostIds[i]))
- {
- anotherStopped = true;
- break;
- }
- }
- if (anotherStopped)
- {
- skippedCount++;
- continue;
- }
-
- if (_tagToVariableNode.TryGetValue(kv.Key, out var variable))
- {
- variable.StatusCode = StatusCodes.Good;
- variable.Timestamp = now;
- variable.ClearChangeMasks(SystemContext, false);
- clearedCount++;
- }
- }
- }
-
- Log.Information(
- "Cleared bad-quality override on {Count} variable(s) for recovered host gobject_id={GobjectId} (skipped {Skipped} with other stopped ancestors)",
- clearedCount, gobjectId, skippedCount);
- }
-
- 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;
- TrackBackgroundSubscribe(tag, "alarm auto-subscribe");
- }
- }
- }
-
- ///
- /// Issues a fire-and-forget SubscribeAsync for and registers
- /// the resulting task so shutdown can drain pending work with a bounded timeout. The
- /// continuation both removes the completed entry and logs faults with the supplied
- /// .
- ///
- private void TrackBackgroundSubscribe(string tag, string context)
- {
- if (_dispatchDisposed)
- return;
-
- var id = Interlocked.Increment(ref _backgroundSubscribeCounter);
- var task = _mxAccessClient.SubscribeAsync(tag, (_, _) => { });
- _pendingBackgroundSubscribes[id] = task;
- task.ContinueWith(t =>
- {
- _pendingBackgroundSubscribes.TryRemove(id, out _);
- if (t.IsFaulted)
- Log.Warning(t.Exception?.InnerException, "Background subscribe failed ({Context}) for {Tag}",
- context, tag);
- }, TaskContinuationOptions.ExecuteSynchronously);
- }
-
- ///
- /// Gets the number of background subscribe tasks currently in flight. Exposed for tests
- /// and for the status dashboard subscription panel.
- ///
- internal int PendingBackgroundSubscribeCount => _pendingBackgroundSubscribes.Count;
-
- private ServiceResult OnAlarmAcknowledge(
- ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment)
- {
- if (!HasAlarmAckPermission(context))
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
-
- var alarmInfo = _alarmInAlarmTags.Values
- .FirstOrDefault(a => a.ConditionNode == condition);
- if (alarmInfo == null)
- return new ServiceResult(StatusCodes.BadNodeIdUnknown);
-
- using var scope = _metrics.BeginOperation("AlarmAcknowledge");
- 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)
- {
- scope.SetSuccess(false);
- Interlocked.Increment(ref _alarmAckWriteFailures);
- Log.Warning(ex, "Failed to write AckMsg for {Source}", alarmInfo.SourceName);
- return new ServiceResult(StatusCodes.BadInternalError);
- }
- }
-
- private ServiceResult OnAlarmConfirm(
- ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment)
- {
- Log.Information("Alarm confirmed: {Name} (Comment={Comment})",
- condition.ConditionName?.Value, comment?.Text);
- return ServiceResult.Good;
- }
-
- private ServiceResult OnAlarmAddComment(
- ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment)
- {
- Log.Information("Alarm comment added: {Name} — {Comment}",
- condition.ConditionName?.Value, comment?.Text);
- return ServiceResult.Good;
- }
-
- private ServiceResult OnAlarmEnableDisable(
- ISystemContext context, ConditionState condition, bool enabling)
- {
- Log.Information("Alarm {Action}: {Name}",
- enabling ? "ENABLED" : "DISABLED", condition.ConditionName?.Value);
- return ServiceResult.Good;
- }
-
- private ServiceResult OnAlarmShelve(
- ISystemContext context, AlarmConditionState alarm, bool shelving, bool oneShot, double shelvingTime)
- {
- alarm.SetShelvingState(context, shelving, oneShot, shelvingTime);
- Log.Information("Alarm {Action}: {Name} (OneShot={OneShot}, Time={Time}s)",
- shelving ? "SHELVED" : "UNSHELVED", alarm.ConditionName?.Value, oneShot,
- shelvingTime / 1000.0);
- return ServiceResult.Good;
- }
-
- private ServiceResult OnAlarmTimedUnshelve(
- ISystemContext context, AlarmConditionState alarm)
- {
- alarm.SetShelvingState(context, false, false, 0);
- Log.Information("Alarm timed unshelve: {Name}", alarm.ConditionName?.Value);
- return ServiceResult.Good;
- }
-
- private void ReportAlarmEvent(AlarmInfo info, bool active)
- {
- var condition = info.ConditionNode;
- if (condition == null)
- return;
-
- var severity = info.CachedSeverity;
- var 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);
-
- // Populate additional event fields
- if (condition.LocalTime != null)
- condition.LocalTime.Value = new TimeZoneDataType
- {
- Offset = (short)TimeZoneInfo.Local.BaseUtcOffset.TotalMinutes,
- DaylightSavingInOffset = TimeZoneInfo.Local.IsDaylightSavingTime(DateTime.Now)
- };
- if (condition.Quality != null)
- condition.Quality.Value = StatusCodes.Good;
-
- // 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);
-
- // Walk up the notifier chain so events reach subscribers at any ancestor level
- if (_tagToVariableNode.TryGetValue(info.SourceTagReference, out var sourceVar))
- ReportEventUpNotifierChain(sourceVar, condition);
-
- Log.Information("Alarm {State}: {Source} (Severity={Severity}, Message={Message})",
- active ? "ACTIVE" : "CLEARED", info.SourceName, severity, message);
- }
-
- ///
- /// Rebuilds the address space, removing old nodes and creating new ones. (OPC-010)
- ///
- /// The latest Galaxy object hierarchy to publish.
- /// The latest Galaxy attributes to publish.
- public void RebuildAddressSpace(List hierarchy, List attributes)
- {
- SyncAddressSpace(hierarchy, attributes);
- }
-
- ///
- /// Incrementally syncs the address space by detecting changed gobjects and rebuilding only those subtrees. (OPC-010)
- ///
- /// The latest Galaxy object hierarchy snapshot to compare against the currently published model.
- /// The latest Galaxy attribute snapshot to compare against the currently published variables.
- public void SyncAddressSpace(List hierarchy, List attributes)
- {
- var tagsToUnsubscribe = new List();
- var tagsToResubscribe = new List();
-
- 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(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 (collects tags for deferred unsubscription)
- TearDownGobjects(changedIds, tagsToUnsubscribe);
-
- // 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 subscription bookkeeping for surviving tags
- foreach (var kvp in affectedSubscriptions)
- {
- if (!_tagToVariableNode.ContainsKey(kvp.Key))
- continue;
-
- _subscriptionRefCounts[kvp.Key] = kvp.Value;
- tagsToResubscribe.Add(kvp.Key);
- }
-
- _lastHierarchy = new List(hierarchy);
- _lastAttributes = new List(attributes);
-
- Log.Information("Incremental sync complete: {Objects} objects, {Variables} variables, {Alarms} alarms",
- ObjectNodeCount, VariableNodeCount, _alarmInAlarmTags.Count);
- }
-
- // Perform subscribe/unsubscribe I/O outside Lock so read/write/browse operations are not blocked
- foreach (var tag in tagsToUnsubscribe)
- try { _mxAccessClient.UnsubscribeAsync(tag).GetAwaiter().GetResult(); }
- catch (Exception ex) { Log.Warning(ex, "Failed to unsubscribe {Tag} after sync", tag); }
-
- foreach (var tag in tagsToResubscribe)
- try { _mxAccessClient.SubscribeAsync(tag, (_, _) => { }).GetAwaiter().GetResult(); }
- catch (Exception ex) { Log.Warning(ex, "Failed to restore subscription for {Tag} after sync", tag); }
- }
-
- private void TearDownGobjects(HashSet gobjectIds, List tagsToUnsubscribe)
- {
- 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())
- {
- // Defer unsubscribe to outside lock
- if (_subscriptionRefCounts.ContainsKey(tagRef))
- {
- tagsToUnsubscribe.Add(tagRef);
- _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];
- // Defer alarm tag unsubscription to outside lock
- foreach (var alarmTag in new[]
- { alarmKey, info.PriorityTagReference, info.DescAttrNameTagReference })
- if (!string.IsNullOrEmpty(alarmTag))
- tagsToUnsubscribe.Add(alarmTag);
-
- _alarmInAlarmTags.Remove(alarmKey);
- if (!string.IsNullOrEmpty(info.PriorityTagReference))
- _alarmPriorityTags.Remove(info.PriorityTagReference);
- if (!string.IsNullOrEmpty(info.DescAttrNameTagReference))
- _alarmDescTags.Remove(info.DescAttrNameTagReference);
- 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 hierarchy, List 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(
- byPrimitive.Select(g => g.Key).Where(k => !string.IsNullOrEmpty(k)),
- StringComparer.OrdinalIgnoreCase);
-
- var variableNodes = new Dictionary(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)
- {
- var includedIds = ResolveAlarmFilterIncludedIds(sorted);
- foreach (var obj in sorted)
- {
- if (obj.IsArea) continue;
- if (includedIds != null && !includedIds.Contains(obj.GobjectId)) 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;
- if (!string.IsNullOrEmpty(alarmInfo.PriorityTagReference))
- _alarmPriorityTags[alarmInfo.PriorityTagReference] = alarmInfo;
- if (!string.IsNullOrEmpty(alarmInfo.DescAttrNameTagReference))
- _alarmDescTags[alarmInfo.DescAttrNameTagReference] = alarmInfo;
- hasAlarms = true;
- }
-
- if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode))
- EnableEventNotifierUpChain(objNode);
- }
-
- // 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(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;
- TrackBackgroundSubscribe(tag, "subtree alarm auto-subscribe");
- }
- }
- }
- }
-
- ///
- /// Sorts hierarchy so parents always appear before children, regardless of input order.
- ///
- private static List TopologicalSort(List hierarchy)
- {
- var byId = hierarchy.ToDictionary(h => h.GobjectId);
- var knownIds = new HashSet(hierarchy.Select(h => h.GobjectId));
- var visited = new HashSet();
- var result = new List(hierarchy.Count);
-
- void Visit(GalaxyObjectInfo obj)
- {
- if (!visited.Add(obj.GobjectId)) return;
-
- // Visit parent first if it exists in the hierarchy
- if (knownIds.Contains(obj.ParentGobjectId) && byId.TryGetValue(obj.ParentGobjectId, out var parent))
- Visit(parent);
-
- result.Add(obj);
- }
-
- foreach (var obj in hierarchy)
- Visit(obj);
-
- return result;
- }
-
- private 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(new List { (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;
-
- if (attr.IsHistorized)
- {
- var histConfigNodeId = new NodeId(nodeIdString + ".HAConfiguration", NamespaceIndex);
- var histConfig = new BaseObjectState(variable)
- {
- NodeId = histConfigNodeId,
- BrowseName = new QualifiedName("HAConfiguration", NamespaceIndex),
- DisplayName = "HA Configuration",
- TypeDefinitionId = ObjectTypeIds.HistoricalDataConfigurationType
- };
-
- var steppedProp = new PropertyState(histConfig)
- {
- NodeId = new NodeId(nodeIdString + ".HAConfiguration.Stepped", NamespaceIndex),
- BrowseName = BrowseNames.Stepped,
- DisplayName = "Stepped",
- Value = false,
- AccessLevel = AccessLevels.CurrentRead,
- UserAccessLevel = AccessLevels.CurrentRead
- };
- histConfig.AddChild(steppedProp);
-
- var definitionProp = new PropertyState(histConfig)
- {
- NodeId = new NodeId(nodeIdString + ".HAConfiguration.Definition", NamespaceIndex),
- BrowseName = BrowseNames.Definition,
- DisplayName = "Definition",
- Value = "Wonderware Historian",
- AccessLevel = AccessLevels.CurrentRead,
- UserAccessLevel = AccessLevels.CurrentRead
- };
- histConfig.AddChild(definitionProp);
-
- variable.AddChild(histConfig);
- AddPredefinedNode(SystemContext, histConfig);
- }
-
- 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,
- SecurityClassification = attr.SecurityClassification
- };
-
- // Track gobject → tag references for incremental sync
- if (!_gobjectToTagRefs.TryGetValue(attr.GobjectId, out var tagList))
- {
- tagList = new List();
- _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 Condition Refresh
-
- ///
- /// The OPC UA request context for the condition refresh operation.
- /// The monitored event items that should receive retained alarm conditions.
- public override ServiceResult ConditionRefresh(OperationContext context,
- IList 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
-
- private sealed class TagMetadata
- {
- ///
- /// Gets or sets the MXAccess data type code used to map Galaxy values into OPC UA variants.
- ///
- public int MxDataType { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the source Galaxy attribute should be exposed as an array node.
- ///
- public bool IsArray { get; set; }
-
- ///
- /// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array.
- ///
- public int? ArrayDimension { get; set; }
-
- ///
- /// Gets or sets the Galaxy security classification (0=FreeAccess, 1=Operate, 4=Tune, 5=Configure, etc.).
- /// Used at write time to determine which write role is required.
- ///
- public int SecurityClassification { get; set; }
- }
-
- private sealed class AlarmInfo
- {
- ///
- /// Gets or sets the full tag reference for the process value whose alarm state is tracked.
- ///
- public string SourceTagReference { get; set; } = "";
-
- ///
- /// Gets or sets the OPC UA node identifier for the source variable that owns the alarm condition.
- ///
- public NodeId SourceNodeId { get; set; } = NodeId.Null;
-
- ///
- /// Gets or sets the operator-facing source name used in generated alarm events.
- ///
- public string SourceName { get; set; } = "";
-
- ///
- /// Gets or sets the most recent in-alarm state so duplicate transitions are not reissued.
- ///
- public bool LastInAlarm { get; set; }
-
- ///
- /// Gets or sets the retained OPC UA condition node associated with the source alarm.
- ///
- public AlarmConditionState? ConditionNode { get; set; }
-
- ///
- /// Gets or sets the Galaxy tag reference that supplies runtime alarm priority updates.
- ///
- public string PriorityTagReference { get; set; } = "";
-
- ///
- /// Gets or sets the Galaxy tag reference or attribute binding used to resolve the alarm message text.
- ///
- public string DescAttrNameTagReference { get; set; } = "";
-
- ///
- /// Gets or sets the cached OPC UA severity derived from the latest alarm priority value.
- ///
- public ushort CachedSeverity { get; set; }
-
- ///
- /// Gets or sets the cached alarm message used when emitting active and cleared events.
- ///
- public string CachedMessage { get; set; } = "";
-
- ///
- /// Gets or sets the Galaxy tag reference for the alarm acknowledged state.
- ///
- public string AckedTagReference { get; set; } = "";
-
- ///
- /// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment.
- ///
- public string AckMsgTagReference { get; set; } = "";
-
- ///
- /// Gets or sets the most recent acknowledged state so duplicate transitions are not reissued.
- ///
- public bool? LastAcked { get; set; }
- }
-
- #region Read/Write Handlers
-
- ///
- public override void Read(OperationContext context, double maxAge, IList nodesToRead,
- IList results, IList errors)
- {
- base.Read(context, maxAge, nodesToRead, results, errors);
-
- for (var 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))
- {
- // Short-circuit when the owning galaxy runtime host is currently Stopped:
- // return the last cached value with BadOutOfService so the operator sees a
- // uniform dead-host signal instead of MxAccess silently serving stale data.
- // This covers both direct Read requests and OPC UA monitored-item sampling,
- // which also flow through this override.
- if (IsTagUnderStoppedHost(tagRef))
- {
- _tagToVariableNode.TryGetValue(tagRef, out var cachedVar);
- results[i] = new DataValue
- {
- Value = cachedVar?.Value,
- StatusCode = StatusCodes.BadOutOfService,
- SourceTimestamp = cachedVar?.Timestamp ?? DateTime.UtcNow,
- ServerTimestamp = DateTime.UtcNow
- };
- errors[i] = ServiceResult.Good;
- continue;
- }
-
- try
- {
- var vtq = SyncOverAsync.WaitSync(
- _mxAccessClient.ReadAsync(tagRef),
- _mxAccessRequestTimeout,
- "MxAccessClient.ReadAsync");
- results[i] = CreatePublishedDataValue(tagRef, vtq);
- errors[i] = ServiceResult.Good;
- }
- catch (TimeoutException ex)
- {
- Log.Warning(ex, "Read timed out for {TagRef}", tagRef);
- errors[i] = new ServiceResult(StatusCodes.BadTimeout);
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Read failed for {TagRef}", tagRef);
- errors[i] = new ServiceResult(StatusCodes.BadInternalError);
- }
- }
- }
- }
-
- private bool IsTagUnderStoppedHost(string tagRef)
- {
- if (_galaxyRuntimeProbeManager == null)
- return false;
- if (!_hostIdsByTagRef.TryGetValue(tagRef, out var hostIds))
- return false;
- for (var i = 0; i < hostIds.Count; i++)
- if (_galaxyRuntimeProbeManager.IsHostStopped(hostIds[i]))
- return true;
- return false;
- }
-
- ///
- public override void Write(OperationContext context, IList nodesToWrite,
- IList errors)
- {
- base.Write(context, nodesToWrite, errors);
-
- for (var 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))
- continue;
-
- // Check write permission based on the node's security classification
- var secClass = _tagMetadata.TryGetValue(tagRef, out var meta) ? meta.SecurityClassification : 1;
- if (!HasWritePermission(context, secClass))
- {
- errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
- continue;
- }
-
- {
- 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 = SyncOverAsync.WaitSync(
- _mxAccessClient.WriteAsync(tagRef, value),
- _mxAccessRequestTimeout,
- "MxAccessClient.WriteAsync");
- if (success)
- {
- PublishLocalWrite(tagRef, value);
- errors[i] = ServiceResult.Good;
- }
- else
- {
- errors[i] = new ServiceResult(StatusCodes.BadInternalError);
- }
- }
- catch (TimeoutException ex)
- {
- Log.Warning(ex, "Write timed out for {TagRef}", tagRef);
- errors[i] = new ServiceResult(StatusCodes.BadTimeout);
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Write failed for {TagRef}", tagRef);
- errors[i] = new ServiceResult(StatusCodes.BadInternalError);
- }
- }
- }
- }
-
- private bool HasWritePermission(OperationContext context, int securityClassification)
- {
- var identity = context.UserIdentity;
-
- // Check anonymous sessions against AnonymousCanWrite
- if (identity?.GrantedRoleIds?.Contains(ObjectIds.WellKnownRole_Anonymous) == true)
- return _anonymousCanWrite;
-
- // When role-based auth is active, require the role matching the security classification
- var requiredRoleId = GetRequiredWriteRole(securityClassification);
- if (requiredRoleId != null)
- return HasGrantedRole(identity, requiredRoleId);
-
- // No role-based auth — authenticated users can write
- return true;
- }
-
- private NodeId? GetRequiredWriteRole(int securityClassification)
- {
- switch (securityClassification)
- {
- case 0: // FreeAccess
- case 1: // Operate
- return _writeOperateRoleId;
- case 4: // Tune
- return _writeTuneRoleId;
- case 5: // Configure
- return _writeConfigureRoleId;
- default:
- // SecuredWrite (2), VerifiedWrite (3), ViewOnly (6) are read-only by AccessLevel
- // but if somehow reached, require the most restrictive role
- return _writeConfigureRoleId;
- }
- }
-
- private bool HasAlarmAckPermission(ISystemContext context)
- {
- if (_alarmAckRoleId == null)
- return true;
-
- var identity = (context as SystemContext)?.UserIdentity;
- return HasGrantedRole(identity, _alarmAckRoleId);
- }
-
- private static bool HasGrantedRole(IUserIdentity? identity, NodeId? roleId)
- {
- return roleId != null &&
- identity?.GrantedRoleIds != null &&
- identity.GrantedRoleIds.Contains(roleId);
- }
-
- private static void EnableEventNotifierUpChain(NodeState node)
- {
- for (var current = node as BaseInstanceState;
- current != null;
- current = current.Parent as BaseInstanceState)
- if (current is BaseObjectState obj)
- obj.EventNotifier = EventNotifiers.SubscribeToEvents;
- else if (current is FolderState folder)
- folder.EventNotifier = EventNotifiers.SubscribeToEvents;
- }
-
- private void ReportEventUpNotifierChain(BaseInstanceState sourceNode, IFilterTarget eventInstance)
- {
- for (var current = sourceNode.Parent; current != null; current = (current as BaseInstanceState)?.Parent)
- current.ReportEvent(SystemContext, eventInstance);
- }
-
- 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 (var i = 0; i < values.Length; i++)
- values.SetValue(string.Empty, i);
-
- return values;
- }
-
- #endregion
-
- #region HistoryRead
-
- ///
- protected override void HistoryReadRawModified(
- ServerSystemContext context,
- ReadRawModifiedDetails details,
- TimestampsToReturn timestampsToReturn,
- IList nodesToRead,
- IList results,
- IList errors,
- List nodesToProcess,
- IDictionary cache)
- {
- foreach (var handle in nodesToProcess)
- {
- var idx = handle.Index;
-
- // Handle continuation point resumption
- if (nodesToRead[idx].ContinuationPoint != null && nodesToRead[idx].ContinuationPoint.Length > 0)
- {
- var remaining = _historyContinuations.Retrieve(nodesToRead[idx].ContinuationPoint);
- if (remaining == null)
- {
- errors[idx] = new ServiceResult(StatusCodes.BadContinuationPointInvalid);
- continue;
- }
-
- ReturnHistoryPage(remaining, details.NumValuesPerNode, results, errors, idx);
- continue;
- }
-
- 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.IsReadModified)
- {
- errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported);
- continue;
- }
-
- using var historyScope = _metrics.BeginOperation("HistoryReadRaw");
- try
- {
- var maxValues = details.NumValuesPerNode > 0 ? (int)details.NumValuesPerNode : 0;
- var dataValues = SyncOverAsync.WaitSync(
- _historianDataSource.ReadRawAsync(
- tagRef, details.StartTime, details.EndTime, maxValues),
- _historianRequestTimeout,
- "HistorianDataSource.ReadRawAsync");
-
- if (details.ReturnBounds)
- AddBoundingValues(dataValues, details.StartTime, details.EndTime);
-
- ReturnHistoryPage(dataValues, details.NumValuesPerNode, results, errors, idx);
- }
- catch (TimeoutException ex)
- {
- historyScope.SetSuccess(false);
- Log.Warning(ex, "HistoryRead raw timed out for {TagRef}", tagRef);
- errors[idx] = new ServiceResult(StatusCodes.BadTimeout);
- }
- catch (Exception ex)
- {
- historyScope.SetSuccess(false);
- Log.Warning(ex, "HistoryRead raw failed for {TagRef}", tagRef);
- errors[idx] = new ServiceResult(StatusCodes.BadInternalError);
- }
- }
- }
-
- ///
- protected override void HistoryReadProcessed(
- ServerSystemContext context,
- ReadProcessedDetails details,
- TimestampsToReturn timestampsToReturn,
- IList nodesToRead,
- IList results,
- IList errors,
- List nodesToProcess,
- IDictionary cache)
- {
- foreach (var handle in nodesToProcess)
- {
- var idx = handle.Index;
-
- // Handle continuation point resumption
- if (nodesToRead[idx].ContinuationPoint != null && nodesToRead[idx].ContinuationPoint.Length > 0)
- {
- var remaining = _historyContinuations.Retrieve(nodesToRead[idx].ContinuationPoint);
- if (remaining == null)
- {
- errors[idx] = new ServiceResult(StatusCodes.BadContinuationPointInvalid);
- continue;
- }
-
- ReturnHistoryPage(remaining, 0, results, errors, idx);
- continue;
- }
-
- 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 = HistorianAggregateMap.MapAggregateToColumn(aggregateId);
- if (column == null)
- {
- errors[idx] = new ServiceResult(StatusCodes.BadAggregateNotSupported);
- continue;
- }
-
- using var historyScope = _metrics.BeginOperation("HistoryReadProcessed");
- try
- {
- var dataValues = SyncOverAsync.WaitSync(
- _historianDataSource.ReadAggregateAsync(
- tagRef, details.StartTime, details.EndTime,
- details.ProcessingInterval, column),
- _historianRequestTimeout,
- "HistorianDataSource.ReadAggregateAsync");
-
- ReturnHistoryPage(dataValues, 0, results, errors, idx);
- }
- catch (TimeoutException ex)
- {
- historyScope.SetSuccess(false);
- Log.Warning(ex, "HistoryRead processed timed out for {TagRef}", tagRef);
- errors[idx] = new ServiceResult(StatusCodes.BadTimeout);
- }
- catch (Exception ex)
- {
- historyScope.SetSuccess(false);
- Log.Warning(ex, "HistoryRead processed failed for {TagRef}", tagRef);
- errors[idx] = new ServiceResult(StatusCodes.BadInternalError);
- }
- }
- }
-
- ///
- protected override void HistoryReadAtTime(
- ServerSystemContext context,
- ReadAtTimeDetails details,
- TimestampsToReturn timestampsToReturn,
- IList nodesToRead,
- IList results,
- IList errors,
- List