Phase 3 PR 18 — delete v1 archived projects #17

Merged
dohertj2 merged 1 commits from phase-3-pr18-delete-v1 into v2 2026-04-18 08:41:57 -04:00
155 changed files with 0 additions and 24774 deletions
Showing only changes of commit dd3a449308 - Show all commits

View File

@@ -8,8 +8,6 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
@@ -24,9 +22,6 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>

View File

@@ -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
{
/// <summary>
/// Reflection entry point invoked by <c>HistorianPluginLoader</c> in the Host. Kept
/// deliberately simple so the plugin contract is a single static factory method.
/// </summary>
public static class AvevaHistorianPluginEntry
{
public static IHistorianDataSource Create(HistorianConfiguration config)
=> new HistorianDataSource(config);
}
}

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Design notes:
/// <list type="bullet">
/// <item>No SDK dependency — fully unit-testable with an injected clock.</item>
/// <item>Per-node state is guarded by a single lock; operations are microsecond-scale
/// so contention is a non-issue.</item>
/// <item>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.</item>
/// <item>Nodes are returned in configuration order so operators can express a
/// preference (primary first, fallback second).</item>
/// <item>When <see cref="HistorianConfiguration.ServerNames"/> is empty, the picker is
/// initialized with a single entry from <see cref="HistorianConfiguration.ServerName"/>
/// so legacy deployments continue to work unchanged.</item>
/// </list>
/// </remarks>
internal sealed class HistorianClusterEndpointPicker
{
private readonly Func<DateTime> _clock;
private readonly TimeSpan _cooldown;
private readonly object _lock = new object();
private readonly List<NodeEntry> _nodes;
public HistorianClusterEndpointPicker(HistorianConfiguration config)
: this(config, () => DateTime.UtcNow) { }
internal HistorianClusterEndpointPicker(HistorianConfiguration config, Func<DateTime> 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<string> { config.ServerName };
_nodes = names
.Where(n => !string.IsNullOrWhiteSpace(n))
.Select(n => n.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(n => new NodeEntry { Name = n })
.ToList();
}
/// <summary>
/// Gets the total number of configured cluster nodes. Stable — nodes are never added
/// or removed after construction.
/// </summary>
public int NodeCount
{
get
{
lock (_lock)
return _nodes.Count;
}
}
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<string> GetHealthyNodes()
{
lock (_lock)
{
var now = _clock();
return _nodes
.Where(n => IsHealthyAt(n, now))
.Select(n => n.Name)
.ToList();
}
}
/// <summary>
/// Gets the count of nodes currently eligible for a connection attempt (i.e., not in cooldown).
/// </summary>
public int HealthyNodeCount
{
get
{
lock (_lock)
{
var now = _clock();
return _nodes.Count(n => IsHealthyAt(n, now));
}
}
}
/// <summary>
/// Places <paramref name="node"/> 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.
/// </summary>
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;
}
}
/// <summary>
/// Marks <paramref name="node"/> as healthy immediately — clears any active cooldown but
/// leaves the cumulative failure counter intact for operator diagnostics. Unknown node
/// names are ignored.
/// </summary>
public void MarkHealthy(string node)
{
lock (_lock)
{
var entry = FindEntry(node);
if (entry == null)
return;
entry.CooldownUntil = null;
}
}
/// <summary>
/// Captures the current per-node state for the health dashboard. Freshly computed from
/// <see cref="_clock"/> so recently-expired cooldowns are reported as healthy.
/// </summary>
public List<HistorianClusterNodeState> 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; }
}
}
}

View File

@@ -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
{
/// <summary>
/// Reads historical data from the Wonderware Historian via the aahClientManaged SDK.
/// </summary>
public sealed class HistorianDataSource : IHistorianDataSource
{
private static readonly ILogger Log = Serilog.Log.ForContext<HistorianDataSource>();
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;
/// <summary>
/// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian SDK queries.
/// </summary>
/// <param name="config">The Historian SDK connection settings used for runtime history lookups.</param>
public HistorianDataSource(HistorianConfiguration config)
: this(config, new SdkHistorianConnectionFactory(), null) { }
/// <summary>
/// Initializes a Historian reader with a custom connection factory for testing. When
/// <paramref name="picker"/> is <see langword="null"/> a new picker is built from
/// <paramref name="config"/>, preserving backward compatibility with existing tests.
/// </summary>
internal HistorianDataSource(
HistorianConfiguration config,
IHistorianConnectionFactory factory,
HistorianClusterEndpointPicker? picker = null)
{
_config = config;
_factory = factory;
_picker = picker ?? new HistorianClusterEndpointPicker(config);
}
/// <summary>
/// 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.
/// </summary>
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
};
}
/// <inheritdoc />
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)");
}
}
/// <inheritdoc />
public Task<List<DataValue>> ReadRawAsync(
string tagName, DateTime startTime, DateTime endTime, int maxValues,
CancellationToken ct = default)
{
var results = new List<DataValue>();
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);
}
/// <inheritdoc />
public Task<List<DataValue>> ReadAggregateAsync(
string tagName, DateTime startTime, DateTime endTime,
double intervalMs, string aggregateColumn,
CancellationToken ct = default)
{
var results = new List<DataValue>();
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);
}
/// <inheritdoc />
public Task<List<DataValue>> ReadAtTimeAsync(
string tagName, DateTime[] timestamps,
CancellationToken ct = default)
{
var results = new List<DataValue>();
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);
}
/// <inheritdoc />
public Task<List<HistorianEventDto>> ReadEventsAsync(
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
CancellationToken ct = default)
{
var results = new List<HistorianEventDto>();
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
};
}
/// <summary>
/// Extracts the requested aggregate value from an <see cref="AnalogSummaryQueryResult"/> by column name.
/// </summary>
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;
}
}
/// <summary>
/// Closes the Historian SDK connection and releases resources.
/// </summary>
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;
}
}
}

View File

@@ -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
{
/// <summary>
/// Creates and opens Historian SDK connections. Extracted so tests can inject
/// fakes that control connection success, failure, and timeout behavior.
/// </summary>
internal interface IHistorianConnectionFactory
{
/// <summary>
/// Creates a new Historian SDK connection, opens it, and waits until it is ready.
/// Throws on connection failure or timeout.
/// </summary>
HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type);
}
/// <summary>
/// Production implementation that creates real Historian SDK connections.
/// </summary>
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");
}
}
}

View File

@@ -1,93 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Historian.Aveva</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Historian.Aveva</AssemblyName>
<!-- Plugin is loaded at runtime via Assembly.LoadFrom; never copy it as a CopyLocal dep. -->
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<!-- Deploy next to Host.exe under bin/<cfg>/Historian/ so F5 works without a manual copy. -->
<HistorianPluginOutputDir>$(MSBuildThisFileDirectory)..\ZB.MOM.WW.OtOpcUa.Host\bin\$(Configuration)\net48\Historian\</HistorianPluginOutputDir>
<!--
Phase 2 Stream D — V1 ARCHIVE. Plugs into the legacy in-process Host's
Wonderware Historian loader. Will be ported into Driver.Galaxy.Host's
Backend/Historian/ subtree when MxAccessGalaxyBackend.HistoryReadAsync is
wired (Task B.1.h follow-up). See docs/v2/V1_ARCHIVE_STATUS.md.
-->
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests"/>
</ItemGroup>
<ItemGroup>
<!-- Logging -->
<PackageReference Include="Serilog" Version="2.10.0"/>
<!-- OPC UA (for DataValue/StatusCodes used by the IHistorianDataSource surface) -->
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
</ItemGroup>
<ItemGroup>
<!-- Private=false: the plugin binds to Host types at compile time but Host.exe must not be
copied into the plugin's output folder (it is already in the process). -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj">
<Private>false</Private>
<ReferenceOutputAssembly>true</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<!-- Wonderware Historian SDK -->
<Reference Include="aahClientManaged">
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="aahClientCommon">
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- Historian SDK native dependencies — copied beside the plugin DLL so the AssemblyResolve
handler in HistorianPluginLoader can find them when the plugin first JITs. -->
<None Include="..\..\lib\aahClient.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\aahClientCommon.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\aahClientManaged.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\Historian.CBE.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\Historian.DPAPI.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\ArchestrA.CloudHistorian.Contract.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="StageHistorianPluginForHost" AfterTargets="Build">
<ItemGroup>
<_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')"/>
</ItemGroup>
<MakeDir Directories="$(HistorianPluginOutputDir)"/>
<Copy SourceFiles="@(_HistorianStageFiles)" DestinationFolder="$(HistorianPluginOutputDir)" SkipUnchangedFiles="true"/>
</Target>
</Project>

View File

@@ -1,27 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Configures the template-based alarm object filter under <c>OpcUa.AlarmFilter</c>.
/// </summary>
/// <remarks>
/// Each entry in <see cref="ObjectFilters"/> is a wildcard pattern matched against the template
/// derivation chain of every Galaxy object. Supported wildcard: <c>*</c>. Matching is case-insensitive
/// and the leading <c>$</c> used by Galaxy template tag_names is normalized away, so operators can
/// write <c>TestMachine*</c> instead of <c>$TestMachine*</c>. An entry may itself contain comma-separated
/// patterns for convenience (e.g., <c>"TestMachine*, Pump_*"</c>). An empty list disables the filter,
/// restoring current behavior: all alarm-bearing objects are monitored when
/// <see cref="OpcUaConfiguration.AlarmTrackingEnabled"/> is <see langword="true"/>.
/// </remarks>
public class AlarmFilterConfiguration
{
/// <summary>
/// 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.
/// </summary>
public List<string> ObjectFilters { get; set; } = new();
}
}

View File

@@ -1,48 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Top-level configuration holder binding all sections from appsettings.json. (SVC-003)
/// </summary>
public class AppConfiguration
{
/// <summary>
/// Gets or sets the OPC UA endpoint settings exposed to downstream clients that browse the LMX address space.
/// </summary>
public OpcUaConfiguration OpcUa { get; set; } = new();
/// <summary>
/// Gets or sets the MXAccess runtime connection settings used to read and write live Galaxy attributes.
/// </summary>
public MxAccessConfiguration MxAccess { get; set; } = new();
/// <summary>
/// Gets or sets the repository settings used to query Galaxy metadata for address-space construction.
/// </summary>
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new();
/// <summary>
/// Gets or sets the embedded dashboard settings used to surface service health to operators.
/// </summary>
public DashboardConfiguration Dashboard { get; set; } = new();
/// <summary>
/// Gets or sets the Wonderware Historian connection settings used to serve OPC UA historical data.
/// </summary>
public HistorianConfiguration Historian { get; set; } = new();
/// <summary>
/// Gets or sets the authentication and role-based access control settings.
/// </summary>
public AuthenticationConfiguration Authentication { get; set; } = new();
/// <summary>
/// Gets or sets the transport security settings that control which OPC UA security profiles are exposed.
/// </summary>
public SecurityProfileConfiguration Security { get; set; } = new();
/// <summary>
/// Gets or sets the redundancy settings that control how this server participates in a redundant pair.
/// </summary>
public RedundancyConfiguration Redundancy { get; set; } = new();
}
}

View File

@@ -1,25 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Authentication and role-based access control settings for the OPC UA server.
/// </summary>
public class AuthenticationConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether anonymous OPC UA connections are accepted.
/// </summary>
public bool AllowAnonymous { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public bool AnonymousCanWrite { get; set; } = true;
/// <summary>
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true,
/// credentials are validated against the LDAP server and group membership determines permissions.
/// </summary>
public LdapConfiguration Ldap { get; set; } = new();
}
}

View File

@@ -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
{
/// <summary>
/// Validates and logs effective configuration at startup. (SVC-003, SVC-005)
/// </summary>
public static class ConfigurationValidator
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
/// <summary>
/// Validates the effective host configuration and writes the resolved values to the startup log before service
/// initialization continues.
/// </summary>
/// <param name="config">
/// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries,
/// and dashboard behavior.
/// </param>
/// <returns>
/// <see langword="true" /> when the required settings are present and within supported bounds; otherwise,
/// <see langword="false" />.
/// </returns>
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<string>();
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)";
}
}
}
}

View File

@@ -1,23 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Status dashboard configuration. (SVC-003, DASH-001)
/// </summary>
public class DashboardConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state.
/// </summary>
public int Port { get; set; } = 8081;
/// <summary>
/// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot.
/// </summary>
public int RefreshIntervalSeconds { get; set; } = 10;
}
}

View File

@@ -1,42 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Galaxy repository database configuration. (SVC-003, GR-005)
/// </summary>
public class GalaxyRepositoryConfiguration
{
/// <summary>
/// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata.
/// </summary>
public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
/// <summary>
/// Gets or sets how often, in seconds, the service polls for Galaxy deploy changes that require an address-space
/// rebuild.
/// </summary>
public int ChangeDetectionIntervalSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model.
/// </summary>
public bool ExtendedAttributes { get; set; } = false;
/// <summary>
/// Gets or sets the scope of Galaxy objects loaded into the OPC UA address space.
/// <c>Galaxy</c> loads all deployed objects (default). <c>LocalPlatform</c> loads only
/// objects hosted by the platform deployed on this machine.
/// </summary>
public GalaxyScope Scope { get; set; } = GalaxyScope.Galaxy;
/// <summary>
/// Gets or sets an explicit platform node name for <see cref="GalaxyScope.LocalPlatform" /> filtering.
/// When <see langword="null" />, the local machine name (<c>Environment.MachineName</c>) is used.
/// </summary>
public string? PlatformName { get; set; }
}
}

View File

@@ -1,18 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Controls how much of the Galaxy object hierarchy is loaded into the OPC UA address space.
/// </summary>
public enum GalaxyScope
{
/// <summary>
/// Load all deployed objects from the entire Galaxy (default, backward-compatible behavior).
/// </summary>
Galaxy,
/// <summary>
/// Load only objects hosted by the local platform and the structural areas needed to reach them.
/// </summary>
LocalPlatform
}
}

View File

@@ -1,76 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Wonderware Historian SDK configuration for OPC UA historical data access.
/// </summary>
public class HistorianConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether OPC UA historical data access is enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the single Historian server hostname used when <see cref="ServerNames"/>
/// is empty. Preserved for backward compatibility with pre-cluster deployments.
/// </summary>
public string ServerName { get; set; } = "localhost";
/// <summary>
/// Gets or sets the ordered list of Historian cluster nodes. When non-empty, this list
/// supersedes <see cref="ServerName"/>: 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 <see cref="FailureCooldownSeconds"/> before being re-eligible.
/// </summary>
public List<string> ServerNames { get; set; } = new();
/// <summary>
/// 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.
/// </summary>
public int FailureCooldownSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets a value indicating whether Windows Integrated Security is used.
/// When false, <see cref="UserName"/> and <see cref="Password"/> are used instead.
/// </summary>
public bool IntegratedSecurity { get; set; } = true;
/// <summary>
/// Gets or sets the username for Historian authentication when <see cref="IntegratedSecurity"/> is false.
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// Gets or sets the password for Historian authentication when <see cref="IntegratedSecurity"/> is false.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Gets or sets the Historian server TCP port.
/// </summary>
public int Port { get; set; } = 32568;
/// <summary>
/// Gets or sets the packet timeout in seconds for Historian SDK operations.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the maximum number of values returned per HistoryRead request.
/// </summary>
public int MaxValuesPerRead { get; set; } = 10000;
/// <summary>
/// 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 <see cref="CommandTimeoutSeconds"/> — e.g., a slow SDK
/// reconnect or mid-failover cluster node. Must be comfortably larger than
/// <see cref="CommandTimeoutSeconds"/> so normal operation is never affected. Default 60s.
/// </summary>
public int RequestTimeoutSeconds { get; set; } = 60;
}
}

View File

@@ -1,75 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// LDAP authentication and group-to-role mapping settings.
/// </summary>
public class LdapConfiguration
{
/// <summary>
/// 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.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the LDAP server hostname or IP address.
/// </summary>
public string Host { get; set; } = "localhost";
/// <summary>
/// Gets or sets the LDAP server port.
/// </summary>
public int Port { get; set; } = 3893;
/// <summary>
/// Gets or sets the base DN for LDAP operations.
/// </summary>
public string BaseDN { get; set; } = "dc=lmxopcua,dc=local";
/// <summary>
/// Gets or sets the bind DN template. Use {username} as a placeholder.
/// </summary>
public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local";
/// <summary>
/// Gets or sets the service account DN used for LDAP searches (group lookups).
/// </summary>
public string ServiceAccountDn { get; set; } = "";
/// <summary>
/// Gets or sets the service account password.
/// </summary>
public string ServiceAccountPassword { get; set; } = "";
/// <summary>
/// Gets or sets the LDAP connection timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the LDAP group name that grants read-only access.
/// </summary>
public string ReadOnlyGroup { get; set; } = "ReadOnly";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes.
/// </summary>
public string WriteOperateGroup { get; set; } = "WriteOperate";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for Tune attributes.
/// </summary>
public string WriteTuneGroup { get; set; } = "WriteTune";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for Configure attributes.
/// </summary>
public string WriteConfigureGroup { get; set; } = "WriteConfigure";
/// <summary>
/// Gets or sets the LDAP group name that grants alarm acknowledgment access.
/// </summary>
public string AlarmAckGroup { get; set; } = "AlarmAck";
}
}

View File

@@ -1,86 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// MXAccess client configuration. (SVC-003, MXA-008, MXA-009)
/// </summary>
public class MxAccessConfiguration
{
/// <summary>
/// Gets or sets the client name registered with the MXAccess runtime for this bridge instance.
/// </summary>
public string ClientName { get; set; } = "LmxOpcUa";
/// <summary>
/// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node.
/// </summary>
public string? NodeName { get; set; }
/// <summary>
/// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics.
/// </summary>
public string? GalaxyName { get; set; }
/// <summary>
/// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete.
/// </summary>
public int ReadTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime.
/// </summary>
public int WriteTimeoutSeconds { get; set; } = 5;
/// <summary>
/// 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
/// <see cref="ReadTimeoutSeconds"/> / <see cref="WriteTimeoutSeconds"/> 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.
/// </summary>
public int RequestTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime.
/// </summary>
public int MaxConcurrentOperations { get; set; } = 10;
/// <summary>
/// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection.
/// </summary>
public int MonitorIntervalSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets a value indicating whether the bridge should automatically attempt to re-establish a dropped MXAccess
/// session.
/// </summary>
public bool AutoReconnect { get; set; } = true;
/// <summary>
/// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data.
/// </summary>
public string? ProbeTag { get; set; }
/// <summary>
/// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale.
/// </summary>
public int ProbeStaleThresholdSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets a value indicating whether the bridge advises <c>&lt;ObjectName&gt;.ScanState</c> for every
/// deployed <c>$WinPlatform</c> and <c>$AppEngine</c>, 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.
/// </summary>
public bool RuntimeStatusProbesEnabled { get; set; } = true;
/// <summary>
/// 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 <c>ScanState</c> is delivered
/// on-change only, a stably Running host does not time out — no starvation check runs on Running
/// entries. Default 15s.
/// </summary>
public int RuntimeStatusUnknownTimeoutSeconds { get; set; } = 15;
}
}

View File

@@ -1,64 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013)
/// </summary>
public class OpcUaConfiguration
{
/// <summary>
/// Gets or sets the IP address or hostname the OPC UA server binds to.
/// Defaults to <c>0.0.0.0</c> (all interfaces). Set to a specific IP or hostname to restrict listening.
/// </summary>
public string BindAddress { get; set; } = "0.0.0.0";
/// <summary>
/// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
/// </summary>
public int Port { get; set; } = 4840;
/// <summary>
/// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server.
/// </summary>
public string EndpointPath { get; set; } = "/LmxOpcUa";
/// <summary>
/// Gets or sets the server name presented to OPC UA clients and used in diagnostics.
/// </summary>
public string ServerName { get; set; } = "LmxOpcUa";
/// <summary>
/// Gets or sets the Galaxy name represented by the published OPC UA namespace.
/// </summary>
public string GalaxyName { get; set; } = "ZB";
/// <summary>
/// Gets or sets the explicit application URI for this server instance.
/// When <see langword="null" />, defaults to <c>urn:{GalaxyName}:LmxOpcUa</c>.
/// Must be set to a unique value per instance when redundancy is enabled.
/// </summary>
public string? ApplicationUri { get; set; }
/// <summary>
/// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
/// </summary>
public int MaxSessions { get; set; } = 100;
/// <summary>
/// Gets or sets the session timeout, in minutes, before idle client sessions are closed.
/// </summary>
public int SessionTimeoutMinutes { get; set; } = 30;
/// <summary>
/// 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.
/// </summary>
public bool AlarmTrackingEnabled { get; set; } = false;
/// <summary>
/// Gets or sets the template-based alarm object filter. When <see cref="AlarmFilterConfiguration.ObjectFilters"/>
/// 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.
/// </summary>
public AlarmFilterConfiguration AlarmFilter { get; set; } = new();
}
}

View File

@@ -1,41 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Non-transparent redundancy settings that control how the server advertises itself
/// within a redundant pair and computes its dynamic ServiceLevel.
/// </summary>
public class RedundancyConfiguration
{
/// <summary>
/// Gets or sets whether redundancy is enabled. When <see langword="false" /> (default),
/// the server reports <c>RedundancySupport.None</c> and <c>ServiceLevel = 255</c>.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the redundancy mode. Valid values: <c>Warm</c>, <c>Hot</c>.
/// </summary>
public string Mode { get; set; } = "Warm";
/// <summary>
/// Gets or sets the role of this instance. Valid values: <c>Primary</c>, <c>Secondary</c>.
/// The primary advertises a higher ServiceLevel than the secondary when both are healthy.
/// </summary>
public string Role { get; set; } = "Primary";
/// <summary>
/// Gets or sets the ApplicationUri values for all servers in the redundant set.
/// Must include this instance's own <c>OpcUa.ApplicationUri</c>.
/// </summary>
public List<string> ServerUris { get; set; } = new();
/// <summary>
/// Gets or sets the base ServiceLevel when the server is fully healthy.
/// The secondary automatically receives <c>ServiceLevelBase - 50</c>.
/// Valid range: 1-255.
/// </summary>
public int ServiceLevelBase { get; set; } = 200;
}
}

View File

@@ -1,52 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Transport security settings that control which OPC UA security profiles the server exposes and how client
/// certificates are handled.
/// </summary>
public class SecurityProfileConfiguration
{
/// <summary>
/// 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.
/// </summary>
public List<string> Profiles { get; set; } = new() { "None" };
/// <summary>
/// Gets or sets a value indicating whether the server automatically accepts client certificates
/// that are not in the trusted store. Should be <see langword="false" /> in production.
/// </summary>
public bool AutoAcceptClientCertificates { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected.
/// </summary>
public bool RejectSHA1Certificates { get; set; } = true;
/// <summary>
/// Gets or sets the minimum RSA key size required for client certificates.
/// </summary>
public int MinimumCertificateKeySize { get; set; } = 2048;
/// <summary>
/// Gets or sets an optional override for the PKI root directory.
/// When <see langword="null" />, defaults to <c>%LOCALAPPDATA%\OPC Foundation\pki</c>.
/// </summary>
public string? PkiRootPath { get; set; }
/// <summary>
/// Gets or sets an optional override for the server certificate subject name.
/// When <see langword="null" />, defaults to <c>CN={ServerName}, O=ZB MOM, DC=localhost</c>.
/// </summary>
public string? CertificateSubject { get; set; }
/// <summary>
/// Gets or sets the lifetime of the auto-generated server certificate in months.
/// Defaults to 60 months (5 years).
/// </summary>
public int CertificateLifetimeMonths { get; set; } = 60;
}
}

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>Matching rules:</para>
/// <list type="bullet">
/// <item>An object is included when any template name in its derivation chain matches
/// any configured pattern.</item>
/// <item>Matching is case-insensitive and ignores the Galaxy leading <c>$</c> prefix on
/// both the chain entry and the user pattern, so <c>TestMachine*</c> matches the stored
/// <c>$TestMachine</c>.</item>
/// <item>Inclusion propagates to every descendant of a matched object (containment subtree).</item>
/// <item>Each object is evaluated once — overlapping matches never produce duplicate
/// inclusions (set semantics).</item>
/// </list>
/// <para>Pattern syntax: literal text plus <c>*</c> wildcards (zero or more characters).
/// Other regex metacharacters in the raw pattern are escaped and treated literally.</para>
/// </remarks>
public class AlarmObjectFilter
{
private static readonly ILogger Log = Serilog.Log.ForContext<AlarmObjectFilter>();
private readonly List<Regex> _patterns;
private readonly List<string> _rawPatterns;
private readonly HashSet<string> _matchedRawPatterns;
/// <summary>
/// Initializes a new alarm object filter from the supplied configuration section.
/// </summary>
/// <param name="config">The alarm filter configuration whose <see cref="AlarmFilterConfiguration.ObjectFilters"/>
/// entries are parsed into regular expressions. Entries may themselves contain comma-separated patterns.</param>
public AlarmObjectFilter(AlarmFilterConfiguration? config)
{
_patterns = new List<Regex>();
_rawPatterns = new List<string>();
_matchedRawPatterns = new HashSet<string>(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);
}
}
}
}
/// <summary>
/// Gets a value indicating whether the filter has any compiled patterns. When <see langword="false"/>,
/// callers should treat alarm tracking as unfiltered (current behavior preserved).
/// </summary>
public bool Enabled => _patterns.Count > 0;
/// <summary>
/// Gets the number of compiled patterns the filter will evaluate against each object.
/// </summary>
public int PatternCount => _patterns.Count;
/// <summary>
/// Gets the raw pattern strings that did not match any object in the most recent call to
/// <see cref="ResolveIncludedObjects"/>. Useful for startup warnings about operator typos.
/// </summary>
public IReadOnlyList<string> UnmatchedPatterns =>
_rawPatterns.Where(p => !_matchedRawPatterns.Contains(p)).ToList();
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<string> RawPatterns => _rawPatterns;
/// <summary>
/// Returns <see langword="true"/> when any template name in <paramref name="chain"/> matches any
/// compiled pattern. An empty chain never matches unless the operator explicitly supplied a pattern
/// equal to <c>*</c> (which collapses to an empty-matching regex after normalization).
/// </summary>
/// <param name="chain">The template derivation chain to test (own template first, ancestors after).</param>
public bool MatchesTemplateChain(IReadOnlyList<string>? 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;
}
/// <summary>
/// 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
/// <see langword="null"/> when the filter is disabled so callers can skip the containment check
/// entirely.
/// </summary>
/// <param name="hierarchy">The full deployed Galaxy hierarchy, as returned by the repository service.</param>
/// <returns>The set of included gobject IDs, or <see langword="null"/> when filtering is disabled.</returns>
public HashSet<int>? ResolveIncludedObjects(IReadOnlyList<GalaxyObjectInfo>? hierarchy)
{
if (!Enabled)
return null;
_matchedRawPatterns.Clear();
var included = new HashSet<int>();
if (hierarchy == null || hierarchy.Count == 0)
return included;
var byId = new Dictionary<int, GalaxyObjectInfo>(hierarchy.Count);
foreach (var obj in hierarchy)
byId[obj.GobjectId] = obj;
var childrenByParent = new Dictionary<int, List<int>>();
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<int>();
childrenByParent[parentId] = list;
}
list.Add(obj.GobjectId);
}
var roots = childrenByParent.TryGetValue(0, out var rootList)
? rootList
: new List<int>();
var visited = new HashSet<int>();
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;
}
}
}

View File

@@ -1,38 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// MXAccess connection lifecycle states. (MXA-002)
/// </summary>
public enum ConnectionState
{
/// <summary>
/// No active session exists to the Galaxy runtime.
/// </summary>
Disconnected,
/// <summary>
/// The bridge is opening a new MXAccess session to the runtime.
/// </summary>
Connecting,
/// <summary>
/// The bridge has an active MXAccess session and can service reads, writes, and subscriptions.
/// </summary>
Connected,
/// <summary>
/// The bridge is closing the current MXAccess session and draining runtime resources.
/// </summary>
Disconnecting,
/// <summary>
/// The bridge detected a connection fault that requires operator attention or recovery logic.
/// </summary>
Error,
/// <summary>
/// The bridge is attempting to restore service after a runtime communication failure.
/// </summary>
Reconnecting
}
}

View File

@@ -1,38 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Event args for connection state transitions. (MXA-002)
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs" /> class.
/// </summary>
/// <param name="previous">The connection state being exited.</param>
/// <param name="current">The connection state being entered.</param>
/// <param name="message">Additional context about the transition, such as a connection fault or reconnect attempt.</param>
public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "")
{
PreviousState = previous;
CurrentState = current;
Message = message ?? "";
}
/// <summary>
/// Gets the previous MXAccess connection state before the transition was raised.
/// </summary>
public ConnectionState PreviousState { get; }
/// <summary>
/// Gets the new MXAccess connection state that the bridge moved into.
/// </summary>
public ConnectionState CurrentState { get; }
/// <summary>
/// Gets an operator-facing message that explains why the connection state changed.
/// </summary>
public string Message { get; }
}
}

View File

@@ -1,76 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// DTO matching attributes.sql result columns. (GR-002)
/// </summary>
public class GalaxyAttributeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier that owns the attribute.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the attribute name as defined on the Galaxy template or instance.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets the human-readable Galaxy data type name returned by the repository query.
/// </summary>
public string DataTypeName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients.
/// </summary>
public string PrimitiveName { get; set; } = "";
/// <summary>
/// Gets or sets the source classification that explains whether the attribute comes from configuration, calculation,
/// or runtime data.
/// </summary>
public string AttributeSource { get; set; } = "";
/// <summary>
/// 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.
/// </summary>
public int SecurityClassification { get; set; } = 1;
/// <summary>
/// Gets or sets a value indicating whether the attribute has a HistoryExtension primitive and is historized by the
/// Wonderware Historian.
/// </summary>
public bool IsHistorized { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute has an AlarmExtension primitive and is an alarm.
/// </summary>
public bool IsAlarm { get; set; }
}
}

View File

@@ -1,64 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// DTO matching hierarchy.sql result columns. (GR-001)
/// </summary>
public class GalaxyObjectInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the contained name shown for the object inside its parent area or object.
/// </summary>
public string ContainedName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object.
/// </summary>
public bool IsArea { get; set; }
/// <summary>
/// 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 <c>$Object</c>. Populated by
/// the recursive CTE in <c>hierarchy.sql</c> on <c>gobject.derived_from_gobject_id</c>. Used by
/// <see cref="AlarmObjectFilter"/> to decide whether an object's alarms should be monitored.
/// </summary>
public List<string> TemplateChain { get; set; } = new();
/// <summary>
/// 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
/// <c>template_definition.category_id</c> by <c>hierarchy.sql</c> and consumed by the runtime
/// status probe manager to identify hosts that should receive a <c>ScanState</c> probe.
/// </summary>
public int CategoryId { get; set; }
/// <summary>
/// Gets or sets the Galaxy object id of this object's runtime host, populated from
/// <c>gobject.hosted_by_gobject_id</c>. Walk this chain upward to find the nearest
/// <c>$WinPlatform</c> or <c>$AppEngine</c> ancestor for subtree quality invalidation when
/// a runtime host is reported Stopped. Zero for root objects that have no host.
/// </summary>
public int HostedByGobjectId { get; set; }
}
}

View File

@@ -1,29 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Runtime state of a deployed Galaxy runtime host ($WinPlatform or $AppEngine) as
/// observed by the bridge via its <c>ScanState</c> probe.
/// </summary>
public enum GalaxyRuntimeState
{
/// <summary>
/// Probe advised but no callback received yet. Transitions to <see cref="Running"/>
/// on the first successful <c>ScanState = true</c> callback, or to <see cref="Stopped"/>
/// once the unknown-resolution timeout elapses.
/// </summary>
Unknown,
/// <summary>
/// Last probe callback reported <c>ScanState = true</c> with a successful item status.
/// The host is on scan and executing.
/// </summary>
Running,
/// <summary>
/// Last probe callback reported <c>ScanState != true</c>, or a failed item status, or
/// the initial probe never resolved before the unknown timeout elapsed. The host is
/// off scan or unreachable.
/// </summary>
Stopped
}
}

View File

@@ -1,72 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Point-in-time runtime state of a single Galaxy runtime host ($WinPlatform or $AppEngine)
/// as tracked by the <c>GalaxyRuntimeProbeManager</c>. Surfaced on the status dashboard and
/// consumed by <c>HealthCheckService</c> so operators can detect a stopped host before
/// downstream clients notice the stale data.
/// </summary>
public sealed class GalaxyRuntimeStatus
{
/// <summary>
/// Gets or sets the Galaxy tag_name of the host (e.g., <c>DevPlatform</c> or
/// <c>DevAppEngine</c>).
/// </summary>
public string ObjectName { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy gobject_id of the host.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the Galaxy template category name — <c>$WinPlatform</c> or
/// <c>$AppEngine</c>. Used by the dashboard to group hosts by kind.
/// </summary>
public string Kind { get; set; } = "";
/// <summary>
/// Gets or sets the current runtime state.
/// </summary>
public GalaxyRuntimeState State { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the most recent probe callback, whether it
/// reported success or failure. <see langword="null"/> before the first callback.
/// </summary>
public DateTime? LastStateCallbackTime { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the most recent <see cref="State"/> transition.
/// Backs the dashboard "Since" column. <see langword="null"/> in the initial Unknown
/// state before any transition.
/// </summary>
public DateTime? LastStateChangeTime { get; set; }
/// <summary>
/// Gets or sets the last <c>ScanState</c> value received from the probe, or
/// <see langword="null"/> before the first update or when the last callback carried
/// a non-success item status (no value delivered).
/// </summary>
public bool? LastScanState { get; set; }
/// <summary>
/// Gets or sets the detail message from the most recent failure callback, cleared on
/// the next successful <c>ScanState = true</c> delivery.
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// Gets or sets the cumulative number of callbacks where <c>ScanState = true</c>.
/// </summary>
public long GoodUpdateCount { get; set; }
/// <summary>
/// Gets or sets the cumulative number of callbacks where <c>ScanState != true</c>
/// or the item status reported failure.
/// </summary>
public long FailureCount { get; set; }
}
}

View File

@@ -1,46 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Interface for Galaxy repository database queries. (GR-001 through GR-004)
/// </summary>
public interface IGalaxyRepository
{
/// <summary>
/// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>A list of Galaxy objects ordered for address-space construction.</returns>
Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default);
/// <summary>
/// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>A list of attribute definitions with MXAccess references and type metadata.</returns>
Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default);
/// <summary>
/// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>The latest deploy timestamp, or <see langword="null" /> when it cannot be determined.</returns>
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
/// <summary>
/// Verifies that the service can reach the Galaxy repository before it attempts to build the address space.
/// </summary>
/// <param name="ct">A token that cancels the connectivity check.</param>
/// <returns><see langword="true" /> when repository access succeeds; otherwise, <see langword="false" />.</returns>
Task<bool> TestConnectionAsync(CancellationToken ct = default);
/// <summary>
/// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild.
/// </summary>
event Action? OnGalaxyChanged;
}
}

View File

@@ -1,79 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Abstraction over MXAccess COM client for tag read/write/subscribe operations.
/// (MXA-001 through MXA-009, OPC-007, OPC-008, OPC-009)
/// </summary>
public interface IMxAccessClient : IDisposable
{
/// <summary>
/// Gets the current runtime connectivity state for the bridge.
/// </summary>
ConnectionState State { get; }
/// <summary>
/// Gets the number of active runtime subscriptions currently being mirrored into OPC UA.
/// </summary>
int ActiveSubscriptionCount { get; }
/// <summary>
/// Gets the number of reconnect cycles attempted since the client was created.
/// </summary>
int ReconnectCount { get; }
/// <summary>
/// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed Galaxy attribute publishes a new runtime value.
/// </summary>
event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Opens the MXAccess session required for runtime reads, writes, and subscriptions.
/// </summary>
/// <param name="ct">A token that cancels the connection attempt.</param>
Task ConnectAsync(CancellationToken ct = default);
/// <summary>
/// Closes the MXAccess session and releases runtime resources.
/// </summary>
Task DisconnectAsync();
/// <summary>
/// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="callback">The callback to invoke when the runtime publishes a new value for the attribute.</param>
Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback);
/// <summary>
/// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
Task UnsubscribeAsync(string fullTagReference);
/// <summary>
/// Reads the current runtime value for a Galaxy attribute.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="ct">A token that cancels the read.</param>
/// <returns>The value, timestamp, and quality returned by the runtime.</returns>
Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default);
/// <summary>
/// Writes a new runtime value to a writable Galaxy attribute.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="value">The value to write to the runtime.</param>
/// <param name="ct">A token that cancels the write.</param>
/// <returns><see langword="true" /> when the write is accepted by the runtime; otherwise, <see langword="false" />.</returns>
Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default);
}
}

View File

@@ -1,99 +0,0 @@
using ArchestrA.MxAccess;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Delegate matching LMXProxyServer.OnDataChange COM event signature.
/// </summary>
/// <param name="hLMXServerHandle">The runtime connection handle that raised the change.</param>
/// <param name="phItemHandle">The runtime item handle for the attribute that changed.</param>
/// <param name="pvItemValue">The new raw runtime value for the attribute.</param>
/// <param name="pwItemQuality">The OPC DA quality code supplied by the runtime.</param>
/// <param name="pftItemTimeStamp">The timestamp object supplied by the runtime for the value.</param>
/// <param name="ItemStatus">The MXAccess status payload associated with the callback.</param>
public delegate void MxDataChangeHandler(
int hLMXServerHandle,
int phItemHandle,
object pvItemValue,
int pwItemQuality,
object pftItemTimeStamp,
ref MXSTATUS_PROXY[] ItemStatus);
/// <summary>
/// Delegate matching LMXProxyServer.OnWriteComplete COM event signature.
/// </summary>
/// <param name="hLMXServerHandle">The runtime connection handle that processed the write.</param>
/// <param name="phItemHandle">The runtime item handle that was written.</param>
/// <param name="ItemStatus">The MXAccess status payload describing the write outcome.</param>
public delegate void MxWriteCompleteHandler(
int hLMXServerHandle,
int phItemHandle,
ref MXSTATUS_PROXY[] ItemStatus);
/// <summary>
/// Abstraction over LMXProxyServer COM object to enable testing without the COM runtime. (MXA-001)
/// </summary>
public interface IMxProxy
{
/// <summary>
/// Registers the bridge as an MXAccess client with the runtime proxy.
/// </summary>
/// <param name="clientName">The client identity reported to the runtime for diagnostics and session tracking.</param>
/// <returns>The runtime connection handle assigned to the client session.</returns>
int Register(string clientName);
/// <summary>
/// Unregisters the bridge from the runtime proxy and releases the connection handle.
/// </summary>
/// <param name="handle">The connection handle returned by <see cref="Register(string)" />.</param>
void Unregister(int handle);
/// <summary>
/// Adds a Galaxy attribute reference to the active runtime session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="address">The fully qualified attribute reference to resolve.</param>
/// <returns>The runtime item handle assigned to the attribute.</returns>
int AddItem(int handle, string address);
/// <summary>
/// Removes a previously registered attribute from the runtime session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle returned by <see cref="AddItem(int, string)" />.</param>
void RemoveItem(int handle, int itemHandle);
/// <summary>
/// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to monitor.</param>
void AdviseSupervisory(int handle, int itemHandle);
/// <summary>
/// Stops supervisory updates for an attribute.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to stop monitoring.</param>
void UnAdviseSupervisory(int handle, int itemHandle);
/// <summary>
/// Writes a new value to a runtime attribute through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to write.</param>
/// <param name="value">The new value to push into the runtime.</param>
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
void Write(int handle, int itemHandle, object value, int securityClassification);
/// <summary>
/// Occurs when the runtime pushes a data-change callback for a subscribed attribute.
/// </summary>
event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the runtime acknowledges completion of a write request.
/// </summary>
event MxWriteCompleteHandler? OnWriteComplete;
}
}

View File

@@ -1,41 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP,
/// etc.).
/// </summary>
public interface IUserAuthenticationProvider
{
/// <summary>
/// Validates a username/password combination.
/// </summary>
bool ValidateCredentials(string username, string password);
}
/// <summary>
/// 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.
/// </summary>
public interface IRoleProvider
{
/// <summary>
/// Returns the set of application-level roles granted to the user.
/// </summary>
IReadOnlyList<string> GetUserRoles(string username);
}
/// <summary>
/// Well-known application-level role names used for permission enforcement.
/// </summary>
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";
}
}

View File

@@ -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
{
/// <summary>
/// Validates credentials via LDAP bind and resolves group membership to application roles.
/// </summary>
public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
{
private static readonly ILogger Log = Serilog.Log.ForContext<LdapAuthenticationProvider>();
private readonly LdapConfiguration _config;
private readonly Dictionary<string, string> _groupToRole;
public LdapAuthenticationProvider(LdapConfiguration config)
{
_config = config;
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ config.ReadOnlyGroup, AppRoles.ReadOnly },
{ config.WriteOperateGroup, AppRoles.WriteOperate },
{ config.WriteTuneGroup, AppRoles.WriteTune },
{ config.WriteConfigureGroup, AppRoles.WriteConfigure },
{ config.AlarmAckGroup, AppRoles.AlarmAck }
};
}
public IReadOnlyList<string> 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<string>();
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");
}
}
}

View File

@@ -1,18 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// 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.
/// </summary>
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";
}
}

View File

@@ -1,87 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// 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.
/// </summary>
public static class MxDataTypeMapper
{
/// <summary>
/// Maps mx_data_type to OPC UA DataType NodeId numeric identifier.
/// Unknown types default to String (i=12).
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The OPC UA built-in data type node identifier.</returns>
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
};
}
/// <summary>
/// Maps mx_data_type to the corresponding CLR type.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The CLR type used to represent runtime values for the MX type.</returns>
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)
};
}
/// <summary>
/// Returns the OPC UA type name for a given mx_data_type.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The OPC UA type name used in diagnostics.</returns>
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"
};
}
}
}

View File

@@ -1,76 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009)
/// </summary>
public static class MxErrorCodes
{
/// <summary>
/// The requested Galaxy attribute reference does not resolve in the runtime.
/// </summary>
public const int MX_E_InvalidReference = 1008;
/// <summary>
/// The supplied value does not match the attribute's configured data type.
/// </summary>
public const int MX_E_WrongDataType = 1012;
/// <summary>
/// The target attribute cannot be written because it is read-only or protected.
/// </summary>
public const int MX_E_NotWritable = 1013;
/// <summary>
/// The runtime did not complete the operation within the configured timeout.
/// </summary>
public const int MX_E_RequestTimedOut = 1014;
/// <summary>
/// Communication with the MXAccess runtime failed during the operation.
/// </summary>
public const int MX_E_CommFailure = 1015;
/// <summary>
/// The operation was attempted without an active MXAccess session.
/// </summary>
public const int MX_E_NotConnected = 1016;
/// <summary>
/// Converts a numeric MXAccess error code into an operator-facing message.
/// </summary>
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
/// <returns>A human-readable description of the runtime failure.</returns>
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}"
};
}
/// <summary>
/// Maps an MXAccess error code to the OPC quality state that should be exposed to clients.
/// </summary>
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
/// <returns>The quality classification that best represents the runtime failure.</returns>
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
};
}
}
}

View File

@@ -1,18 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Maps a deployed Galaxy platform to the hostname where it executes.
/// </summary>
public class PlatformInfo
{
/// <summary>
/// Gets or sets the gobject_id of the platform object in the Galaxy repository.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the hostname (node_name) where the platform is deployed.
/// </summary>
public string NodeName { get; set; } = "";
}
}

View File

@@ -1,122 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// OPC DA quality codes mapped from MXAccess quality values. (MXA-009, OPC-005)
/// </summary>
public enum Quality : byte
{
// Bad family (0-63)
/// <summary>
/// No valid process value is available.
/// </summary>
Bad = 0,
/// <summary>
/// The value is invalid because the Galaxy attribute definition or mapping is wrong.
/// </summary>
BadConfigError = 4,
/// <summary>
/// The bridge is not currently connected to the Galaxy runtime.
/// </summary>
BadNotConnected = 8,
/// <summary>
/// The runtime device or adapter failed while obtaining the value.
/// </summary>
BadDeviceFailure = 12,
/// <summary>
/// The underlying field source reported a bad sensor condition.
/// </summary>
BadSensorFailure = 16,
/// <summary>
/// Communication with the runtime failed while retrieving the value.
/// </summary>
BadCommFailure = 20,
/// <summary>
/// The attribute is intentionally unavailable for service, such as a locked or unwritable value.
/// </summary>
BadOutOfService = 24,
/// <summary>
/// The bridge is still waiting for the first usable value after startup or resubscription.
/// </summary>
BadWaitingForInitialData = 32,
// Uncertain family (64-191)
/// <summary>
/// A value is available, but it should be treated cautiously.
/// </summary>
Uncertain = 64,
/// <summary>
/// The last usable value is being repeated because a newer one is unavailable.
/// </summary>
UncertainLastUsable = 68,
/// <summary>
/// The sensor or source is providing a value with reduced accuracy.
/// </summary>
UncertainSensorNotAccurate = 80,
/// <summary>
/// The value exceeds its engineered limits.
/// </summary>
UncertainEuExceeded = 84,
/// <summary>
/// The source is operating in a degraded or subnormal state.
/// </summary>
UncertainSubNormal = 88,
// Good family (192+)
/// <summary>
/// The value is current and suitable for normal client use.
/// </summary>
Good = 192,
/// <summary>
/// The value is good but currently overridden locally rather than flowing from the live source.
/// </summary>
GoodLocalOverride = 216
}
/// <summary>
/// Helper methods for reasoning about OPC quality families used by the bridge.
/// </summary>
public static class QualityExtensions
{
/// <summary>
/// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true" /> when the value is in the good quality range; otherwise, <see langword="false" />.</returns>
public static bool IsGood(this Quality q)
{
return (byte)q >= 192;
}
/// <summary>
/// Determines whether the quality represents an uncertain runtime value that should be treated cautiously.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true" /> when the value is in the uncertain range; otherwise, <see langword="false" />.</returns>
public static bool IsUncertain(this Quality q)
{
return (byte)q >= 64 && (byte)q < 192;
}
/// <summary>
/// Determines whether the quality represents a bad runtime value that should not be used as valid process data.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true" /> when the value is in the bad range; otherwise, <see langword="false" />.</returns>
public static bool IsBad(this Quality q)
{
return (byte)q < 64;
}
}
}

View File

@@ -1,60 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Maps MXAccess integer quality to domain Quality enum and OPC UA StatusCodes. (MXA-009, OPC-005)
/// </summary>
public static class QualityMapper
{
/// <summary>
/// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality.
/// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad.
/// </summary>
/// <param name="mxQuality">The raw MXAccess quality integer.</param>
/// <returns>The mapped bridge quality value.</returns>
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;
}
/// <summary>
/// Maps domain Quality to OPC UA StatusCode uint32.
/// </summary>
/// <param name="quality">The bridge quality value.</param>
/// <returns>The OPC UA status code represented as a 32-bit unsigned integer.</returns>
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
};
}
}
}

View File

@@ -1,30 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Maps Galaxy security classification values to OPC UA write access decisions.
/// See gr/data_type_mapping.md for the full mapping table.
/// </summary>
public static class SecurityClassificationMapper
{
/// <summary>
/// Determines whether an attribute with the given security classification should allow writes.
/// </summary>
/// <param name="securityClassification">The Galaxy security classification value.</param>
/// <returns>
/// <see langword="true" /> for FreeAccess (0), Operate (1), Tune (4), Configure (5);
/// <see langword="false" /> for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6).
/// </returns>
public static bool IsWritable(int securityClassification)
{
switch (securityClassification)
{
case 2: // SecuredWrite
case 3: // VerifiedWrite
case 6: // ViewOnly
return false;
default:
return true;
}
}
}
}

View File

@@ -1,96 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007)
/// </summary>
public readonly struct Vtq : IEquatable<Vtq>
{
/// <summary>
/// Gets the runtime value returned for the Galaxy attribute.
/// </summary>
public object? Value { get; }
/// <summary>
/// Gets the timestamp associated with the runtime value.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets the quality classification that tells OPC UA clients whether the value is usable.
/// </summary>
public Quality Quality { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Vtq" /> struct for a Galaxy attribute value.
/// </summary>
/// <param name="value">The runtime value returned by MXAccess.</param>
/// <param name="timestamp">The timestamp assigned to the runtime value.</param>
/// <param name="quality">The quality classification for the runtime value.</param>
public Vtq(object? value, DateTime timestamp, Quality quality)
{
Value = value;
Timestamp = timestamp;
Quality = quality;
}
/// <summary>
/// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value.
/// </summary>
/// <param name="value">The runtime value to wrap.</param>
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and good quality.</returns>
public static Vtq Good(object? value)
{
return new Vtq(value, DateTime.UtcNow, Quality.Good);
}
/// <summary>
/// Creates a bad-quality VTQ snapshot when no usable runtime value is available.
/// </summary>
/// <param name="quality">The specific bad quality reason to expose to clients.</param>
/// <returns>A VTQ with no value, the current UTC timestamp, and the requested bad quality.</returns>
public static Vtq Bad(Quality quality = Quality.Bad)
{
return new Vtq(null, DateTime.UtcNow, quality);
}
/// <summary>
/// Creates an uncertain VTQ snapshot when the runtime value exists but should be treated cautiously.
/// </summary>
/// <param name="value">The runtime value to wrap.</param>
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and uncertain quality.</returns>
public static Vtq Uncertain(object? value)
{
return new Vtq(value, DateTime.UtcNow, Quality.Uncertain);
}
/// <summary>
/// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
/// </summary>
/// <param name="other">The other VTQ snapshot to compare.</param>
/// <returns><see langword="true" /> when all fields match; otherwise, <see langword="false" />.</returns>
public bool Equals(Vtq other)
{
return Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
}
/// <inheritdoc />
public override bool Equals(object? obj)
{
return obj is Vtq other && Equals(other);
}
/// <inheritdoc />
public override int GetHashCode()
{
return HashCode.Combine(Value, Timestamp, Quality);
}
/// <inheritdoc />
public override string ToString()
{
return $"Vtq({Value}, {Timestamp:O}, {Quality})";
}
}
}

View File

@@ -1,7 +0,0 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura>
<ExcludeAssemblies>
ArchestrA.MxAccess
</ExcludeAssemblies>
</Costura>
</Weavers>

View File

@@ -1,176 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="Costura" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:all>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX86Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinArm64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
</xs:all>
<xs:attribute name="CreateTemporaryAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>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.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeDebugSymbols" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if .pdbs for reference assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeRuntimeReferences" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if runtime assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UseRuntimeReferencePaths" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls whether the runtime assemblies are embedded with their full path or only with their assembly name.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCompression" type="xs:boolean">
<xs:annotation>
<xs:documentation>Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCleanup" type="xs:boolean">
<xs:annotation>
<xs:documentation>As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableEventSubscription" type="xs:boolean">
<xs:annotation>
<xs:documentation>The attach method no longer subscribes to the `AppDomain.AssemblyResolve` (.NET 4.x) and `AssemblyLoadContext.Resolving` (.NET 6.0+) events.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="LoadAtModuleInit" type="xs:boolean">
<xs:annotation>
<xs:documentation>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.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IgnoreSatelliteAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>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.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UnmanagedWinX86Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UnmanagedWinX64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UnmanagedWinArm64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>

View File

@@ -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
{
/// <summary>
/// Polls the Galaxy database for deployment changes and fires OnGalaxyChanged. (GR-003, GR-004)
/// </summary>
public class ChangeDetectionService : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<ChangeDetectionService>();
private readonly int _intervalSeconds;
private readonly IGalaxyRepository _repository;
private CancellationTokenSource? _cts;
private Task? _pollTask;
/// <summary>
/// Initializes a new change detector for Galaxy deploy timestamps.
/// </summary>
/// <param name="repository">The repository used to query the latest deploy timestamp.</param>
/// <param name="intervalSeconds">The polling interval, in seconds, between deploy checks.</param>
/// <param name="initialDeployTime">An optional deploy timestamp already known at service startup.</param>
public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds,
DateTime? initialDeployTime = null)
{
_repository = repository;
_intervalSeconds = intervalSeconds;
LastKnownDeployTime = initialDeployTime;
}
/// <summary>
/// Gets the last deploy timestamp observed by the polling loop.
/// </summary>
public DateTime? LastKnownDeployTime { get; private set; }
/// <summary>
/// Stops the polling loop and disposes the underlying cancellation resources.
/// </summary>
public void Dispose()
{
Stop();
_cts?.Dispose();
}
/// <summary>
/// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt.
/// </summary>
public event Action? OnGalaxyChanged;
/// <summary>
/// Starts the background polling loop that watches for Galaxy deploy changes.
/// </summary>
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);
}
/// <summary>
/// Stops the background polling loop.
/// </summary>
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;
}
}
}
}
}

View File

@@ -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
{
/// <summary>
/// Implements IGalaxyRepository using SQL queries against the Galaxy ZB database. (GR-001 through GR-007)
/// </summary>
public class GalaxyRepositoryService : IGalaxyRepository
{
private static readonly ILogger Log = Serilog.Log.ForContext<GalaxyRepositoryService>();
private readonly GalaxyRepositoryConfiguration _config;
/// <summary>
/// When <see cref="Configuration.GalaxyScope.LocalPlatform" /> filtering is active, caches the set of
/// gobject_ids that passed the hierarchy filter so <see cref="GetAttributesAsync" /> can apply the same scope.
/// Populated by <see cref="GetHierarchyAsync" /> and consumed by <see cref="GetAttributesAsync" />.
/// </summary>
private HashSet<int>? _scopeFilteredGobjectIds;
/// <summary>
/// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
/// </summary>
/// <param name="config">The repository connection, timeout, and attribute-selection settings.</param>
public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
{
_config = config;
}
/// <summary>
/// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
/// </summary>
public event Action? OnGalaxyChanged;
/// <summary>
/// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The deployed Galaxy objects that should appear in the namespace.</returns>
public async Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
{
var results = new List<GalaxyObjectInfo>();
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<string>()
: 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;
}
/// <summary>
/// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The attribute rows required to build runtime tag mappings and variable metadata.</returns>
public async Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
{
var results = new List<GalaxyAttributeInfo>();
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;
}
/// <summary>
/// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The most recent deploy timestamp, or <see langword="null" /> when none is available.</returns>
public async Task<DateTime?> 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;
}
/// <summary>
/// Executes a lightweight query to confirm that the repository database is reachable.
/// </summary>
/// <param name="ct">A token that cancels the connectivity check.</param>
/// <returns><see langword="true" /> when the query succeeds; otherwise, <see langword="false" />.</returns>
public async Task<bool> 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;
}
}
/// <summary>
/// Queries the platform table for deployed platform-to-hostname mappings used by
/// <see cref="Configuration.GalaxyScope.LocalPlatform" /> filtering.
/// </summary>
private async Task<List<PlatformInfo>> GetPlatformsAsync(CancellationToken ct = default)
{
var results = new List<PlatformInfo>();
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;
}
/// <summary>
/// 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
/// </summary>
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
};
}
/// <summary>
/// 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
/// </summary>
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)
};
}
/// <summary>
/// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy.
/// </summary>
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
}
}

View File

@@ -1,40 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
{
/// <summary>
/// POCO for dashboard: Galaxy repository status info. (DASH-009)
/// </summary>
public class GalaxyRepositoryStats
{
/// <summary>
/// Gets or sets the Galaxy name currently being represented by the bridge.
/// </summary>
public string GalaxyName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the Galaxy repository database is reachable.
/// </summary>
public bool DbConnected { get; set; }
/// <summary>
/// Gets or sets the latest deploy timestamp read from the Galaxy repository.
/// </summary>
public DateTime? LastDeployTime { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy objects currently published into the OPC UA address space.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space.
/// </summary>
public int AttributeCount { get; set; }
/// <summary>
/// Gets or sets the UTC time when the address space was last rebuilt from repository data.
/// </summary>
public DateTime? LastRebuildTime { get; set; }
}
}

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
public static class PlatformScopeFilter
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(PlatformScopeFilter));
private const int CategoryWinPlatform = 1;
private const int CategoryAppEngine = 3;
/// <summary>
/// Filters the hierarchy to objects hosted by the platform whose <c>node_name</c> matches
/// <paramref name="platformName" />, plus ancestor areas that keep the tree connected.
/// </summary>
/// <param name="hierarchy">The full Galaxy object hierarchy.</param>
/// <param name="platforms">Deployed platform-to-hostname mappings from the <c>platform</c> table.</param>
/// <param name="platformName">The target hostname to match (case-insensitive).</param>
/// <returns>
/// 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.
/// </returns>
public static (List<GalaxyObjectInfo> Hierarchy, HashSet<int> GobjectIds) Filter(
List<GalaxyObjectInfo> hierarchy,
List<PlatformInfo> 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<GalaxyObjectInfo>(), new HashSet<int>());
}
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<int> { 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<int>(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<int>(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);
}
/// <summary>
/// Filters attributes to retain only those belonging to objects in the given set.
/// </summary>
public static List<GalaxyAttributeInfo> FilterAttributes(
List<GalaxyAttributeInfo> attributes,
HashSet<int> 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;
}
}
}

View File

@@ -1,31 +0,0 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// 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.
/// </summary>
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;
}
}
}

View File

@@ -1,49 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// Point-in-time state of a single historian cluster node. One entry per configured node is
/// surfaced inside <see cref="HistorianHealthSnapshot"/> so the status dashboard can render
/// per-node health and operators can see which nodes are in cooldown.
/// </summary>
public sealed class HistorianClusterNodeState
{
/// <summary>
/// Gets or sets the configured node hostname exactly as it appears in
/// <c>HistorianConfiguration.ServerNames</c>.
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the node is currently eligible for new connection
/// attempts. <see langword="false"/> means the node is in its post-failure cooldown window
/// and the picker is skipping it.
/// </summary>
public bool IsHealthy { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp at which the node's cooldown expires, or
/// <see langword="null"/> when the node is not in cooldown.
/// </summary>
public DateTime? CooldownUntil { get; set; }
/// <summary>
/// Gets or sets the number of times this node has transitioned from healthy to failed
/// since startup. Does not decrement on recovery.
/// </summary>
public int FailureCount { get; set; }
/// <summary>
/// Gets or sets the message from the most recent failure, or <see langword="null"/> when
/// the node has never failed.
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the most recent failure, or <see langword="null"/>
/// when the node has never failed.
/// </summary>
public DateTime? LastFailureTime { get; set; }
}
}

View File

@@ -1,18 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// SDK-free representation of a Historian event record exposed by the historian plugin.
/// Prevents ArchestrA types from leaking into the Host assembly.
/// </summary>
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; }
}
}

View File

@@ -1,97 +0,0 @@
using System;
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// 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
/// (<see cref="HistorianPluginLoader.LastOutcome"/>) and actual query behavior so operators
/// can detect silent query degradation.
/// </summary>
public sealed class HistorianHealthSnapshot
{
/// <summary>
/// Gets or sets the total number of historian read operations attempted since startup
/// across all read paths (raw, aggregate, at-time, events).
/// </summary>
public long TotalQueries { get; set; }
/// <summary>
/// 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".
/// </summary>
public long TotalSuccesses { get; set; }
/// <summary>
/// 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.
/// </summary>
public long TotalFailures { get; set; }
/// <summary>
/// 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.
/// </summary>
public int ConsecutiveFailures { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the last successful read, or <see langword="null"/>
/// when no query has succeeded since startup.
/// </summary>
public DateTime? LastSuccessTime { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the last failure, or <see langword="null"/> when no
/// query has failed since startup.
/// </summary>
public DateTime? LastFailureTime { get; set; }
/// <summary>
/// Gets or sets the exception message from the most recent failure. Cleared on the next
/// successful query.
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the plugin currently holds an open SDK
/// connection for the process (historical values) path.
/// </summary>
public bool ProcessConnectionOpen { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the plugin currently holds an open SDK
/// connection for the event (alarm history) path.
/// </summary>
public bool EventConnectionOpen { get; set; }
/// <summary>
/// Gets or sets the node the plugin is currently connected to for the process path,
/// or <see langword="null"/> when no connection is open.
/// </summary>
public string? ActiveProcessNode { get; set; }
/// <summary>
/// Gets or sets the node the plugin is currently connected to for the event path,
/// or <see langword="null"/> when no event connection is open.
/// </summary>
public string? ActiveEventNode { get; set; }
/// <summary>
/// Gets or sets the total number of configured historian cluster nodes. A value of 1
/// reflects a legacy single-node deployment.
/// </summary>
public int NodeCount { get; set; }
/// <summary>
/// Gets or sets the number of configured nodes that are currently healthy (not in cooldown).
/// </summary>
public int HealthyNodeCount { get; set; }
/// <summary>
/// Gets or sets the per-node cluster state in configuration order.
/// </summary>
public List<HistorianClusterNodeState> Nodes { get; set; } = new();
}
}

View File

@@ -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
{
/// <summary>
/// Result of the most recent historian plugin load attempt.
/// </summary>
public enum HistorianPluginStatus
{
/// <summary>Historian.Enabled is false; TryLoad was not called.</summary>
Disabled,
/// <summary>Plugin DLL was not present in the Historian/ subfolder.</summary>
NotFound,
/// <summary>Plugin file exists but could not be loaded or instantiated.</summary>
LoadFailed,
/// <summary>Plugin loaded and an IHistorianDataSource was constructed.</summary>
Loaded
}
/// <summary>
/// Structured outcome of a <see cref="HistorianPluginLoader.TryLoad"/> or
/// <see cref="HistorianPluginLoader.MarkDisabled"/> call, used by the status dashboard.
/// </summary>
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; }
}
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Gets the outcome of the most recent load attempt (or <see cref="HistorianPluginStatus.Disabled"/>
/// if the loader has never been invoked). The dashboard reads this to distinguish "disabled",
/// "plugin missing", and "plugin crashed".
/// </summary>
public static HistorianPluginOutcome LastOutcome { get; private set; }
= new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
/// <summary>
/// Records that the historian plugin is disabled by configuration. Called by
/// <c>OpcUaService</c> when <c>Historian.Enabled=false</c> so the status dashboard can
/// report the exact reason history is unavailable.
/// </summary>
public static void MarkDisabled()
{
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
}
/// <summary>
/// Attempts to load the historian plugin and construct an <see cref="IHistorianDataSource"/>.
/// Returns null on any failure so the server can continue with history unsupported. The
/// specific reason is published on <see cref="LastOutcome"/>.
/// </summary>
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;
}
}
}
}

View File

@@ -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
{
/// <summary>
/// Manages continuation points for OPC UA HistoryRead requests that return
/// more data than the per-request limit allows.
/// </summary>
internal sealed class HistoryContinuationPointManager
{
private static readonly ILogger Log = Serilog.Log.ForContext<HistoryContinuationPointManager>();
private readonly ConcurrentDictionary<Guid, StoredContinuation> _store = new();
private readonly TimeSpan _timeout;
public HistoryContinuationPointManager() : this(TimeSpan.FromMinutes(5)) { }
internal HistoryContinuationPointManager(TimeSpan timeout)
{
_timeout = timeout;
}
/// <summary>
/// Stores remaining data values and returns a continuation point identifier.
/// </summary>
public byte[] Store(List<DataValue> 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();
}
/// <summary>
/// Retrieves and removes the remaining data values for a continuation point.
/// Returns null if the continuation point is invalid or expired.
/// </summary>
public List<DataValue>? 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;
}
/// <summary>
/// Releases a continuation point without retrieving its data.
/// </summary>
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<DataValue> values, DateTime createdAt)
{
Values = values;
CreatedAt = createdAt;
}
public List<DataValue> Values { get; }
public DateTime CreatedAt { get; }
}
}
}

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
public interface IHistorianDataSource : IDisposable
{
Task<List<DataValue>> ReadRawAsync(
string tagName, DateTime startTime, DateTime endTime, int maxValues,
CancellationToken ct = default);
Task<List<DataValue>> ReadAggregateAsync(
string tagName, DateTime startTime, DateTime endTime,
double intervalMs, string aggregateColumn,
CancellationToken ct = default);
Task<List<DataValue>> ReadAtTimeAsync(
string tagName, DateTime[] timestamps,
CancellationToken ct = default);
Task<List<HistorianEventDto>> ReadEventsAsync(
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
CancellationToken ct = default);
/// <summary>
/// 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.
/// </summary>
HistorianHealthSnapshot GetHealthSnapshot();
}
}

View File

@@ -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
{
/// <summary>
/// Disposable scope returned by <see cref="PerformanceMetrics.BeginOperation" />. (MXA-008)
/// </summary>
public interface ITimingScope : IDisposable
{
/// <summary>
/// Marks whether the timed bridge operation completed successfully.
/// </summary>
/// <param name="success">A value indicating whether the measured operation succeeded.</param>
void SetSuccess(bool success);
}
/// <summary>
/// Statistics snapshot for a single operation type.
/// </summary>
public class MetricsStatistics
{
/// <summary>
/// Gets or sets the total number of recorded executions for the operation.
/// </summary>
public long TotalCount { get; set; }
/// <summary>
/// Gets or sets the number of recorded executions that completed successfully.
/// </summary>
public long SuccessCount { get; set; }
/// <summary>
/// Gets or sets the ratio of successful executions to total executions.
/// </summary>
public double SuccessRate { get; set; }
/// <summary>
/// Gets or sets the mean execution time in milliseconds across the recorded sample.
/// </summary>
public double AverageMilliseconds { get; set; }
/// <summary>
/// Gets or sets the fastest recorded execution time in milliseconds.
/// </summary>
public double MinMilliseconds { get; set; }
/// <summary>
/// Gets or sets the slowest recorded execution time in milliseconds.
/// </summary>
public double MaxMilliseconds { get; set; }
/// <summary>
/// Gets or sets the 95th percentile execution time in milliseconds.
/// </summary>
public double Percentile95Milliseconds { get; set; }
}
/// <summary>
/// Per-operation timing and success tracking with a 1000-entry rolling buffer. (MXA-008)
/// </summary>
public class OperationMetrics
{
private readonly List<double> _durations = new();
private readonly object _lock = new();
private double _maxMilliseconds;
private double _minMilliseconds = double.MaxValue;
private long _successCount;
private long _totalCount;
private double _totalMilliseconds;
/// <summary>
/// Records the outcome and duration of a single bridge operation invocation.
/// </summary>
/// <param name="duration">The elapsed time for the operation.</param>
/// <param name="success">A value indicating whether the operation completed successfully.</param>
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);
}
}
/// <summary>
/// Creates a snapshot of the current statistics for this operation type.
/// </summary>
/// <returns>A statistics snapshot suitable for logs, status reporting, and tests.</returns>
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]
};
}
}
}
/// <summary>
/// Tracks per-operation performance metrics with periodic logging. (MXA-008)
/// </summary>
public class PerformanceMetrics : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
private readonly ConcurrentDictionary<string, OperationMetrics>
_metrics = new(StringComparer.OrdinalIgnoreCase);
private readonly Timer _reportingTimer;
private bool _disposed;
/// <summary>
/// Initializes a new metrics collector and starts periodic performance reporting.
/// </summary>
public PerformanceMetrics()
{
_reportingTimer = new Timer(ReportMetrics, null,
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
}
/// <summary>
/// Stops periodic reporting and emits a final metrics snapshot.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_reportingTimer.Dispose();
ReportMetrics(null);
}
/// <summary>
/// Records a completed bridge operation under the specified metrics bucket.
/// </summary>
/// <param name="operationName">The logical operation name, such as read, write, or subscribe.</param>
/// <param name="duration">The elapsed time for the operation.</param>
/// <param name="success">A value indicating whether the operation completed successfully.</param>
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
{
var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
metrics.Record(duration, success);
}
/// <summary>
/// Starts timing a bridge operation and returns a disposable scope that records the result when disposed.
/// </summary>
/// <param name="operationName">The logical operation name to record.</param>
/// <returns>A timing scope that reports elapsed time back into this collector.</returns>
public ITimingScope BeginOperation(string operationName)
{
return new TimingScope(this, operationName);
}
/// <summary>
/// Retrieves the raw metrics bucket for a named operation.
/// </summary>
/// <param name="operationName">The logical operation name to look up.</param>
/// <returns>The metrics bucket when present; otherwise, <see langword="null" />.</returns>
public OperationMetrics? GetMetrics(string operationName)
{
return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
}
/// <summary>
/// Produces a statistics snapshot for all recorded bridge operations.
/// </summary>
/// <returns>A dictionary keyed by operation name containing current metrics statistics.</returns>
public Dictionary<string, MetricsStatistics> GetStatistics()
{
var result = new Dictionary<string, MetricsStatistics>(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);
}
}
/// <summary>
/// Timing scope that records one operation result into the owning metrics collector.
/// </summary>
private class TimingScope : ITimingScope
{
private readonly PerformanceMetrics _metrics;
private readonly string _operationName;
private readonly Stopwatch _stopwatch;
private bool _disposed;
private bool _success = true;
/// <summary>
/// Initializes a timing scope for a named bridge operation.
/// </summary>
/// <param name="metrics">The metrics collector that should receive the result.</param>
/// <param name="operationName">The logical operation name being timed.</param>
public TimingScope(PerformanceMetrics metrics, string operationName)
{
_metrics = metrics;
_operationName = operationName;
_stopwatch = Stopwatch.StartNew();
}
/// <summary>
/// Marks whether the timed operation should be recorded as successful.
/// </summary>
/// <param name="success">A value indicating whether the operation succeeded.</param>
public void SetSuccess(bool success)
{
_success = success;
}
/// <summary>
/// Stops timing and records the operation result once.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_stopwatch.Stop();
_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success);
}
}
}
}

View File

@@ -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
{
/// <summary>
/// Advises <c>&lt;ObjectName&gt;.ScanState</c> on every deployed <c>$WinPlatform</c> and
/// <c>$AppEngine</c>, 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 <c>BadOutOfService</c> (and clear on recovery).
/// </summary>
/// <remarks>
/// State machine semantics are documented in <c>runtimestatus.md</c>. Key facts:
/// <list type="bullet">
/// <item><c>ScanState</c> is delivered on-change only — no periodic heartbeat. A stably
/// Running host may go hours without a callback.</item>
/// <item>Running → Stopped is driven by explicit error callbacks or <c>ScanState = false</c>,
/// NEVER by starvation. The only starvation check applies to the initial Unknown state.</item>
/// <item>When the MxAccess transport is disconnected, <see cref="GetSnapshot"/> returns every
/// entry with <see cref="GalaxyRuntimeState.Unknown"/> regardless of the underlying state,
/// because we can't observe anything through a dead transport.</item>
/// <item>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 <c>Lock</c>.</item>
/// </list>
/// </remarks>
public sealed class GalaxyRuntimeProbeManager : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<GalaxyRuntimeProbeManager>();
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<int>? _onHostStopped;
private readonly Action<int>? _onHostRunning;
private readonly Func<DateTime> _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<string, GalaxyRuntimeStatus> _byProbe =
new Dictionary<string, GalaxyRuntimeStatus>(StringComparer.OrdinalIgnoreCase);
// Reverse index: gobject_id -> probe tag, so Sync() can diff new/removed hosts efficiently.
private readonly Dictionary<int, string> _probeByGobjectId = new Dictionary<int, string>();
private readonly object _lock = new object();
private bool _disposed;
/// <summary>
/// Initializes a new probe manager. <paramref name="onHostStopped"/> and
/// <paramref name="onHostRunning"/> are invoked synchronously on Running↔Stopped
/// transitions so the owning node manager can invalidate / restore the hosted subtree.
/// </summary>
public GalaxyRuntimeProbeManager(
IMxAccessClient client,
int unknownTimeoutSeconds,
Action<int>? onHostStopped = null,
Action<int>? onHostRunning = null)
: this(client, unknownTimeoutSeconds, onHostStopped, onHostRunning, () => DateTime.UtcNow)
{
}
internal GalaxyRuntimeProbeManager(
IMxAccessClient client,
int unknownTimeoutSeconds,
Action<int>? onHostStopped,
Action<int>? onHostRunning,
Func<DateTime> 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));
}
/// <summary>
/// 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.
/// </summary>
public int ActiveProbeCount
{
get
{
lock (_lock)
return _byProbe.Count;
}
}
/// <summary>
/// Returns <see langword="true"/> when the galaxy runtime host identified by
/// <paramref name="gobjectId"/> is currently in the <see cref="GalaxyRuntimeState.Stopped"/>
/// 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 <see cref="GetSnapshot"/> 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.
/// </summary>
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;
}
/// <summary>
/// Returns a point-in-time clone of the runtime status for the host identified by
/// <paramref name="gobjectId"/>, or <see langword="null"/> when no probe is registered
/// for that object. Used by the node manager to populate the synthetic <c>$RuntimeState</c>
/// child variables on each host object. Uses the underlying state directly (not the
/// transport-gated rewrite), matching <see cref="IsHostStopped"/>.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Sync is synchronous on MxAccess: <see cref="IMxAccessClient.SubscribeAsync"/> 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.
/// </remarks>
public async Task SyncAsync(IReadOnlyList<GalaxyObjectInfo> hierarchy)
{
if (_disposed || hierarchy == null)
return;
// Filter to runtime hosts and project to the expected probe tag name.
var desired = new Dictionary<int, (string Probe, string Kind, GalaxyObjectInfo Obj)>();
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<string> toUnsubscribe;
lock (_lock)
{
toSubscribe = new List<(int, string)>();
toUnsubscribe = new List<string>();
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);
}
}
}
/// <summary>
/// Routes an <c>OnTagValueChanged</c> callback to the probe state machine. Returns
/// <see langword="true"/> when <paramref name="tagRef"/> matches a bridge-owned probe
/// (in which case the owning node manager should skip its normal variable-update path).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public void Tick()
{
if (_disposed)
return;
var transitions = new List<int>();
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); }
}
}
/// <summary>
/// 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 <c>_byProbe</c> map is not modified.
/// </summary>
public IReadOnlyList<GalaxyRuntimeStatus> GetSnapshot()
{
var transportDown = _client.State != ConnectionState.Connected;
lock (_lock)
{
var result = new List<GalaxyRuntimeStatus>(_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;
}
}
/// <inheritdoc />
public void Dispose()
{
List<string> 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)"}";
}
}
}

View File

@@ -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
{
/// <summary>
/// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription.
/// </summary>
/// <param name="ct">A token that cancels the connection attempt.</param>
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;
}
}
/// <summary>
/// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations.
/// </summary>
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);
}
}
/// <summary>
/// Attempts to recover from a runtime fault by disconnecting and reconnecting the client.
/// </summary>
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;
}
}
}

View File

@@ -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
{
/// <summary>
/// COM event handler for MxAccess OnDataChange events.
/// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface.
/// </summary>
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);
}
}
/// <summary>
/// COM event handler for MxAccess OnWriteComplete events.
/// </summary>
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;
}
}
}

View File

@@ -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;
/// <summary>
/// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness.
/// </summary>
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);
}
/// <summary>
/// Stops the background monitor loop.
/// </summary>
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");
}
}
}

View File

@@ -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
{
/// <summary>
/// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to read.</param>
/// <param name="ct">A token that cancels the read.</param>
/// <returns>The resulting VTQ value or a bad-quality fallback on timeout or failure.</returns>
public async Task<Vtq> 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<Vtq>();
var itemHandle = await _staThread.RunAsync(() =>
{
var h = _proxy.AddItem(_connectionHandle, fullTagReference);
_proxy.AdviseSupervisory(_connectionHandle, h);
return h;
});
var pendingReads = _pendingReadsByAddress.GetOrAdd(fullTagReference,
_ => new ConcurrentDictionary<int, TaskCompletionSource<Vtq>>());
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();
}
}
/// <summary>
/// Writes a value to a Galaxy tag and waits for the runtime write-complete callback.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to write.</param>
/// <param name="value">The value to send to the runtime.</param>
/// <param name="ct">A token that cancels the write.</param>
/// <returns><see langword="true" /> when the runtime acknowledges success; otherwise, <see langword="false" />.</returns>
public async Task<bool> 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<bool>();
_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();
}
}
}
}

View File

@@ -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
{
/// <summary>
/// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to monitor.</param>
/// <param name="callback">The callback that should receive runtime value changes.</param>
public async Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
{
_storedSubscriptions[fullTagReference] = callback;
if (_state != ConnectionState.Connected) return;
if (_addressToHandle.ContainsKey(fullTagReference)) return;
await SubscribeInternalAsync(fullTagReference);
}
/// <summary>
/// Removes a persistent subscription callback and tears down the runtime item when appropriate.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to stop monitoring.</param>
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);
}
}
}

View File

@@ -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
{
/// <summary>
/// Core MXAccess client implementing IMxAccessClient via IMxProxy abstraction.
/// Split across partial classes: Connection, Subscription, ReadWrite, EventHandlers, Monitor.
/// (MXA-001 through MXA-009)
/// </summary>
public sealed partial class MxAccessClient : IMxAccessClient
{
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
private readonly ConcurrentDictionary<string, int> _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
private readonly MxAccessConfiguration _config;
// Handle mappings
private readonly ConcurrentDictionary<int, string> _handleToAddress = new();
private readonly PerformanceMetrics _metrics;
private readonly SemaphoreSlim _operationSemaphore;
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, TaskCompletionSource<Vtq>>>
_pendingReadsByAddress
= new(StringComparer.OrdinalIgnoreCase);
// Pending writes
private readonly ConcurrentDictionary<int, TaskCompletionSource<bool>> _pendingWrites = new();
private readonly IMxProxy _proxy;
private readonly StaComThread _staThread;
// Subscription storage
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _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;
/// <summary>
/// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
/// </summary>
/// <param name="staThread">The STA thread used to marshal COM interactions.</param>
/// <param name="proxy">The COM proxy abstraction used to talk to the runtime.</param>
/// <param name="config">The runtime timeout, throttling, and reconnect settings.</param>
/// <param name="metrics">The metrics collector used to time MXAccess operations.</param>
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);
}
/// <summary>
/// Gets the current runtime connection state for the MXAccess client.
/// </summary>
public ConnectionState State => _state;
/// <summary>
/// Gets the number of active tag subscriptions currently maintained against the runtime.
/// </summary>
public int ActiveSubscriptionCount => _storedSubscriptions.Count;
/// <summary>
/// Gets the number of reconnect attempts performed since the client was created.
/// </summary>
public int ReconnectCount => _reconnectCount;
/// <summary>
/// Occurs when the MXAccess connection state changes.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed runtime tag publishes a new value.
/// </summary>
public event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Cancels monitoring and disconnects the runtime session before releasing local resources.
/// </summary>
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));
}
}
}

View File

@@ -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
{
/// <summary>
/// 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)
/// </summary>
public sealed class MxProxyAdapter : IMxProxy
{
private LMXProxyServer? _lmxProxy;
/// <summary>
/// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute.
/// </summary>
public event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the COM proxy confirms completion of a write request.
/// </summary>
public event MxWriteCompleteHandler? OnWriteComplete;
/// <summary>
/// Creates and registers the COM proxy session that backs live MXAccess operations.
/// </summary>
/// <param name="clientName">The client name reported to the Wonderware runtime.</param>
/// <returns>The runtime connection handle assigned by the COM server.</returns>
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;
}
/// <summary>
/// Unregisters the COM proxy session and releases the underlying COM object.
/// </summary>
/// <param name="handle">The runtime connection handle returned by <see cref="Register(string)" />.</param>
public void Unregister(int handle)
{
if (_lmxProxy != null)
try
{
_lmxProxy.OnDataChange -= ProxyOnDataChange;
_lmxProxy.OnWriteComplete -= ProxyOnWriteComplete;
_lmxProxy.Unregister(handle);
}
finally
{
Marshal.ReleaseComObject(_lmxProxy);
_lmxProxy = null;
}
}
/// <summary>
/// Resolves a Galaxy attribute reference into a runtime item handle through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="address">The fully qualified Galaxy attribute reference.</param>
/// <returns>The item handle assigned by the COM proxy.</returns>
public int AddItem(int handle, string address)
{
return _lmxProxy!.AddItem(handle, address);
}
/// <summary>
/// Removes an item handle from the active COM proxy session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to remove.</param>
public void RemoveItem(int handle, int itemHandle)
{
_lmxProxy!.RemoveItem(handle, itemHandle);
}
/// <summary>
/// Enables supervisory callbacks for the specified runtime item.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to monitor.</param>
public void AdviseSupervisory(int handle, int itemHandle)
{
_lmxProxy!.AdviseSupervisory(handle, itemHandle);
}
/// <summary>
/// Disables supervisory callbacks for the specified runtime item.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to stop monitoring.</param>
public void UnAdviseSupervisory(int handle, int itemHandle)
{
_lmxProxy!.UnAdvise(handle, itemHandle);
}
/// <summary>
/// Writes a value to the specified runtime item through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to write.</param>
/// <param name="value">The value to send to the runtime.</param>
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
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);
}
}
}

View File

@@ -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
{
/// <summary>
/// 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)
/// </summary>
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<StaComThread>();
private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5);
private readonly TaskCompletionSource<bool> _ready = new();
private readonly Thread _thread;
private readonly ConcurrentQueue<WorkItem> _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;
/// <summary>
/// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
/// </summary>
public StaComThread()
{
_thread = new Thread(ThreadEntry)
{
Name = "MxAccess-STA",
IsBackground = true
};
_thread.SetApartmentState(ApartmentState.STA);
}
/// <summary>
/// Gets a value indicating whether the STA thread is running and able to accept work.
/// </summary>
public bool IsRunning => _nativeThreadId != 0 && !_disposed && !_pumpExited;
/// <summary>
/// Stops the STA thread and releases the message-pump resources used for COM interop.
/// </summary>
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");
}
/// <summary>
/// Starts the STA thread and waits until its message pump is ready for COM work.
/// </summary>
public void Start()
{
_thread.Start();
_ready.Task.GetAwaiter().GetResult();
Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
}
/// <summary>
/// Queues an action to execute on the STA thread.
/// </summary>
/// <param name="action">The work item to execute on the STA thread.</param>
/// <returns>A task that completes when the action has finished executing.</returns>
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<bool>();
_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;
}
/// <summary>
/// Queues a function to execute on the STA thread and returns its result.
/// </summary>
/// <typeparam name="T">The result type produced by the function.</typeparam>
/// <param name="func">The work item to execute on the STA thread.</param>
/// <returns>A task that completes with the function result.</returns>
public Task<T> RunAsync<T>(Func<T> func)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited");
var tcs = new TaskCompletionSource<T>();
_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<Exception> 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
}
}

View File

@@ -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
{
/// <summary>
/// Builds the tag reference mappings from Galaxy hierarchy and attributes.
/// Testable without an OPC UA server. (OPC-002, OPC-003, OPC-004)
/// </summary>
public class AddressSpaceBuilder
{
private static readonly ILogger Log = Serilog.Log.ForContext<AddressSpaceBuilder>();
/// <summary>
/// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes
/// nodes.
/// </summary>
/// <param name="hierarchy">The Galaxy object hierarchy returned by the repository.</param>
/// <param name="attributes">The Galaxy attribute rows associated with the hierarchy.</param>
/// <returns>An address-space model containing roots, variables, and tag-reference mappings.</returns>
public static AddressSpaceModel Build(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> 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<int>(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<int, List<GalaxyAttributeInfo>> attrsByObject,
Dictionary<int, List<GalaxyObjectInfo>> 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;
}
/// <summary>
/// Node info for the address space tree.
/// </summary>
public class NodeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier represented by this address-space node.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier used to assemble the tree.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the node represents a Galaxy area folder.
/// </summary>
public bool IsArea { get; set; }
/// <summary>
/// Gets or sets the attribute nodes published beneath this object.
/// </summary>
public List<AttributeNodeInfo> Attributes { get; set; } = new();
/// <summary>
/// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy.
/// </summary>
public List<NodeInfo> Children { get; set; } = new();
}
/// <summary>
/// Lightweight description of an attribute node that will become an OPC UA variable.
/// </summary>
public class AttributeNodeInfo
{
/// <summary>
/// Gets or sets the Galaxy attribute name published under the object.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy data type code used to pick the OPC UA variable type.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is modeled as an array.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the declared array length when the attribute is a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the primitive name that groups the attribute under a sub-object node.
/// Empty for root-level attributes.
/// </summary>
public string PrimitiveName { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
/// </summary>
public int SecurityClassification { get; set; } = 1;
/// <summary>
/// Gets or sets a value indicating whether the attribute is historized.
/// </summary>
public bool IsHistorized { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is an alarm.
/// </summary>
public bool IsAlarm { get; set; }
}
/// <summary>
/// Result of building the address space model.
/// </summary>
public class AddressSpaceModel
{
/// <summary>
/// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace.
/// </summary>
public List<NodeInfo> RootNodes { get; set; } = new();
/// <summary>
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
/// </summary>
public Dictionary<string, string> NodeIdToTagReference { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets the number of non-area Galaxy objects included in the model.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of variable nodes created from Galaxy attributes.
/// </summary>
public int VariableCount { get; set; }
}
}
}

View File

@@ -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
{
/// <summary>
/// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes.
/// </summary>
public static class AddressSpaceDiff
{
/// <summary>
/// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference.
/// </summary>
/// <param name="oldHierarchy">The previously published Galaxy object hierarchy snapshot.</param>
/// <param name="oldAttributes">The previously published Galaxy attribute snapshot keyed to the old hierarchy.</param>
/// <param name="newHierarchy">The latest Galaxy object hierarchy snapshot pulled from the repository.</param>
/// <param name="newAttributes">The latest Galaxy attribute snapshot that should be reflected in the OPC UA namespace.</param>
public static HashSet<int> FindChangedGobjectIds(
List<GalaxyObjectInfo> oldHierarchy, List<GalaxyAttributeInfo> oldAttributes,
List<GalaxyObjectInfo> newHierarchy, List<GalaxyAttributeInfo> newAttributes)
{
var changed = new HashSet<int>();
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<int>(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;
}
/// <summary>
/// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy.
/// </summary>
/// <param name="changed">The root Galaxy objects that were detected as changed between snapshots.</param>
/// <param name="hierarchy">The hierarchy used to include descendant objects whose OPC UA nodes must also be rebuilt.</param>
public static HashSet<int> ExpandToSubtrees(HashSet<int> changed, List<GalaxyObjectInfo> hierarchy)
{
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
.ToDictionary(g => g.Key, g => g.Select(h => h.GobjectId).ToList());
var expanded = new HashSet<int>(changed);
var queue = new Queue<int>(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<GalaxyAttributeInfo>? a, List<GalaxyAttributeInfo>? 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;
}
}
}

View File

@@ -1,92 +0,0 @@
using System;
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Converts between domain Vtq and OPC UA DataValue. Handles all data_type_mapping.md types. (OPC-005, OPC-007)
/// </summary>
public static class DataValueConverter
{
/// <summary>
/// Converts a bridge VTQ snapshot into an OPC UA data value.
/// </summary>
/// <param name="vtq">The VTQ snapshot to convert.</param>
/// <returns>An OPC UA data value suitable for reads and subscriptions.</returns>
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;
}
/// <summary>
/// Converts an OPC UA data value back into a bridge VTQ snapshot.
/// </summary>
/// <param name="dataValue">The OPC UA data value to convert.</param>
/// <returns>A VTQ snapshot containing the converted value, timestamp, and derived quality.</returns>
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
};
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,528 +0,0 @@
using System;
using System.Collections.Generic;
using Opc.Ua;
using Opc.Ua.Server;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
using ZB.MOM.WW.OtOpcUa.Host.Historian;
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Custom OPC UA server that creates the LmxNodeManager, handles user authentication,
/// and exposes redundancy state through the standard server object. (OPC-001, OPC-012)
/// </summary>
public class LmxOpcUaServer : StandardServer
{
private static readonly ILogger Log = Serilog.Log.ForContext<LmxOpcUaServer>();
private readonly bool _alarmTrackingEnabled;
private readonly AlarmObjectFilter? _alarmObjectFilter;
private readonly string? _applicationUri;
private readonly AuthenticationConfiguration _authConfig;
private readonly IUserAuthenticationProvider? _authProvider;
private readonly string _galaxyName;
private readonly IHistorianDataSource? _historianDataSource;
private readonly PerformanceMetrics _metrics;
private readonly IMxAccessClient _mxAccessClient;
private readonly RedundancyConfiguration _redundancyConfig;
private readonly ServiceLevelCalculator _serviceLevelCalculator = new();
private NodeId? _alarmAckRoleId;
// Resolved custom role NodeIds (populated in CreateMasterNodeManager)
private NodeId? _readOnlyRoleId;
private NodeId? _writeConfigureRoleId;
private NodeId? _writeOperateRoleId;
private NodeId? _writeTuneRoleId;
private readonly bool _runtimeStatusProbesEnabled;
private readonly int _runtimeStatusUnknownTimeoutSeconds;
private readonly int _mxAccessRequestTimeoutSeconds;
private readonly int _historianRequestTimeoutSeconds;
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
IHistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null,
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null,
AlarmObjectFilter? alarmObjectFilter = null,
bool runtimeStatusProbesEnabled = false,
int runtimeStatusUnknownTimeoutSeconds = 15,
int mxAccessRequestTimeoutSeconds = 30,
int historianRequestTimeoutSeconds = 60)
{
_galaxyName = galaxyName;
_mxAccessClient = mxAccessClient;
_metrics = metrics;
_historianDataSource = historianDataSource;
_alarmTrackingEnabled = alarmTrackingEnabled;
_alarmObjectFilter = alarmObjectFilter;
_authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider;
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
_applicationUri = applicationUri;
_runtimeStatusProbesEnabled = runtimeStatusProbesEnabled;
_runtimeStatusUnknownTimeoutSeconds = runtimeStatusUnknownTimeoutSeconds;
_mxAccessRequestTimeoutSeconds = mxAccessRequestTimeoutSeconds;
_historianRequestTimeoutSeconds = historianRequestTimeoutSeconds;
}
/// <summary>
/// Gets the custom node manager that publishes the Galaxy-backed namespace.
/// </summary>
public LmxNodeManager? NodeManager { get; private set; }
/// <summary>
/// Gets the number of active OPC UA sessions currently connected to the server.
/// </summary>
public int ActiveSessionCount
{
get
{
try
{
return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0;
}
catch
{
return 0;
}
}
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server,
ApplicationConfiguration configuration)
{
// Resolve custom role NodeIds from the roles namespace
ResolveRoleNodeIds(server);
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
NodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
_writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId,
_alarmObjectFilter,
_runtimeStatusProbesEnabled, _runtimeStatusUnknownTimeoutSeconds,
_mxAccessRequestTimeoutSeconds, _historianRequestTimeoutSeconds);
var nodeManagers = new List<INodeManager> { NodeManager };
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
}
private void ResolveRoleNodeIds(IServerInternal server)
{
var nsIndex = server.NamespaceUris.GetIndexOrAppend(LmxRoleIds.NamespaceUri);
_readOnlyRoleId = new NodeId(LmxRoleIds.ReadOnly, nsIndex);
_writeOperateRoleId = new NodeId(LmxRoleIds.WriteOperate, nsIndex);
_writeTuneRoleId = new NodeId(LmxRoleIds.WriteTune, nsIndex);
_writeConfigureRoleId = new NodeId(LmxRoleIds.WriteConfigure, nsIndex);
_alarmAckRoleId = new NodeId(LmxRoleIds.AlarmAck, nsIndex);
Log.Debug("Resolved custom role NodeIds in namespace index {NsIndex}", nsIndex);
}
/// <inheritdoc />
protected override void OnServerStarted(IServerInternal server)
{
base.OnServerStarted(server);
server.SessionManager.ImpersonateUser += OnImpersonateUser;
ConfigureRedundancy(server);
ConfigureHistoryCapabilities(server);
ConfigureServerCapabilities(server);
}
private void ConfigureRedundancy(IServerInternal server)
{
var mode = RedundancyModeResolver.Resolve(_redundancyConfig.Mode, _redundancyConfig.Enabled);
try
{
// Set RedundancySupport via the diagnostics node manager
var redundancySupportNodeId = VariableIds.Server_ServerRedundancy_RedundancySupport;
var redundancySupportNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
redundancySupportNodeId, typeof(BaseVariableState)) as BaseVariableState;
if (redundancySupportNode != null)
{
redundancySupportNode.Value = (int)mode;
redundancySupportNode.ClearChangeMasks(server.DefaultSystemContext, false);
Log.Information("Set RedundancySupport to {Mode}", mode);
}
// Set ServerUriArray for non-transparent redundancy
if (_redundancyConfig.Enabled && _redundancyConfig.ServerUris.Count > 0)
{
var serverUriArrayNodeId = VariableIds.Server_ServerRedundancy_ServerUriArray;
var serverUriArrayNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
serverUriArrayNodeId, typeof(BaseVariableState)) as BaseVariableState;
if (serverUriArrayNode != null)
{
serverUriArrayNode.Value = _redundancyConfig.ServerUris.ToArray();
serverUriArrayNode.ClearChangeMasks(server.DefaultSystemContext, false);
Log.Information("Set ServerUriArray to [{Uris}]",
string.Join(", ", _redundancyConfig.ServerUris));
}
else
{
Log.Warning(
"ServerUriArray node not found in address space — SDK may not expose it for RedundancySupport.None base type");
}
}
// Set initial ServiceLevel
var initialLevel = CalculateCurrentServiceLevel(true, true);
SetServiceLevelValue(server, initialLevel);
Log.Information("Initial ServiceLevel set to {ServiceLevel}", initialLevel);
}
catch (Exception ex)
{
Log.Warning(ex,
"Failed to configure redundancy nodes — redundancy state may not be visible to clients");
}
}
private void ConfigureHistoryCapabilities(IServerInternal server)
{
if (_historianDataSource == null)
return;
try
{
var dnm = server.DiagnosticsNodeManager;
var ctx = server.DefaultSystemContext;
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability, true);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability,
_alarmTrackingEnabled);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_MaxReturnDataValues,
(uint)(_historianDataSource != null ? 10000 : 0));
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_MaxReturnEventValues, (uint)0);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_InsertDataCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_ReplaceDataCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_UpdateDataCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_DeleteRawCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_DeleteAtTimeCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_InsertEventCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_ReplaceEventCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_UpdateEventCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_DeleteEventCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_InsertAnnotationCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_ServerTimestampSupported, true);
// Add aggregate function references under the AggregateFunctions folder
var aggFolderNode = dnm?.FindPredefinedNode(
ObjectIds.HistoryServerCapabilities_AggregateFunctions,
typeof(FolderState)) as FolderState;
if (aggFolderNode != null)
{
var aggregateIds = new[]
{
ObjectIds.AggregateFunction_Average,
ObjectIds.AggregateFunction_Minimum,
ObjectIds.AggregateFunction_Maximum,
ObjectIds.AggregateFunction_Count,
ObjectIds.AggregateFunction_Start,
ObjectIds.AggregateFunction_End,
ObjectIds.AggregateFunction_StandardDeviationPopulation
};
foreach (var aggId in aggregateIds)
{
var aggNode = dnm?.FindPredefinedNode(aggId, typeof(BaseObjectState)) as BaseObjectState;
if (aggNode != null)
{
try
{
aggFolderNode.AddReference(ReferenceTypeIds.Organizes, false, aggNode.NodeId);
}
catch (ArgumentException)
{
// Reference already exists — skip
}
try
{
aggNode.AddReference(ReferenceTypeIds.Organizes, true, aggFolderNode.NodeId);
}
catch (ArgumentException)
{
// Reference already exists — skip
}
}
}
Log.Information("HistoryServerCapabilities configured with {Count} aggregate functions",
aggregateIds.Length);
}
else
{
Log.Warning("AggregateFunctions folder not found in predefined nodes");
}
}
catch (Exception ex)
{
Log.Warning(ex,
"Failed to configure HistoryServerCapabilities — history discovery may not work for clients");
}
}
private void ConfigureServerCapabilities(IServerInternal server)
{
try
{
var dnm = server.DiagnosticsNodeManager;
var ctx = server.DefaultSystemContext;
// Server profiles
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_ServerProfileArray,
new[] { "http://opcfoundation.org/UA-Profile/Server/StandardUA2017" });
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_LocaleIdArray,
new[] { "en" });
// Limits
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MinSupportedSampleRate, 100.0);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxBrowseContinuationPoints, (ushort)100);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxQueryContinuationPoints, (ushort)0);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxHistoryContinuationPoints, (ushort)100);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxArrayLength, (uint)65535);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxStringLength, (uint)(4 * 1024 * 1024));
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxByteStringLength, (uint)(4 * 1024 * 1024));
// OperationLimits
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerWrite, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRegisterNodes, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerTranslateBrowsePathsToNodeIds,
(uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerMethodCall, (uint)0);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerNodeManagement, (uint)0);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadData, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadEvents, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateData, (uint)0);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateEvents, (uint)0);
// Diagnostics
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerDiagnostics_EnabledFlag, true);
Log.Information(
"ServerCapabilities configured (OperationLimits, diagnostics enabled)");
}
catch (Exception ex)
{
Log.Warning(ex,
"Failed to configure ServerCapabilities — capability discovery may not work for clients");
}
}
private static void SetPredefinedVariable(DiagnosticsNodeManager? dnm, ServerSystemContext ctx,
NodeId variableId, object value)
{
var node = dnm?.FindPredefinedNode(variableId, typeof(BaseVariableState)) as BaseVariableState;
if (node != null)
{
node.Value = value;
node.ClearChangeMasks(ctx, false);
}
}
/// <summary>
/// Updates the server's ServiceLevel based on current runtime health.
/// Called by the service layer when MXAccess or DB health changes.
/// </summary>
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
{
var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected);
try
{
if (ServerInternal != null) SetServiceLevelValue(ServerInternal, level);
}
catch (Exception ex)
{
Log.Debug(ex, "Failed to update ServiceLevel node");
}
}
private byte CalculateCurrentServiceLevel(bool mxAccessConnected, bool dbConnected)
{
if (!_redundancyConfig.Enabled)
return 255; // SDK default when redundancy is not configured
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
var baseLevel = isPrimary
? _redundancyConfig.ServiceLevelBase
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
return _serviceLevelCalculator.Calculate(baseLevel, mxAccessConnected, dbConnected);
}
private static void SetServiceLevelValue(IServerInternal server, byte level)
{
var serviceLevelNodeId = VariableIds.Server_ServiceLevel;
var serviceLevelNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
serviceLevelNodeId, typeof(BaseVariableState)) as BaseVariableState;
if (serviceLevelNode != null)
{
serviceLevelNode.Value = level;
serviceLevelNode.ClearChangeMasks(server.DefaultSystemContext, false);
}
}
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
{
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
{
if (!_authConfig.AllowAnonymous)
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected,
"Anonymous access is disabled");
args.Identity = new RoleBasedIdentity(
new UserIdentity(anonymousToken),
new List<Role> { Role.Anonymous });
Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite);
return;
}
if (args.NewIdentity is UserNameIdentityToken userNameToken)
{
var password = userNameToken.DecryptedPassword ?? "";
if (_authProvider == null || !_authProvider.ValidateCredentials(userNameToken.UserName, password))
{
Log.Warning("AUDIT: Authentication FAILED for user {Username} from session {SessionId}",
userNameToken.UserName, session?.Id);
throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password");
}
var roles = new List<Role> { Role.AuthenticatedUser };
if (_authProvider is IRoleProvider roleProvider)
{
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
foreach (var appRole in appRoles)
switch (appRole)
{
case AppRoles.ReadOnly:
if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
break;
case AppRoles.WriteOperate:
if (_writeOperateRoleId != null)
roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate));
break;
case AppRoles.WriteTune:
if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune));
break;
case AppRoles.WriteConfigure:
if (_writeConfigureRoleId != null)
roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure));
break;
case AppRoles.AlarmAck:
if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck));
break;
}
Log.Information("AUDIT: Authentication SUCCESS for user {Username} with roles [{Roles}] session {SessionId}",
userNameToken.UserName, string.Join(", ", appRoles), session?.Id);
}
else
{
Log.Information("AUDIT: Authentication SUCCESS for user {Username} session {SessionId}",
userNameToken.UserName, session?.Id);
}
args.Identity = new RoleBasedIdentity(
new UserIdentity(userNameToken), roles);
return;
}
if (args.NewIdentity is X509IdentityToken x509Token)
{
var cert = x509Token.Certificate;
var subject = cert?.Subject ?? "Unknown";
// Extract CN from certificate subject for display
var cn = subject;
var cnStart = subject.IndexOf("CN=", StringComparison.OrdinalIgnoreCase);
if (cnStart >= 0)
{
cn = subject.Substring(cnStart + 3);
var commaIdx = cn.IndexOf(',');
if (commaIdx >= 0)
cn = cn.Substring(0, commaIdx);
}
var roles = new List<Role> { Role.AuthenticatedUser };
// X.509 authenticated users get ReadOnly role by default
if (_readOnlyRoleId != null)
roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
args.Identity = new RoleBasedIdentity(
new UserIdentity(x509Token), roles);
Log.Information("X509 certificate authenticated: CN={CN}, Subject={Subject}, Thumbprint={Thumbprint}",
cn, subject, cert?.Thumbprint);
return;
}
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type");
}
/// <inheritdoc />
protected override ServerProperties LoadServerProperties()
{
var properties = new ServerProperties
{
ManufacturerName = "ZB MOM",
ProductName = "LmxOpcUa Server",
ProductUri = $"urn:{_galaxyName}:LmxOpcUa",
SoftwareVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0",
BuildNumber = "1",
BuildDate = DateTime.UtcNow
};
return properties;
}
}
}

View File

@@ -1,33 +0,0 @@
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Maps domain Quality to OPC UA StatusCodes for the OPC UA server layer. (OPC-005)
/// </summary>
public static class OpcUaQualityMapper
{
/// <summary>
/// Converts bridge quality values into OPC UA status codes.
/// </summary>
/// <param name="quality">The bridge quality value.</param>
/// <returns>The OPC UA status code to publish.</returns>
public static StatusCode ToStatusCode(Quality quality)
{
return new StatusCode(QualityMapper.MapToOpcUaStatusCode(quality));
}
/// <summary>
/// Converts an OPC UA status code back into a bridge quality category.
/// </summary>
/// <param name="statusCode">The OPC UA status code to interpret.</param>
/// <returns>The bridge quality category represented by the status code.</returns>
public static Quality FromStatusCode(StatusCode statusCode)
{
if (StatusCode.IsGood(statusCode)) return Quality.Good;
if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
return Quality.Bad;
}
}
}

View File

@@ -1,325 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.Configuration;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
using ZB.MOM.WW.OtOpcUa.Host.Historian;
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Manages the OPC UA ApplicationInstance lifecycle. Programmatic config, no XML. (OPC-001, OPC-012, OPC-013)
/// </summary>
public class OpcUaServerHost : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaServerHost>();
private readonly AlarmObjectFilter? _alarmObjectFilter;
private readonly AuthenticationConfiguration _authConfig;
private readonly IUserAuthenticationProvider? _authProvider;
private readonly OpcUaConfiguration _config;
private readonly IHistorianDataSource? _historianDataSource;
private readonly PerformanceMetrics _metrics;
private readonly IMxAccessClient _mxAccessClient;
private readonly RedundancyConfiguration _redundancyConfig;
private readonly SecurityProfileConfiguration _securityConfig;
private ApplicationInstance? _application;
private LmxOpcUaServer? _server;
/// <summary>
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
/// </summary>
/// <param name="config">The endpoint and session settings for the OPC UA host.</param>
/// <param name="mxAccessClient">The runtime client used by the node manager for live reads, writes, and subscriptions.</param>
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
/// <param name="historianDataSource">The optional historian adapter that enables OPC UA history read support.</param>
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
IHistorianDataSource? historianDataSource = null,
AuthenticationConfiguration? authConfig = null,
IUserAuthenticationProvider? authProvider = null,
SecurityProfileConfiguration? securityConfig = null,
RedundancyConfiguration? redundancyConfig = null,
AlarmObjectFilter? alarmObjectFilter = null,
MxAccessConfiguration? mxAccessConfig = null,
HistorianConfiguration? historianConfig = null)
{
_config = config;
_mxAccessClient = mxAccessClient;
_metrics = metrics;
_historianDataSource = historianDataSource;
_authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider;
_securityConfig = securityConfig ?? new SecurityProfileConfiguration();
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
_alarmObjectFilter = alarmObjectFilter;
_mxAccessConfig = mxAccessConfig ?? new MxAccessConfiguration();
_historianConfig = historianConfig ?? new HistorianConfiguration();
}
private readonly MxAccessConfiguration _mxAccessConfig;
private readonly HistorianConfiguration _historianConfig;
/// <summary>
/// Gets the active node manager that holds the published Galaxy namespace.
/// </summary>
public LmxNodeManager? NodeManager => _server?.NodeManager;
/// <summary>
/// Gets the number of currently connected OPC UA client sessions.
/// </summary>
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
/// <summary>
/// Gets a value indicating whether the OPC UA server has been started and not yet stopped.
/// </summary>
public bool IsRunning => _server != null;
/// <summary>
/// Gets the list of opc.tcp base addresses the server is currently listening on.
/// Returns an empty list when the server has not started.
/// </summary>
public IReadOnlyList<string> BaseAddresses
{
get
{
var addrs = _application?.ApplicationConfiguration?.ServerConfiguration?.BaseAddresses;
return addrs != null ? addrs.ToList() : Array.Empty<string>();
}
}
/// <summary>
/// Gets the list of active security policies advertised to clients (SecurityMode + PolicyUri).
/// Returns an empty list when the server has not started.
/// </summary>
public IReadOnlyList<ServerSecurityPolicy> SecurityPolicies
{
get
{
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.SecurityPolicies;
return policies != null ? policies.ToList() : Array.Empty<ServerSecurityPolicy>();
}
}
/// <summary>
/// Gets the list of user token policy names advertised to clients (Anonymous, UserName, Certificate).
/// Returns an empty list when the server has not started.
/// </summary>
public IReadOnlyList<string> UserTokenPolicies
{
get
{
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.UserTokenPolicies;
return policies != null ? policies.Select(p => p.TokenType.ToString()).ToList() : Array.Empty<string>();
}
}
/// <summary>
/// Stops the host and releases server resources.
/// </summary>
public void Dispose()
{
Stop();
}
/// <summary>
/// Updates the OPC UA ServiceLevel based on current runtime health.
/// </summary>
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
{
_server?.UpdateServiceLevel(mxAccessConnected, dbConnected);
}
/// <summary>
/// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured
/// endpoint.
/// </summary>
public async Task StartAsync()
{
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
var applicationUri = _config.ApplicationUri ?? namespaceUri;
// Resolve configured security profiles
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
foreach (var sp in securityPolicies)
Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode);
// Build PKI paths
var pkiRoot = _securityConfig.PkiRootPath ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OPC Foundation", "pki");
var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost";
var serverConfig = new ServerConfiguration
{
BaseAddresses = { $"opc.tcp://{_config.BindAddress}:{_config.Port}{_config.EndpointPath}" },
MaxSessionCount = _config.MaxSessions,
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
MinSessionTimeout = 10000,
UserTokenPolicies = BuildUserTokenPolicies()
};
foreach (var policy in securityPolicies)
serverConfig.SecurityPolicies.Add(policy);
var secConfig = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "own"),
SubjectName = certSubject
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "issuer")
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "trusted")
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "rejected")
},
AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates,
RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates,
MinimumCertificateKeySize = (ushort)_securityConfig.MinimumCertificateKeySize
};
var appConfig = new ApplicationConfiguration
{
ApplicationName = _config.ServerName,
ApplicationUri = applicationUri,
ApplicationType = ApplicationType.Server,
ProductUri = namespaceUri,
ServerConfiguration = serverConfig,
SecurityConfiguration = secConfig,
TransportQuotas = new TransportQuotas
{
OperationTimeout = 120000,
MaxStringLength = 4 * 1024 * 1024,
MaxByteStringLength = 4 * 1024 * 1024,
MaxArrayLength = 65535,
MaxMessageSize = 4 * 1024 * 1024,
MaxBufferSize = 65535,
ChannelLifetime = 600000,
SecurityTokenLifetime = 3600000
},
TraceConfiguration = new TraceConfiguration
{
OutputFilePath = null,
TraceMasks = 0
}
};
await appConfig.Validate(ApplicationType.Server);
// Hook certificate validation logging
appConfig.CertificateValidator.CertificateValidation += OnCertificateValidation;
_application = new ApplicationInstance
{
ApplicationName = _config.ServerName,
ApplicationType = ApplicationType.Server,
ApplicationConfiguration = appConfig
};
// Check/create application certificate
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
var certLifetimeMonths = (ushort)_securityConfig.CertificateLifetimeMonths;
var certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
if (!certOk)
{
Log.Warning("Application certificate check failed, attempting to create...");
certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
}
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
_config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri,
_alarmObjectFilter,
_mxAccessConfig.RuntimeStatusProbesEnabled,
_mxAccessConfig.RuntimeStatusUnknownTimeoutSeconds,
_mxAccessConfig.RequestTimeoutSeconds,
_historianConfig.RequestTimeoutSeconds);
await _application.Start(_server);
Log.Information(
"OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})",
_config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri);
}
private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e)
{
var cert = e.Certificate;
var subject = cert?.Subject ?? "Unknown";
var thumbprint = cert?.Thumbprint ?? "N/A";
if (_securityConfig.AutoAcceptClientCertificates)
{
e.Accept = true;
Log.Warning(
"Client certificate auto-accepted: Subject={Subject}, Thumbprint={Thumbprint}, ValidTo={ValidTo}",
subject, thumbprint, cert?.NotAfter.ToString("yyyy-MM-dd"));
}
else
{
Log.Warning(
"Client certificate validation: Error={Error}, Subject={Subject}, Thumbprint={Thumbprint}, Accepted={Accepted}",
e.Error?.StatusCode, subject, thumbprint, e.Accept);
}
}
/// <summary>
/// Stops the OPC UA application instance and releases its in-memory server objects.
/// </summary>
public void Stop()
{
try
{
_server?.Stop();
Log.Information("OPC UA server stopped");
}
catch (Exception ex)
{
Log.Warning(ex, "Error stopping OPC UA server");
}
finally
{
_server = null;
_application = null;
}
}
private UserTokenPolicyCollection BuildUserTokenPolicies()
{
var policies = new UserTokenPolicyCollection();
if (_authConfig.AllowAnonymous)
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
if (_authConfig.Ldap.Enabled || _authProvider != null)
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
// X.509 certificate authentication is always available when security is configured
if (_securityConfig.Profiles.Any(p =>
!p.Equals("None", StringComparison.OrdinalIgnoreCase)))
policies.Add(new UserTokenPolicy(UserTokenType.Certificate));
if (policies.Count == 0)
{
Log.Warning("No authentication methods configured — adding Anonymous as fallback");
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
}
return policies;
}
}
}

View File

@@ -1,39 +0,0 @@
using Opc.Ua;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Maps a configured redundancy mode string to the OPC UA <see cref="RedundancySupport" /> enum.
/// </summary>
public static class RedundancyModeResolver
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(RedundancyModeResolver));
/// <summary>
/// Resolves the configured mode string to a <see cref="RedundancySupport" /> value.
/// Returns <see cref="RedundancySupport.None" /> when redundancy is disabled or the mode is unrecognized.
/// </summary>
/// <param name="mode">The mode string from configuration (e.g., "Warm", "Hot").</param>
/// <param name="enabled">Whether redundancy is enabled.</param>
/// <returns>The resolved redundancy support mode.</returns>
public static RedundancySupport Resolve(string mode, bool enabled)
{
if (!enabled)
return RedundancySupport.None;
var resolved = (mode ?? "").Trim().ToLowerInvariant() switch
{
"warm" => RedundancySupport.Warm,
"hot" => RedundancySupport.Hot,
_ => RedundancySupport.None
};
if (resolved == RedundancySupport.None)
Log.Warning("Unknown redundancy mode '{Mode}' — falling back to None. Supported modes: Warm, Hot",
mode);
return resolved;
}
}
}

View File

@@ -1,101 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Opc.Ua;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Maps configured security profile names to OPC UA <see cref="ServerSecurityPolicy" /> instances.
/// </summary>
public static class SecurityProfileResolver
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver));
private static readonly Dictionary<string, ServerSecurityPolicy> KnownProfiles =
new(StringComparer.OrdinalIgnoreCase)
{
["None"] = new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None
},
["Basic256Sha256-Sign"] = new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.Sign,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
},
["Basic256Sha256-SignAndEncrypt"] = new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
},
["Aes128_Sha256_RsaOaep-Sign"] = new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.Sign,
SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep
},
["Aes128_Sha256_RsaOaep-SignAndEncrypt"] = new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep
},
["Aes256_Sha256_RsaPss-Sign"] = new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.Sign,
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss
},
["Aes256_Sha256_RsaPss-SignAndEncrypt"] = new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss
}
};
/// <summary>
/// Gets the list of valid profile names for validation and documentation.
/// </summary>
public static IReadOnlyCollection<string> ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly();
/// <summary>
/// Resolves the configured profile names to <see cref="ServerSecurityPolicy" /> entries.
/// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to <c>None</c>.
/// </summary>
/// <param name="profileNames">The profile names from configuration.</param>
/// <returns>A deduplicated list of server security policies.</returns>
public static List<ServerSecurityPolicy> Resolve(IReadOnlyCollection<string> profileNames)
{
var resolved = new List<ServerSecurityPolicy>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in profileNames ?? Array.Empty<string>())
{
if (string.IsNullOrWhiteSpace(name))
continue;
var trimmed = name.Trim();
if (!seen.Add(trimmed))
{
Log.Debug("Skipping duplicate security profile: {Profile}", trimmed);
continue;
}
if (KnownProfiles.TryGetValue(trimmed, out var policy))
resolved.Add(policy);
else
Log.Warning("Unknown security profile '{Profile}' — skipping. Valid profiles: {ValidProfiles}",
trimmed, string.Join(", ", KnownProfiles.Keys));
}
if (resolved.Count == 0)
{
Log.Warning("No valid security profiles configured — falling back to None");
resolved.Add(KnownProfiles["None"]);
}
return resolved;
}
}
}

View File

@@ -1,33 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Computes the OPC UA ServiceLevel byte from a baseline and runtime health inputs.
/// </summary>
public sealed class ServiceLevelCalculator
{
/// <summary>
/// Calculates the current ServiceLevel from a role-adjusted baseline and health state.
/// </summary>
/// <param name="baseLevel">The role-adjusted baseline (e.g., 200 for primary, 150 for secondary).</param>
/// <param name="mxAccessConnected">Whether the MXAccess runtime connection is healthy.</param>
/// <param name="dbConnected">Whether the Galaxy repository database is reachable.</param>
/// <returns>A ServiceLevel byte between 0 and 255.</returns>
public byte Calculate(int baseLevel, bool mxAccessConnected, bool dbConnected)
{
if (!mxAccessConnected && !dbConnected)
return 0;
var level = baseLevel;
if (!mxAccessConnected)
level -= 100;
if (!dbConnected)
level -= 50;
return (byte)Math.Max(0, Math.Min(level, 255));
}
}
}

View File

@@ -1,532 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
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.OpcUa;
using ZB.MOM.WW.OtOpcUa.Host.Status;
namespace ZB.MOM.WW.OtOpcUa.Host
{
/// <summary>
/// Full service implementation wiring all components together. (SVC-004, SVC-005, SVC-006)
/// </summary>
internal sealed class OpcUaService
{
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaService>();
private readonly IUserAuthenticationProvider? _authProviderOverride;
private readonly AppConfiguration _config;
private readonly IGalaxyRepository? _galaxyRepository;
private readonly bool _hasAuthProviderOverride;
private readonly bool _hasMxAccessClientOverride;
private readonly IMxAccessClient? _mxAccessClientOverride;
private readonly IMxProxy? _mxProxy;
private CancellationTokenSource? _cts;
private HealthCheckService? _healthCheck;
private IHistorianDataSource? _historianDataSource;
private MxAccessClient? _mxAccessClient;
private IMxAccessClient? _mxAccessClientForWiring;
private StaComThread? _staThread;
/// <summary>
/// Production constructor. Loads configuration from appsettings.json.
/// </summary>
public OpcUaService()
{
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", false)
.AddJsonFile(
$"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json",
true)
.AddEnvironmentVariables()
.Build();
_config = new AppConfiguration();
configuration.GetSection("OpcUa").Bind(_config.OpcUa);
configuration.GetSection("MxAccess").Bind(_config.MxAccess);
configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository);
configuration.GetSection("Dashboard").Bind(_config.Dashboard);
configuration.GetSection("Historian").Bind(_config.Historian);
configuration.GetSection("Authentication").Bind(_config.Authentication);
// Clear the default Profiles list before binding so JSON values replace rather than append
_config.Security.Profiles.Clear();
configuration.GetSection("Security").Bind(_config.Security);
configuration.GetSection("Redundancy").Bind(_config.Redundancy);
_mxProxy = new MxProxyAdapter();
_galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository);
}
/// <summary>
/// Test constructor. Accepts injected dependencies.
/// </summary>
/// <param name="config">
/// The service configuration used to shape OPC UA hosting, MXAccess connectivity, and dashboard
/// behavior during the test run.
/// </param>
/// <param name="mxProxy">The MXAccess proxy substitute used when a test wants to exercise COM-style wiring.</param>
/// <param name="galaxyRepository">
/// The repository substitute that supplies Galaxy hierarchy and deploy metadata for
/// address-space builds.
/// </param>
/// <param name="mxAccessClientOverride">
/// An optional direct MXAccess client substitute that bypasses STA thread setup and
/// COM interop.
/// </param>
/// <param name="hasMxAccessClientOverride">
/// A value indicating whether the override client should be used instead of
/// creating a client from <paramref name="mxProxy" />.
/// </param>
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository,
IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false,
IUserAuthenticationProvider? authProviderOverride = null, bool hasAuthProviderOverride = false)
{
_config = config;
_mxProxy = mxProxy;
_galaxyRepository = galaxyRepository;
_mxAccessClientOverride = mxAccessClientOverride;
_hasMxAccessClientOverride = hasMxAccessClientOverride;
_authProviderOverride = authProviderOverride;
_hasAuthProviderOverride = hasAuthProviderOverride;
}
// Accessors for testing
/// <summary>
/// Gets the MXAccess client instance currently wired into the service for test inspection.
/// </summary>
internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring;
/// <summary>
/// Gets the metrics collector that tracks bridge operation timings during the service lifetime.
/// </summary>
internal PerformanceMetrics? Metrics { get; private set; }
/// <summary>
/// Gets the OPC UA server host that owns the runtime endpoint.
/// </summary>
internal OpcUaServerHost? ServerHost { get; private set; }
/// <summary>
/// Gets the node manager instance that holds the current Galaxy-derived address space.
/// </summary>
internal LmxNodeManager? NodeManagerInstance { get; private set; }
/// <summary>
/// Gets the change-detection service that watches for Galaxy deploys requiring a rebuild.
/// </summary>
internal ChangeDetectionService? ChangeDetectionInstance { get; private set; }
/// <summary>
/// Gets the hosted status web server when the dashboard is enabled and successfully bound.
/// Null when <c>Dashboard.Enabled</c> is false or when <see cref="DashboardStartFailed"/> is true.
/// </summary>
internal StatusWebServer? StatusWeb { get; private set; }
/// <summary>
/// Gets a flag indicating that the dashboard was enabled in configuration but failed to bind
/// its HTTP port at startup. The service continues in degraded mode (matching the pattern
/// for other optional subsystems: MxAccess connect, Galaxy DB connect, initial address space
/// build). Surfaced for tests and any external health probe that needs to distinguish
/// "dashboard disabled by config" from "dashboard failed to start".
/// </summary>
internal bool DashboardStartFailed { get; private set; }
/// <summary>
/// Gets the dashboard report generator used to assemble operator-facing status snapshots.
/// </summary>
internal StatusReportService? StatusReportInstance { get; private set; }
/// <summary>
/// Gets the Galaxy statistics snapshot populated during repository reads and rebuilds.
/// </summary>
internal GalaxyRepositoryStats? GalaxyStatsInstance { get; private set; }
/// <summary>
/// Starts the bridge by validating configuration, connecting runtime dependencies, building the Galaxy-backed OPC UA
/// address space, and optionally hosting the status dashboard.
/// </summary>
public void Start()
{
Log.Information("LmxOpcUa service starting");
try
{
// Step 2: Validate config
if (!ConfigurationValidator.ValidateAndLog(_config))
{
Log.Error("Configuration validation failed");
throw new InvalidOperationException("Configuration validation failed");
}
// Step 3: Register exception handler (SVC-006)
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
// Step 4: Create PerformanceMetrics
_cts = new CancellationTokenSource();
Metrics = new PerformanceMetrics();
// Step 5: Create MxAccessClient → Connect
if (_hasMxAccessClientOverride)
{
// Test path: use injected IMxAccessClient directly (skips STA thread + COM)
_mxAccessClientForWiring = _mxAccessClientOverride;
if (_mxAccessClientForWiring != null && _mxAccessClientForWiring.State != ConnectionState.Connected)
_mxAccessClientForWiring.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
}
else if (_mxProxy != null)
{
try
{
_staThread = new StaComThread();
_staThread.Start();
_mxAccessClient = new MxAccessClient(_staThread, _mxProxy, _config.MxAccess, Metrics);
try
{
_mxAccessClient.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Log.Warning(ex,
"MxAccess connection failed at startup - monitor will continue retrying in the background");
}
// Step 6: Start monitor loop even if initial connect failed
_mxAccessClient.StartMonitor();
}
catch (Exception ex)
{
Log.Warning(ex, "MxAccess initialization failed - continuing without runtime data access");
_mxAccessClient?.Dispose();
_mxAccessClient = null;
_staThread?.Dispose();
_staThread = null;
}
}
// Step 7: Create GalaxyRepositoryService → TestConnection
GalaxyStatsInstance = new GalaxyRepositoryStats { GalaxyName = _config.OpcUa.GalaxyName };
if (_galaxyRepository != null)
{
var dbOk = _galaxyRepository.TestConnectionAsync(_cts.Token).GetAwaiter().GetResult();
GalaxyStatsInstance.DbConnected = dbOk;
if (!dbOk)
Log.Warning("Galaxy repository database connection failed — continuing without initial data");
}
// Step 8: Create OPC UA server host + node manager
var effectiveMxClient = (IMxAccessClient?)_mxAccessClient ??
_mxAccessClientForWiring ?? new NullMxAccessClient();
if (_config.Historian.Enabled)
{
_historianDataSource = HistorianPluginLoader.TryLoad(_config.Historian);
}
else
{
HistorianPluginLoader.MarkDisabled();
_historianDataSource = null;
}
IUserAuthenticationProvider? authProvider = null;
if (_hasAuthProviderOverride)
{
authProvider = _authProviderOverride;
}
else if (_config.Authentication.Ldap.Enabled)
{
authProvider = new LdapAuthenticationProvider(_config.Authentication.Ldap);
Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})",
_config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port,
_config.Authentication.Ldap.BaseDN);
}
var alarmObjectFilter = new AlarmObjectFilter(_config.OpcUa.AlarmFilter);
if (alarmObjectFilter.Enabled)
Log.Information(
"Alarm object filter compiled with {PatternCount} pattern(s): [{Patterns}]",
alarmObjectFilter.PatternCount,
string.Join(", ", _config.OpcUa.AlarmFilter.ObjectFilters));
ServerHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, Metrics, _historianDataSource,
_config.Authentication, authProvider, _config.Security, _config.Redundancy, alarmObjectFilter,
_config.MxAccess, _config.Historian);
// Step 9-10: Query hierarchy, start server, build address space
DateTime? initialDeployTime = null;
if (_galaxyRepository != null && GalaxyStatsInstance.DbConnected)
{
try
{
initialDeployTime = _galaxyRepository.GetLastDeployTimeAsync(_cts.Token).GetAwaiter()
.GetResult();
var hierarchy = _galaxyRepository.GetHierarchyAsync(_cts.Token).GetAwaiter().GetResult();
var attributes = _galaxyRepository.GetAttributesAsync(_cts.Token).GetAwaiter().GetResult();
GalaxyStatsInstance.ObjectCount = hierarchy.Count;
GalaxyStatsInstance.AttributeCount = attributes.Count;
ServerHost.StartAsync().GetAwaiter().GetResult();
NodeManagerInstance = ServerHost.NodeManager;
if (NodeManagerInstance != null)
{
NodeManagerInstance.BuildAddressSpace(hierarchy, attributes);
GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow;
}
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to build initial address space");
if (!ServerHost.IsRunning)
{
ServerHost.StartAsync().GetAwaiter().GetResult();
NodeManagerInstance = ServerHost.NodeManager;
}
}
}
else
{
ServerHost.StartAsync().GetAwaiter().GetResult();
NodeManagerInstance = ServerHost.NodeManager;
}
// Step 11-12: Change detection wired to rebuild
if (_galaxyRepository != null)
{
ChangeDetectionInstance = new ChangeDetectionService(_galaxyRepository,
_config.GalaxyRepository.ChangeDetectionIntervalSeconds, initialDeployTime);
ChangeDetectionInstance.OnGalaxyChanged += OnGalaxyChanged;
ChangeDetectionInstance.Start();
}
// Step 13: Dashboard
_healthCheck = new HealthCheckService();
StatusReportInstance = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds);
StatusReportInstance.SetComponents(effectiveMxClient, Metrics, GalaxyStatsInstance, ServerHost,
NodeManagerInstance,
_config.Redundancy, _config.OpcUa.ApplicationUri, _config.Historian);
if (_config.Dashboard.Enabled)
{
var dashboardServer = new StatusWebServer(StatusReportInstance, _config.Dashboard.Port);
if (dashboardServer.Start())
{
StatusWeb = dashboardServer;
}
else
{
// Degraded mode: StatusWebServer.Start() already logged the underlying exception.
// Dispose the unstarted instance, null out the reference, and flag the failure so
// tests and health probes can observe it. Service startup continues.
Log.Warning("Status dashboard failed to bind on port {Port}; service continues without dashboard",
_config.Dashboard.Port);
dashboardServer.Dispose();
DashboardStartFailed = true;
}
}
// Wire ServiceLevel updates from MXAccess health changes
if (_config.Redundancy.Enabled)
effectiveMxClient.ConnectionStateChanged += OnMxAccessStateChangedForServiceLevel;
// Step 14
Log.Information("LmxOpcUa service started successfully");
}
catch (Exception ex)
{
Log.Fatal(ex, "LmxOpcUa service failed to start");
throw;
}
}
/// <summary>
/// Stops the bridge, cancels monitoring loops, disconnects runtime integrations, and releases hosted resources in
/// shutdown order.
/// </summary>
public void Stop()
{
Log.Information("LmxOpcUa service stopping");
try
{
_cts?.Cancel();
ChangeDetectionInstance?.Stop();
ServerHost?.Stop();
if (_mxAccessClient != null)
{
_mxAccessClient.StopMonitor();
_mxAccessClient.DisconnectAsync().GetAwaiter().GetResult();
_mxAccessClient.Dispose();
}
_staThread?.Dispose();
_historianDataSource?.Dispose();
StatusWeb?.Dispose();
Metrics?.Dispose();
ChangeDetectionInstance?.Dispose();
_cts?.Dispose();
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
}
catch (Exception ex)
{
Log.Warning(ex, "Error during service shutdown");
}
Log.Information("Service shutdown complete");
}
private void OnGalaxyChanged()
{
Log.Information("Galaxy change detected — rebuilding address space");
try
{
if (_galaxyRepository == null || NodeManagerInstance == null) return;
var hierarchy = _galaxyRepository.GetHierarchyAsync().GetAwaiter().GetResult();
var attributes = _galaxyRepository.GetAttributesAsync().GetAwaiter().GetResult();
NodeManagerInstance.RebuildAddressSpace(hierarchy, attributes);
if (GalaxyStatsInstance != null)
{
GalaxyStatsInstance.ObjectCount = hierarchy.Count;
GalaxyStatsInstance.AttributeCount = attributes.Count;
GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow;
GalaxyStatsInstance.LastDeployTime = ChangeDetectionInstance?.LastKnownDeployTime;
}
}
catch (Exception ex)
{
Log.Error(ex, "Failed to rebuild address space");
}
}
private void OnMxAccessStateChangedForServiceLevel(object? sender, ConnectionStateChangedEventArgs e)
{
var mxConnected = e.CurrentState == ConnectionState.Connected;
var dbConnected = GalaxyStatsInstance?.DbConnected ?? false;
ServerHost?.UpdateServiceLevel(mxConnected, dbConnected);
Log.Debug("ServiceLevel updated: MxAccess={MxState}, DB={DbState}", e.CurrentState, dbConnected);
}
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})",
e.IsTerminating);
}
/// <summary>
/// Triggers an address space rebuild from the current Galaxy repository data. For testing.
/// </summary>
internal void TriggerRebuild()
{
OnGalaxyChanged();
}
}
/// <summary>
/// Null implementation of IMxAccessClient for when MXAccess is not available.
/// </summary>
internal sealed class NullMxAccessClient : IMxAccessClient
{
/// <summary>
/// Gets the disconnected state reported when the bridge is running without live MXAccess connectivity.
/// </summary>
public ConnectionState State => ConnectionState.Disconnected;
/// <summary>
/// Gets the active subscription count, which is always zero for the null runtime client.
/// </summary>
public int ActiveSubscriptionCount => 0;
/// <summary>
/// Gets the reconnect count, which is always zero because the null client never establishes a session.
/// </summary>
public int ReconnectCount => 0;
/// <summary>
/// Occurs when the runtime connection state changes. The null client never raises this event.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed tag value changes. The null client never raises this event.
/// </summary>
public event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Completes immediately because no live runtime connection is available or required.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
public Task ConnectAsync(CancellationToken ct = default)
{
return Task.CompletedTask;
}
/// <summary>
/// Completes immediately because there is no live runtime session to close.
/// </summary>
public Task DisconnectAsync()
{
return Task.CompletedTask;
}
/// <summary>
/// Completes immediately because the null client does not subscribe to live Galaxy attributes.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been subscribed.</param>
/// <param name="callback">The callback that would have received runtime value changes.</param>
public Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
{
return Task.CompletedTask;
}
/// <summary>
/// Completes immediately because the null client does not maintain runtime subscriptions.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been unsubscribed.</param>
public Task UnsubscribeAsync(string fullTagReference)
{
return Task.CompletedTask;
}
/// <summary>
/// Returns a bad-quality value because no live runtime source exists.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been read from the runtime.</param>
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
/// <returns>A bad-quality VTQ indicating that runtime data is unavailable.</returns>
public Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
{
return Task.FromResult(Vtq.Bad());
}
/// <summary>
/// Rejects writes because there is no live runtime endpoint behind the null client.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been written.</param>
/// <param name="value">The value that would have been sent to the runtime.</param>
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
/// <returns>A completed task returning <see langword="false" />.</returns>
public Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
{
return Task.FromResult(false);
}
/// <summary>
/// Releases the null client. No unmanaged runtime resources exist.
/// </summary>
public void Dispose()
{
}
}
}

View File

@@ -1,295 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host
{
/// <summary>
/// Fluent builder for constructing OpcUaService with dependency overrides.
/// Used by integration tests to substitute fakes for COM/DB components.
/// </summary>
internal class OpcUaServiceBuilder
{
private IUserAuthenticationProvider? _authProvider;
private bool _authProviderSet;
private AppConfiguration _config = new();
private IGalaxyRepository? _galaxyRepository;
private bool _galaxyRepositorySet;
private IMxAccessClient? _mxAccessClient;
private bool _mxAccessClientSet;
private IMxProxy? _mxProxy;
private bool _mxProxySet;
/// <summary>
/// Replaces the default service configuration used by the test host.
/// </summary>
/// <param name="config">The full configuration snapshot to inject into the service under test.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithConfig(AppConfiguration config)
{
_config = config;
return this;
}
/// <summary>
/// Sets the OPC UA port used by the test host so multiple integration runs can coexist.
/// </summary>
/// <param name="port">The TCP port to expose for the test server.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithOpcUaPort(int port)
{
_config.OpcUa.Port = port;
return this;
}
/// <summary>
/// Sets the Galaxy name represented by the test address space.
/// </summary>
/// <param name="name">The Galaxy name to expose through OPC UA and diagnostics.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithGalaxyName(string name)
{
_config.OpcUa.GalaxyName = name;
return this;
}
/// <summary>
/// Injects an MXAccess proxy substitute for tests that exercise the proxy-driven runtime path.
/// </summary>
/// <param name="proxy">The proxy fake or stub to supply to the service.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithMxProxy(IMxProxy? proxy)
{
_mxProxy = proxy;
_mxProxySet = true;
return this;
}
/// <summary>
/// Injects a repository substitute for tests that control Galaxy hierarchy and deploy metadata.
/// </summary>
/// <param name="repository">The repository fake or stub to supply to the service.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithGalaxyRepository(IGalaxyRepository? repository)
{
_galaxyRepository = repository;
_galaxyRepositorySet = true;
return this;
}
/// <summary>
/// Override the MxAccessClient directly, skipping STA thread and COM interop entirely.
/// When set, the service will use this client instead of creating one from IMxProxy.
/// </summary>
/// <param name="client">The direct MXAccess client substitute to inject into the service.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithMxAccessClient(IMxAccessClient? client)
{
_mxAccessClient = client;
_mxAccessClientSet = true;
return this;
}
/// <summary>
/// Seeds a convenience fake repository with Galaxy hierarchy and attribute rows for address-space tests.
/// </summary>
/// <param name="hierarchy">The object hierarchy to expose through the test OPC UA namespace.</param>
/// <param name="attributes">The attribute rows to attach to the hierarchy.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithHierarchy(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
if (!_galaxyRepositorySet)
{
var fake = new FakeBuilderGalaxyRepository();
_galaxyRepository = fake;
_galaxyRepositorySet = true;
}
if (_galaxyRepository is FakeBuilderGalaxyRepository fakeRepo)
{
fakeRepo.Hierarchy = hierarchy;
fakeRepo.Attributes = attributes;
}
return this;
}
/// <summary>
/// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener.
/// </summary>
/// <returns>The current builder so additional overrides can be chained.</returns>
/// <summary>
/// Injects a custom authentication provider for tests that need deterministic role resolution.
/// </summary>
public OpcUaServiceBuilder WithAuthProvider(IUserAuthenticationProvider? provider)
{
_authProvider = provider;
_authProviderSet = true;
return this;
}
/// <summary>
/// Sets the authentication configuration for the test host.
/// </summary>
public OpcUaServiceBuilder WithAuthentication(AuthenticationConfiguration authConfig)
{
_config.Authentication = authConfig;
return this;
}
public OpcUaServiceBuilder DisableDashboard()
{
_config.Dashboard.Enabled = false;
return this;
}
/// <summary>
/// Sets the redundancy configuration for the test host.
/// </summary>
/// <param name="redundancy">The redundancy configuration to inject.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithRedundancy(RedundancyConfiguration redundancy)
{
_config.Redundancy = redundancy;
return this;
}
/// <summary>
/// Sets the application URI for the test host, distinct from the namespace URI.
/// </summary>
/// <param name="applicationUri">The unique application URI for this server instance.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithApplicationUri(string applicationUri)
{
_config.OpcUa.ApplicationUri = applicationUri;
return this;
}
/// <summary>
/// Sets the security profile configuration for the test host.
/// </summary>
/// <param name="security">The security profile configuration to inject.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
/// <summary>
/// Enables alarm condition tracking on the test host so integration tests can exercise the alarm-creation path.
/// </summary>
/// <param name="enabled">Whether alarm tracking should be enabled.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithAlarmTracking(bool enabled)
{
_config.OpcUa.AlarmTrackingEnabled = enabled;
return this;
}
/// <summary>
/// Configures the template-based alarm object filter for integration tests.
/// </summary>
/// <param name="filters">Zero or more wildcard patterns. Empty → filter disabled.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithAlarmFilter(params string[] filters)
{
_config.OpcUa.AlarmFilter = new AlarmFilterConfiguration
{
ObjectFilters = filters.ToList()
};
return this;
}
public OpcUaServiceBuilder WithSecurity(SecurityProfileConfiguration security)
{
_config.Security = security;
return this;
}
/// <summary>
/// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations.
/// </summary>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder DisableChangeDetection()
{
_config.GalaxyRepository.ChangeDetectionIntervalSeconds = int.MaxValue;
return this;
}
/// <summary>
/// Creates an <see cref="OpcUaService" /> using the accumulated test doubles and configuration overrides.
/// </summary>
/// <returns>A service instance ready for integration-style testing.</returns>
public OpcUaService Build()
{
return new OpcUaService(
_config,
_mxProxySet ? _mxProxy : null,
_galaxyRepositorySet ? _galaxyRepository : null,
_mxAccessClientSet ? _mxAccessClient : null,
_mxAccessClientSet,
_authProviderSet ? _authProvider : null,
_authProviderSet);
}
/// <summary>
/// Internal fake repository used by WithHierarchy for convenience.
/// </summary>
private class FakeBuilderGalaxyRepository : IGalaxyRepository
{
/// <summary>
/// Gets or sets the hierarchy rows that the fake repository returns to the service.
/// </summary>
public List<GalaxyObjectInfo> Hierarchy { get; set; } = new();
/// <summary>
/// Gets or sets the attribute rows that the fake repository returns to the service.
/// </summary>
public List<GalaxyAttributeInfo> Attributes { get; set; } = new();
/// <summary>
/// Occurs when the fake repository wants to simulate a Galaxy deploy change.
/// </summary>
public event Action? OnGalaxyChanged;
/// <summary>
/// Returns the seeded hierarchy rows for address-space construction.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>The configured hierarchy rows.</returns>
public Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
{
return Task.FromResult(Hierarchy);
}
/// <summary>
/// Returns the seeded attribute rows for address-space construction.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>The configured attribute rows.</returns>
public Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
{
return Task.FromResult(Attributes);
}
/// <summary>
/// Returns the current UTC time so change-detection tests have a deploy timestamp to compare against.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>The current UTC time.</returns>
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
return Task.FromResult<DateTime?>(DateTime.UtcNow);
}
/// <summary>
/// Reports a healthy repository connection for builder-based test setups.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>A completed task returning <see langword="true" />.</returns>
public Task<bool> TestConnectionAsync(CancellationToken ct = default)
{
return Task.FromResult(true);
}
}
}
}

View File

@@ -1,57 +0,0 @@
using System;
using Serilog;
using Topshelf;
namespace ZB.MOM.WW.OtOpcUa.Host
{
internal static class Program
{
private static int Main(string[] args)
{
// Set working directory to exe location so relative log paths resolve correctly
// (Windows services default to System32)
Environment.CurrentDirectory = AppDomain.CurrentDomain.BaseDirectory;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File(
"logs/lmxopcua-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 31)
.CreateLogger();
try
{
var exitCode = HostFactory.Run(host =>
{
host.UseSerilog();
host.Service<OpcUaService>(svc =>
{
svc.ConstructUsing(() => new OpcUaService());
svc.WhenStarted(s => s.Start());
svc.WhenStopped(s => s.Stop());
});
host.SetServiceName("OtOpcUa");
host.SetDisplayName("LMX OPC UA Server");
host.SetDescription("OPC UA server exposing System Platform Galaxy tags via MXAccess.");
host.RunAsLocalSystem();
host.StartAutomatically();
});
return (int)exitCode;
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly");
return 1;
}
finally
{
Log.CloseAndFlush();
}
}
}
}

View File

@@ -1,141 +0,0 @@
using System.Linq;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
namespace ZB.MOM.WW.OtOpcUa.Host.Status
{
/// <summary>
/// Determines health status based on connection state and operation success rates. (DASH-003)
/// </summary>
public class HealthCheckService
{
/// <summary>
/// Evaluates bridge health from runtime connectivity, recorded performance metrics, and optional
/// historian/alarm integration state.
/// </summary>
/// <param name="connectionState">The current MXAccess connection state.</param>
/// <param name="metrics">The recorded performance metrics, if available.</param>
/// <param name="historian">Optional historian integration snapshot; pass <c>null</c> to skip historian health rules.</param>
/// <param name="alarms">Optional alarm integration snapshot; pass <c>null</c> to skip alarm health rules.</param>
/// <returns>A dashboard health snapshot describing the current service condition.</returns>
public HealthInfo CheckHealth(
ConnectionState connectionState,
PerformanceMetrics? metrics,
HistorianStatusInfo? historian = null,
AlarmStatusInfo? alarms = null,
RuntimeStatusInfo? runtime = null)
{
// Rule 1: Not connected → Unhealthy
if (connectionState != ConnectionState.Connected)
return new HealthInfo
{
Status = "Unhealthy",
Message = $"MXAccess not connected (state: {connectionState})",
Color = "red"
};
// Rule 2b: Historian enabled but plugin did not load → Degraded
if (historian != null && historian.Enabled && historian.PluginStatus != "Loaded")
return new HealthInfo
{
Status = "Degraded",
Message =
$"Historian enabled but plugin status is {historian.PluginStatus}: {historian.PluginError ?? "(no error)"}",
Color = "yellow"
};
// Rule 2b2: Historian plugin loaded but queries are failing consecutively → Degraded.
// Threshold of 3 avoids flagging a single transient blip; anything beyond that means
// the SDK is in a broken state that the reconnect loop isn't recovering from.
if (historian != null && historian.Enabled && historian.PluginStatus == "Loaded"
&& historian.ConsecutiveFailures >= 3)
return new HealthInfo
{
Status = "Degraded",
Message =
$"Historian plugin has {historian.ConsecutiveFailures} consecutive query failures: " +
$"{historian.LastQueryError ?? "(no error)"}",
Color = "yellow"
};
// Rule 2b3: Historian cluster has nodes in cooldown → Degraded (partial cluster).
// Only surfaces when the operator actually configured a multi-node cluster.
if (historian != null && historian.Enabled && historian.PluginStatus == "Loaded"
&& historian.NodeCount > 1 && historian.HealthyNodeCount < historian.NodeCount)
return new HealthInfo
{
Status = "Degraded",
Message =
$"Historian cluster has {historian.HealthyNodeCount} of {historian.NodeCount} " +
"nodes healthy — one or more nodes are in failure cooldown",
Color = "yellow"
};
// Rule 2 / 2c: Success rate too low for any recorded operation
if (metrics != null)
{
var stats = metrics.GetStatistics();
foreach (var kvp in stats)
{
var isHistoryOp = kvp.Key.StartsWith("HistoryRead", System.StringComparison.OrdinalIgnoreCase);
// History reads are rare; drop the sample threshold so a stuck historian surfaces quickly.
var sampleThreshold = isHistoryOp ? 10 : 100;
if (kvp.Value.TotalCount > sampleThreshold && kvp.Value.SuccessRate < 0.5)
return new HealthInfo
{
Status = "Degraded",
Message =
$"{kvp.Key} success rate is {kvp.Value.SuccessRate:P0} ({kvp.Value.TotalCount} ops)",
Color = "yellow"
};
}
}
// Rule 2d: Any alarm acknowledge write has failed since startup → Degraded (latched)
if (alarms != null && alarms.TrackingEnabled && alarms.AckWriteFailures > 0)
return new HealthInfo
{
Status = "Degraded",
Message = $"Alarm acknowledge writes have failed ({alarms.AckWriteFailures} total)",
Color = "yellow"
};
// Rule 2e: Any Galaxy runtime host (Platform/AppEngine) is Stopped → Degraded.
// Runs after the transport check so that MxAccess-disconnected remains Unhealthy via
// Rule 1 without also firing the runtime rule — avoids a double-message when the
// transport is the root cause of every host going Unknown/Stopped.
if (runtime != null && runtime.StoppedCount > 0)
{
var stoppedNames = string.Join(", ",
runtime.Hosts.Where(h => h.State == Domain.GalaxyRuntimeState.Stopped).Select(h => h.ObjectName));
return new HealthInfo
{
Status = "Degraded",
Message =
$"Galaxy runtime has {runtime.StoppedCount} of {runtime.Total} host(s) stopped: {stoppedNames}",
Color = "yellow"
};
}
// Rule 3: All good
return new HealthInfo
{
Status = "Healthy",
Message = "All systems operational",
Color = "green"
};
}
/// <summary>
/// Determines whether the bridge should currently be treated as healthy.
/// </summary>
/// <param name="connectionState">The current MXAccess connection state.</param>
/// <param name="metrics">The recorded performance metrics, if available.</param>
/// <returns><see langword="true" /> when the bridge is not unhealthy; otherwise, <see langword="false" />.</returns>
public bool IsHealthy(ConnectionState connectionState, PerformanceMetrics? metrics)
{
var health = CheckHealth(connectionState, metrics);
return health.Status != "Unhealthy";
}
}
}

View File

@@ -1,570 +0,0 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
namespace ZB.MOM.WW.OtOpcUa.Host.Status
{
/// <summary>
/// DTO containing all dashboard data. (DASH-001 through DASH-009)
/// </summary>
public class StatusData
{
/// <summary>
/// Gets or sets the current MXAccess and service connectivity summary shown on the dashboard.
/// </summary>
public ConnectionInfo Connection { get; set; } = new();
/// <summary>
/// Gets or sets the overall health state communicated to operators.
/// </summary>
public HealthInfo Health { get; set; } = new();
/// <summary>
/// Gets or sets subscription counts that show how many live tag streams the bridge is maintaining.
/// </summary>
public SubscriptionInfo Subscriptions { get; set; } = new();
/// <summary>
/// Gets or sets Galaxy-specific metadata such as deploy timing and address-space counts.
/// </summary>
public GalaxyInfo Galaxy { get; set; } = new();
/// <summary>
/// Gets or sets MXAccess data change dispatch queue metrics.
/// </summary>
public DataChangeInfo DataChange { get; set; } = new();
/// <summary>
/// Gets or sets per-operation performance statistics used to diagnose bridge throughput and latency.
/// </summary>
public Dictionary<string, MetricsStatistics> Operations { get; set; } = new();
/// <summary>
/// Gets or sets the historian integration status (plugin load outcome, server target).
/// </summary>
public HistorianStatusInfo Historian { get; set; } = new();
/// <summary>
/// Gets or sets the alarm integration status and event counters.
/// </summary>
public AlarmStatusInfo Alarms { get; set; } = new();
/// <summary>
/// Gets or sets the redundancy state when redundancy is enabled.
/// </summary>
public RedundancyInfo? Redundancy { get; set; }
/// <summary>
/// Gets or sets the listening OPC UA endpoints and active security profiles.
/// </summary>
public EndpointsInfo Endpoints { get; set; } = new();
/// <summary>
/// Gets or sets the Galaxy runtime host state (Platforms + AppEngines).
/// </summary>
public RuntimeStatusInfo RuntimeStatus { get; set; } = new();
/// <summary>
/// Gets or sets footer details such as the snapshot timestamp and service version.
/// </summary>
public FooterInfo Footer { get; set; } = new();
}
/// <summary>
/// Dashboard model summarizing per-host Galaxy runtime state.
/// </summary>
public class RuntimeStatusInfo
{
/// <summary>
/// Gets or sets the total number of tracked runtime hosts ($WinPlatform + $AppEngine).
/// </summary>
public int Total { get; set; }
/// <summary>
/// Gets or sets the count of hosts currently reported Running.
/// </summary>
public int RunningCount { get; set; }
/// <summary>
/// Gets or sets the count of hosts currently reported Stopped.
/// </summary>
public int StoppedCount { get; set; }
/// <summary>
/// Gets or sets the count of hosts whose state is still Unknown (either awaiting initial
/// probe resolution or transported-through-disconnected).
/// </summary>
public int UnknownCount { get; set; }
/// <summary>
/// Gets or sets the per-host state in stable alphabetical order.
/// </summary>
public List<GalaxyRuntimeStatus> Hosts { get; set; } = new();
}
/// <summary>
/// Dashboard model describing the OPC UA server's listening endpoints and active security profiles.
/// </summary>
public class EndpointsInfo
{
/// <summary>
/// Gets or sets the list of opc.tcp base addresses the server is listening on.
/// </summary>
public List<string> BaseAddresses { get; set; } = new();
/// <summary>
/// Gets or sets the list of configured user token policies (Anonymous, UserName, Certificate).
/// </summary>
public List<string> UserTokenPolicies { get; set; } = new();
/// <summary>
/// Gets or sets the active security profiles reported to clients.
/// </summary>
public List<SecurityProfileInfo> SecurityProfiles { get; set; } = new();
}
/// <summary>
/// Dashboard model for a single configured OPC UA server security profile.
/// </summary>
public class SecurityProfileInfo
{
/// <summary>
/// Gets or sets the OPC UA security policy URI (e.g., http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256).
/// </summary>
public string PolicyUri { get; set; } = "";
/// <summary>
/// Gets or sets the short policy name extracted from the policy URI.
/// </summary>
public string PolicyName { get; set; } = "";
/// <summary>
/// Gets or sets the message security mode (None, Sign, SignAndEncrypt).
/// </summary>
public string SecurityMode { get; set; } = "";
}
/// <summary>
/// Dashboard model for current runtime connection details.
/// </summary>
public class ConnectionInfo
{
/// <summary>
/// Gets or sets the current MXAccess connection state shown to operators.
/// </summary>
public string State { get; set; } = "Disconnected";
/// <summary>
/// Gets or sets how many reconnect attempts have occurred since the service started.
/// </summary>
public int ReconnectCount { get; set; }
/// <summary>
/// Gets or sets the number of active OPC UA sessions connected to the bridge.
/// </summary>
public int ActiveSessions { get; set; }
}
/// <summary>
/// Dashboard model for the overall health banner.
/// </summary>
public class HealthInfo
{
/// <summary>
/// Gets or sets the high-level health state, such as Healthy, Degraded, or Unhealthy.
/// </summary>
public string Status { get; set; } = "Unknown";
/// <summary>
/// Gets or sets the operator-facing explanation for the current health state.
/// </summary>
public string Message { get; set; } = "";
/// <summary>
/// Gets or sets the color token used by the dashboard UI to render the health banner.
/// </summary>
public string Color { get; set; } = "gray";
}
/// <summary>
/// Dashboard model for subscription load.
/// </summary>
public class SubscriptionInfo
{
/// <summary>
/// Gets or sets the number of active tag subscriptions mirrored from MXAccess into OPC UA.
/// This total includes bridge-owned runtime status probes; see <see cref="ProbeCount"/> for the
/// subset attributable to probes.
/// </summary>
public int ActiveCount { get; set; }
/// <summary>
/// Gets or sets the count of bridge-owned runtime status probes included in
/// <see cref="ActiveCount"/>. Surfaced on the dashboard so operators can distinguish probe
/// overhead from client-driven subscription load.
/// </summary>
public int ProbeCount { get; set; }
}
/// <summary>
/// Dashboard model for Galaxy metadata and rebuild status.
/// </summary>
public class GalaxyInfo
{
/// <summary>
/// Gets or sets the Galaxy name currently being bridged into OPC UA.
/// </summary>
public string GalaxyName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the repository database is currently reachable.
/// </summary>
public bool DbConnected { get; set; }
/// <summary>
/// Gets or sets the most recent deploy timestamp observed in the Galaxy repository.
/// </summary>
public DateTime? LastDeployTime { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy objects currently represented in the address space.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy attributes currently represented as OPC UA variables.
/// </summary>
public int AttributeCount { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the last completed address-space rebuild.
/// </summary>
public DateTime? LastRebuildTime { get; set; }
}
/// <summary>
/// Dashboard model for MXAccess data change dispatch metrics.
/// </summary>
public class DataChangeInfo
{
/// <summary>
/// Gets or sets the rate of MXAccess data change events received per second.
/// </summary>
public double EventsPerSecond { get; set; }
/// <summary>
/// Gets or sets the average number of items processed per dispatch cycle.
/// </summary>
public double AvgBatchSize { get; set; }
/// <summary>
/// Gets or sets the number of items currently waiting in the dispatch queue.
/// </summary>
public int PendingItems { get; set; }
/// <summary>
/// Gets or sets the total MXAccess data change events received since startup.
/// </summary>
public long TotalEvents { get; set; }
}
/// <summary>
/// Dashboard model for the Wonderware historian integration (runtime-loaded plugin).
/// </summary>
public class HistorianStatusInfo
{
/// <summary>
/// Gets or sets a value indicating whether historian support is enabled in configuration.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the most recent plugin load outcome as a string.
/// Values: <c>Disabled</c>, <c>NotFound</c>, <c>LoadFailed</c>, <c>Loaded</c>.
/// </summary>
public string PluginStatus { get; set; } = "Disabled";
/// <summary>
/// Gets or sets the error message from the last load attempt when <see cref="PluginStatus"/> is <c>LoadFailed</c>.
/// </summary>
public string? PluginError { get; set; }
/// <summary>
/// Gets or sets the absolute path the loader probed for the plugin assembly.
/// </summary>
public string PluginPath { get; set; } = "";
/// <summary>
/// Gets or sets the configured historian server hostname.
/// </summary>
public string ServerName { get; set; } = "";
/// <summary>
/// Gets or sets the configured historian TCP port.
/// </summary>
public int Port { get; set; }
/// <summary>
/// Gets or sets the total number of historian read queries attempted since startup.
/// </summary>
public long QueryTotal { get; set; }
/// <summary>
/// Gets or sets the number of historian queries that completed without an exception.
/// </summary>
public long QuerySuccesses { get; set; }
/// <summary>
/// Gets or sets the number of historian queries that raised an exception.
/// </summary>
public long QueryFailures { get; set; }
/// <summary>
/// Gets or sets the number of consecutive failures since the last successful query.
/// </summary>
public int ConsecutiveFailures { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the last successful query.
/// </summary>
public DateTime? LastSuccessTime { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the last query failure.
/// </summary>
public DateTime? LastFailureTime { get; set; }
/// <summary>
/// Gets or sets the exception message from the most recent failure.
/// </summary>
public string? LastQueryError { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the plugin currently holds an open process-path
/// SDK connection.
/// </summary>
public bool ProcessConnectionOpen { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the plugin currently holds an open event-path
/// SDK connection.
/// </summary>
public bool EventConnectionOpen { get; set; }
/// <summary>
/// Gets or sets the total number of configured historian cluster nodes.
/// </summary>
public int NodeCount { get; set; }
/// <summary>
/// Gets or sets the number of cluster nodes currently eligible for new connections
/// (i.e., not in failure cooldown).
/// </summary>
public int HealthyNodeCount { get; set; }
/// <summary>
/// Gets or sets the node currently serving process (historical value) queries, or null
/// when no process connection is open.
/// </summary>
public string? ActiveProcessNode { get; set; }
/// <summary>
/// Gets or sets the node currently serving event (alarm history) queries, or null when
/// no event connection is open.
/// </summary>
public string? ActiveEventNode { get; set; }
/// <summary>
/// Gets or sets the per-node cluster state in configuration order.
/// </summary>
public List<Historian.HistorianClusterNodeState> Nodes { get; set; } = new();
}
/// <summary>
/// Dashboard model for alarm integration health and event counters.
/// </summary>
public class AlarmStatusInfo
{
/// <summary>
/// Gets or sets a value indicating whether alarm condition tracking is enabled in configuration.
/// </summary>
public bool TrackingEnabled { get; set; }
/// <summary>
/// Gets or sets the number of distinct alarm conditions currently tracked.
/// </summary>
public int ConditionCount { get; set; }
/// <summary>
/// Gets or sets the number of alarms currently in the InAlarm=true state.
/// </summary>
public int ActiveAlarmCount { get; set; }
/// <summary>
/// Gets or sets the total number of InAlarm transitions observed since startup.
/// </summary>
public long TransitionCount { get; set; }
/// <summary>
/// Gets or sets the total number of alarm acknowledgement transitions observed since startup.
/// </summary>
public long AckEventCount { get; set; }
/// <summary>
/// Gets or sets the total number of alarm acknowledgement MXAccess writes that have failed since startup.
/// </summary>
public long AckWriteFailures { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the template-based alarm object filter is active.
/// </summary>
public bool FilterEnabled { get; set; }
/// <summary>
/// Gets or sets the number of compiled alarm filter patterns.
/// </summary>
public int FilterPatternCount { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy objects included by the alarm filter during the most recent build.
/// </summary>
public int FilterIncludedObjectCount { get; set; }
/// <summary>
/// Gets or sets the raw alarm filter patterns exactly as configured, for dashboard display.
/// </summary>
public List<string> FilterPatterns { get; set; } = new();
}
/// <summary>
/// Dashboard model for redundancy state. Only populated when redundancy is enabled.
/// </summary>
public class RedundancyInfo
{
/// <summary>
/// Gets or sets whether redundancy is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the redundancy mode (e.g., "Warm", "Hot").
/// </summary>
public string Mode { get; set; } = "";
/// <summary>
/// Gets or sets this instance's role ("Primary" or "Secondary").
/// </summary>
public string Role { get; set; } = "";
/// <summary>
/// Gets or sets the current ServiceLevel byte.
/// </summary>
public byte ServiceLevel { get; set; }
/// <summary>
/// Gets or sets this instance's ApplicationUri.
/// </summary>
public string ApplicationUri { get; set; } = "";
/// <summary>
/// Gets or sets the list of all server URIs in the redundant set.
/// </summary>
public List<string> ServerUris { get; set; } = new();
}
/// <summary>
/// DTO for the /api/health endpoint. Includes component-level health, ServiceLevel, and redundancy state.
/// </summary>
public class HealthEndpointData
{
/// <summary>
/// Gets or sets the overall health status: Healthy, Degraded, or Unhealthy.
/// </summary>
public string Status { get; set; } = "Unknown";
/// <summary>
/// Gets or sets the computed OPC UA ServiceLevel byte (0-255). Only meaningful when redundancy is enabled.
/// </summary>
public byte ServiceLevel { get; set; }
/// <summary>
/// Gets or sets whether redundancy is enabled.
/// </summary>
public bool RedundancyEnabled { get; set; }
/// <summary>
/// Gets or sets this instance's redundancy role when enabled (Primary/Secondary), or null when disabled.
/// </summary>
public string? RedundancyRole { get; set; }
/// <summary>
/// Gets or sets the redundancy mode when enabled (Warm/Hot), or null when disabled.
/// </summary>
public string? RedundancyMode { get; set; }
/// <summary>
/// Gets or sets the per-component health breakdown.
/// </summary>
public ComponentHealth Components { get; set; } = new();
/// <summary>
/// Gets or sets the server uptime since the health endpoint was initialized.
/// </summary>
public string Uptime { get; set; } = "";
/// <summary>
/// Gets or sets the UTC timestamp of this health snapshot.
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Per-component health breakdown for the health endpoint.
/// </summary>
public class ComponentHealth
{
/// <summary>
/// Gets or sets MXAccess runtime connectivity status.
/// </summary>
public string MxAccess { get; set; } = "Disconnected";
/// <summary>
/// Gets or sets Galaxy repository database connectivity status.
/// </summary>
public string Database { get; set; } = "Disconnected";
/// <summary>
/// Gets or sets OPC UA server status.
/// </summary>
public string OpcUaServer { get; set; } = "Stopped";
/// <summary>
/// Gets or sets the historian plugin status.
/// Values: <c>Disabled</c>, <c>NotFound</c>, <c>LoadFailed</c>, <c>Loaded</c>.
/// </summary>
public string Historian { get; set; } = "Disabled";
/// <summary>
/// Gets or sets whether alarm condition tracking is enabled.
/// Values: <c>Disabled</c>, <c>Enabled</c>.
/// </summary>
public string Alarms { get; set; } = "Disabled";
}
/// <summary>
/// Dashboard model for the status page footer.
/// </summary>
public class FooterInfo
{
/// <summary>
/// Gets or sets the UTC time when the status snapshot was generated.
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets the service version displayed to operators for support and traceability.
/// </summary>
public string Version { get; set; } = "";
}
}

View File

@@ -1,644 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
using ZB.MOM.WW.OtOpcUa.Host.Historian;
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Status
{
/// <summary>
/// Aggregates status from all components and generates HTML/JSON reports. (DASH-001 through DASH-009)
/// </summary>
public class StatusReportService
{
private readonly HealthCheckService _healthCheck;
private readonly int _refreshIntervalSeconds;
private readonly DateTime _startTime = DateTime.UtcNow;
private string? _applicationUri;
private GalaxyRepositoryStats? _galaxyStats;
private PerformanceMetrics? _metrics;
private HistorianConfiguration? _historianConfig;
private IMxAccessClient? _mxAccessClient;
private LmxNodeManager? _nodeManager;
private RedundancyConfiguration? _redundancyConfig;
private OpcUaServerHost? _serverHost;
/// <summary>
/// Initializes a new status report service for the dashboard using the supplied health-check policy and refresh
/// interval.
/// </summary>
/// <param name="healthCheck">The health-check component used to derive the overall dashboard health status.</param>
/// <param name="refreshIntervalSeconds">The HTML auto-refresh interval, in seconds, for the dashboard page.</param>
public StatusReportService(HealthCheckService healthCheck, int refreshIntervalSeconds)
{
_healthCheck = healthCheck;
_refreshIntervalSeconds = refreshIntervalSeconds;
}
/// <summary>
/// Supplies the live bridge components whose status should be reflected in generated dashboard snapshots.
/// </summary>
/// <param name="mxAccessClient">The runtime client whose connection and subscription state should be reported.</param>
/// <param name="metrics">The performance metrics collector whose operation statistics should be reported.</param>
/// <param name="galaxyStats">The Galaxy repository statistics to surface on the dashboard.</param>
/// <param name="serverHost">The OPC UA server host whose active session count should be reported.</param>
/// <param name="nodeManager">
/// The node manager whose queue depth and MXAccess event throughput should be surfaced on the
/// dashboard.
/// </param>
public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics,
GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost,
LmxNodeManager? nodeManager = null,
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null,
HistorianConfiguration? historianConfig = null)
{
_mxAccessClient = mxAccessClient;
_metrics = metrics;
_galaxyStats = galaxyStats;
_serverHost = serverHost;
_nodeManager = nodeManager;
_redundancyConfig = redundancyConfig;
_applicationUri = applicationUri;
_historianConfig = historianConfig;
}
/// <summary>
/// Builds the structured dashboard snapshot consumed by the HTML and JSON renderers.
/// </summary>
/// <returns>The current dashboard status data for the bridge.</returns>
public StatusData GetStatusData()
{
var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected;
var historianInfo = BuildHistorianStatusInfo();
var alarmInfo = BuildAlarmStatusInfo();
return new StatusData
{
Connection = new ConnectionInfo
{
State = connectionState.ToString(),
ReconnectCount = _mxAccessClient?.ReconnectCount ?? 0,
ActiveSessions = _serverHost?.ActiveSessionCount ?? 0
},
Health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo, BuildRuntimeStatusInfo()),
Subscriptions = new SubscriptionInfo
{
ActiveCount = _mxAccessClient?.ActiveSubscriptionCount ?? 0,
ProbeCount = _nodeManager?.ActiveRuntimeProbeCount ?? 0
},
Galaxy = new GalaxyInfo
{
GalaxyName = _galaxyStats?.GalaxyName ?? "",
DbConnected = _galaxyStats?.DbConnected ?? false,
LastDeployTime = _galaxyStats?.LastDeployTime,
ObjectCount = _galaxyStats?.ObjectCount ?? 0,
AttributeCount = _galaxyStats?.AttributeCount ?? 0,
LastRebuildTime = _galaxyStats?.LastRebuildTime
},
DataChange = new DataChangeInfo
{
EventsPerSecond = _nodeManager?.MxChangeEventsPerSecond ?? 0,
AvgBatchSize = _nodeManager?.AverageDispatchBatchSize ?? 0,
PendingItems = _nodeManager?.PendingDataChangeCount ?? 0,
TotalEvents = _nodeManager?.TotalMxChangeEvents ?? 0
},
Operations = _metrics?.GetStatistics() ?? new Dictionary<string, MetricsStatistics>(),
Historian = historianInfo,
Alarms = alarmInfo,
Redundancy = BuildRedundancyInfo(),
Endpoints = BuildEndpointsInfo(),
RuntimeStatus = BuildRuntimeStatusInfo(),
Footer = new FooterInfo
{
Timestamp = DateTime.UtcNow,
Version = typeof(StatusReportService).Assembly.GetName().Version?.ToString() ?? "1.0.0"
}
};
}
private HistorianStatusInfo BuildHistorianStatusInfo()
{
var outcome = HistorianPluginLoader.LastOutcome;
var health = _nodeManager?.HistorianHealth;
return new HistorianStatusInfo
{
Enabled = _historianConfig?.Enabled ?? false,
PluginStatus = outcome.Status.ToString(),
PluginError = outcome.Error,
PluginPath = outcome.PluginPath,
ServerName = _historianConfig?.ServerName ?? "",
Port = _historianConfig?.Port ?? 0,
QueryTotal = health?.TotalQueries ?? 0,
QuerySuccesses = health?.TotalSuccesses ?? 0,
QueryFailures = health?.TotalFailures ?? 0,
ConsecutiveFailures = health?.ConsecutiveFailures ?? 0,
LastSuccessTime = health?.LastSuccessTime,
LastFailureTime = health?.LastFailureTime,
LastQueryError = health?.LastError,
ProcessConnectionOpen = health?.ProcessConnectionOpen ?? false,
EventConnectionOpen = health?.EventConnectionOpen ?? false,
NodeCount = health?.NodeCount ?? 0,
HealthyNodeCount = health?.HealthyNodeCount ?? 0,
ActiveProcessNode = health?.ActiveProcessNode,
ActiveEventNode = health?.ActiveEventNode,
Nodes = health?.Nodes ?? new List<Historian.HistorianClusterNodeState>()
};
}
private AlarmStatusInfo BuildAlarmStatusInfo()
{
return new AlarmStatusInfo
{
TrackingEnabled = _nodeManager?.AlarmTrackingEnabled ?? false,
ConditionCount = _nodeManager?.AlarmConditionCount ?? 0,
ActiveAlarmCount = _nodeManager?.ActiveAlarmCount ?? 0,
TransitionCount = _nodeManager?.AlarmTransitionCount ?? 0,
AckEventCount = _nodeManager?.AlarmAckEventCount ?? 0,
AckWriteFailures = _nodeManager?.AlarmAckWriteFailures ?? 0,
FilterEnabled = _nodeManager?.AlarmFilterEnabled ?? false,
FilterPatternCount = _nodeManager?.AlarmFilterPatternCount ?? 0,
FilterIncludedObjectCount = _nodeManager?.AlarmFilterIncludedObjectCount ?? 0,
FilterPatterns = _nodeManager?.AlarmFilterPatterns?.ToList() ?? new List<string>()
};
}
private EndpointsInfo BuildEndpointsInfo()
{
var info = new EndpointsInfo();
if (_serverHost == null)
return info;
info.BaseAddresses = _serverHost.BaseAddresses.ToList();
info.UserTokenPolicies = _serverHost.UserTokenPolicies.Distinct().ToList();
foreach (var policy in _serverHost.SecurityPolicies)
{
var uri = policy.SecurityPolicyUri ?? "";
var hashIdx = uri.LastIndexOf('#');
var name = hashIdx >= 0 && hashIdx < uri.Length - 1 ? uri.Substring(hashIdx + 1) : uri;
info.SecurityProfiles.Add(new SecurityProfileInfo
{
PolicyUri = uri,
PolicyName = name,
SecurityMode = policy.SecurityMode.ToString()
});
}
return info;
}
private RuntimeStatusInfo BuildRuntimeStatusInfo()
{
var hosts = _nodeManager?.RuntimeStatuses?.ToList() ?? new List<GalaxyRuntimeStatus>();
var info = new RuntimeStatusInfo
{
Total = hosts.Count,
Hosts = hosts
};
foreach (var host in hosts)
{
switch (host.State)
{
case GalaxyRuntimeState.Running: info.RunningCount++; break;
case GalaxyRuntimeState.Stopped: info.StoppedCount++; break;
default: info.UnknownCount++; break;
}
}
return info;
}
private RedundancyInfo? BuildRedundancyInfo()
{
if (_redundancyConfig == null || !_redundancyConfig.Enabled)
return null;
var mxConnected = (_mxAccessClient?.State ?? ConnectionState.Disconnected) == ConnectionState.Connected;
var dbConnected = _galaxyStats?.DbConnected ?? false;
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
var baseLevel = isPrimary
? _redundancyConfig.ServiceLevelBase
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
var calculator = new ServiceLevelCalculator();
return new RedundancyInfo
{
Enabled = true,
Mode = _redundancyConfig.Mode,
Role = _redundancyConfig.Role,
ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected),
ApplicationUri = _applicationUri ?? "",
ServerUris = new List<string>(_redundancyConfig.ServerUris)
};
}
/// <summary>
/// Generates the operator-facing HTML dashboard for the current bridge status.
/// </summary>
/// <returns>An HTML document containing the latest dashboard snapshot.</returns>
public string GenerateHtml()
{
var data = GetStatusData();
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html><html><head>");
sb.AppendLine("<meta charset='utf-8'>");
sb.AppendLine($"<meta http-equiv='refresh' content='{_refreshIntervalSeconds}'>");
sb.AppendLine("<title>LmxOpcUa Status</title>");
sb.AppendLine("<style>");
sb.AppendLine("body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; }");
sb.AppendLine(".panel { border: 2px solid #444; border-radius: 8px; padding: 15px; margin: 10px 0; }");
sb.AppendLine(
".green { border-color: #00cc66; } .red { border-color: #cc3333; } .yellow { border-color: #cccc33; } .gray { border-color: #666; }");
sb.AppendLine(
"table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #333; }");
sb.AppendLine("h2 { margin: 0 0 10px 0; } h1 { color: #66ccff; }");
sb.AppendLine("h1 .version { color: #888; font-size: 0.5em; font-weight: normal; margin-left: 12px; }");
sb.AppendLine("</style></head><body>");
sb.AppendLine(
$"<h1>LmxOpcUa Status Dashboard<span class='version'>v{WebUtility.HtmlEncode(data.Footer.Version)}</span></h1>");
// Connection panel
var connColor = data.Connection.State == "Connected" ? "green" :
data.Connection.State == "Connecting" ? "yellow" : "red";
sb.AppendLine($"<div class='panel {connColor}'><h2>Connection</h2>");
sb.AppendLine(
$"<p>State: <b>{data.Connection.State}</b> | Reconnects: {data.Connection.ReconnectCount} | Sessions: {data.Connection.ActiveSessions}</p>");
sb.AppendLine("</div>");
// Health panel
sb.AppendLine($"<div class='panel {data.Health.Color}'><h2>Health</h2>");
sb.AppendLine($"<p>Status: <b>{data.Health.Status}</b> — {data.Health.Message}</p>");
sb.AppendLine("</div>");
// Endpoints panel (exposed URLs + security profiles)
var endpointsColor = data.Endpoints.BaseAddresses.Count > 0 ? "green" : "gray";
sb.AppendLine($"<div class='panel {endpointsColor}'><h2>Endpoints</h2>");
if (data.Endpoints.BaseAddresses.Count == 0)
{
sb.AppendLine("<p>No endpoints — OPC UA server not started.</p>");
}
else
{
sb.AppendLine("<p><b>Base Addresses:</b></p><ul>");
foreach (var addr in data.Endpoints.BaseAddresses)
sb.AppendLine($"<li>{WebUtility.HtmlEncode(addr)}</li>");
sb.AppendLine("</ul>");
sb.AppendLine("<p><b>Security Profiles:</b></p>");
sb.AppendLine("<table><tr><th>Mode</th><th>Policy</th><th>Policy URI</th></tr>");
foreach (var profile in data.Endpoints.SecurityProfiles)
{
sb.AppendLine(
$"<tr><td>{WebUtility.HtmlEncode(profile.SecurityMode)}</td>" +
$"<td>{WebUtility.HtmlEncode(profile.PolicyName)}</td>" +
$"<td>{WebUtility.HtmlEncode(profile.PolicyUri)}</td></tr>");
}
sb.AppendLine("</table>");
if (data.Endpoints.UserTokenPolicies.Count > 0)
sb.AppendLine(
$"<p><b>User Token Policies:</b> {WebUtility.HtmlEncode(string.Join(", ", data.Endpoints.UserTokenPolicies))}</p>");
}
sb.AppendLine("</div>");
// Redundancy panel (only when enabled)
if (data.Redundancy != null)
{
var roleColor = data.Redundancy.Role == "Primary" ? "green" : "yellow";
sb.AppendLine($"<div class='panel {roleColor}'><h2>Redundancy</h2>");
sb.AppendLine(
$"<p>Mode: <b>{data.Redundancy.Mode}</b> | Role: <b>{data.Redundancy.Role}</b> | Service Level: <b>{data.Redundancy.ServiceLevel}</b></p>");
sb.AppendLine($"<p>Application URI: {data.Redundancy.ApplicationUri}</p>");
sb.AppendLine($"<p>Redundant Set: {string.Join(", ", data.Redundancy.ServerUris)}</p>");
sb.AppendLine("</div>");
}
// Subscriptions panel
sb.AppendLine("<div class='panel gray'><h2>Subscriptions</h2>");
sb.AppendLine($"<p>Active: <b>{data.Subscriptions.ActiveCount}</b></p>");
if (data.Subscriptions.ProbeCount > 0)
sb.AppendLine(
$"<p>Probes: {data.Subscriptions.ProbeCount} (bridge-owned runtime status)</p>");
sb.AppendLine("</div>");
// Data Change Dispatch panel
sb.AppendLine("<div class='panel gray'><h2>Data Change Dispatch</h2>");
sb.AppendLine(
$"<p>Events/sec: <b>{data.DataChange.EventsPerSecond:F1}</b> | Avg Batch Size: <b>{data.DataChange.AvgBatchSize:F1}</b> | Pending: {data.DataChange.PendingItems} | Total Events: {data.DataChange.TotalEvents:N0}</p>");
sb.AppendLine("</div>");
// Galaxy Info panel
sb.AppendLine("<div class='panel gray'><h2>Galaxy Info</h2>");
sb.AppendLine(
$"<p>Galaxy: <b>{data.Galaxy.GalaxyName}</b> | DB: {(data.Galaxy.DbConnected ? "Connected" : "Disconnected")}</p>");
sb.AppendLine(
$"<p>Last Deploy: {data.Galaxy.LastDeployTime:O} | Objects: {data.Galaxy.ObjectCount} | Attributes: {data.Galaxy.AttributeCount}</p>");
sb.AppendLine($"<p>Last Rebuild: {data.Galaxy.LastRebuildTime:O}</p>");
sb.AppendLine("</div>");
// Galaxy Runtime panel — per-host Platform + AppEngine state
if (data.RuntimeStatus.Total > 0)
{
var rtColor = data.RuntimeStatus.StoppedCount > 0 ? "red"
: data.RuntimeStatus.UnknownCount > 0 ? "yellow"
: "green";
sb.AppendLine($"<div class='panel {rtColor}'><h2>Galaxy Runtime</h2>");
sb.AppendLine(
$"<p>{data.RuntimeStatus.RunningCount} of {data.RuntimeStatus.Total} hosts running" +
$" ({data.RuntimeStatus.StoppedCount} stopped, {data.RuntimeStatus.UnknownCount} unknown)</p>");
sb.AppendLine("<table><tr><th>Name</th><th>Kind</th><th>State</th><th>Since</th><th>Last Error</th></tr>");
foreach (var host in data.RuntimeStatus.Hosts)
{
var since = host.LastStateChangeTime?.ToString("O") ?? "-";
var err = WebUtility.HtmlEncode(host.LastError ?? "");
sb.AppendLine(
$"<tr><td>{WebUtility.HtmlEncode(host.ObjectName)}</td>" +
$"<td>{WebUtility.HtmlEncode(host.Kind)}</td>" +
$"<td>{host.State}</td>" +
$"<td>{since}</td>" +
$"<td><code>{err}</code></td></tr>");
}
sb.AppendLine("</table>");
sb.AppendLine("</div>");
}
// Historian panel
var anyClusterNodeFailed =
data.Historian.NodeCount > 0 && data.Historian.HealthyNodeCount < data.Historian.NodeCount;
var allClusterNodesFailed =
data.Historian.NodeCount > 0 && data.Historian.HealthyNodeCount == 0;
var histColor = !data.Historian.Enabled ? "gray"
: data.Historian.PluginStatus != "Loaded" ? "red"
: allClusterNodesFailed ? "red"
: data.Historian.ConsecutiveFailures >= 5 ? "red"
: anyClusterNodeFailed || data.Historian.ConsecutiveFailures > 0 ? "yellow"
: "green";
sb.AppendLine($"<div class='panel {histColor}'><h2>Historian</h2>");
sb.AppendLine(
$"<p>Enabled: <b>{data.Historian.Enabled}</b> | Plugin: <b>{data.Historian.PluginStatus}</b> | Port: {data.Historian.Port}</p>");
if (!string.IsNullOrEmpty(data.Historian.PluginError))
sb.AppendLine($"<p>Plugin Error: {WebUtility.HtmlEncode(data.Historian.PluginError)}</p>");
if (data.Historian.PluginStatus == "Loaded")
{
sb.AppendLine(
$"<p>Queries: <b>{data.Historian.QueryTotal:N0}</b> " +
$"(Success: {data.Historian.QuerySuccesses:N0}, Failure: {data.Historian.QueryFailures:N0}) " +
$"| Consecutive Failures: <b>{data.Historian.ConsecutiveFailures}</b></p>");
var procBadge = data.Historian.ProcessConnectionOpen
? $"open ({WebUtility.HtmlEncode(data.Historian.ActiveProcessNode ?? "?")})"
: "closed";
var evtBadge = data.Historian.EventConnectionOpen
? $"open ({WebUtility.HtmlEncode(data.Historian.ActiveEventNode ?? "?")})"
: "closed";
sb.AppendLine(
$"<p>Process Conn: <b>{procBadge}</b> | Event Conn: <b>{evtBadge}</b></p>");
if (data.Historian.LastSuccessTime.HasValue)
sb.AppendLine($"<p>Last Success: {data.Historian.LastSuccessTime:O}</p>");
if (data.Historian.LastFailureTime.HasValue)
sb.AppendLine($"<p>Last Failure: {data.Historian.LastFailureTime:O}</p>");
if (!string.IsNullOrEmpty(data.Historian.LastQueryError))
sb.AppendLine(
$"<p>Last Error: <code>{WebUtility.HtmlEncode(data.Historian.LastQueryError)}</code></p>");
// Cluster table: only when a true multi-node cluster is configured.
if (data.Historian.NodeCount > 1)
{
sb.AppendLine(
$"<p><b>Cluster:</b> {data.Historian.HealthyNodeCount} of {data.Historian.NodeCount} nodes healthy</p>");
sb.AppendLine(
"<table><tr><th>Node</th><th>State</th><th>Cooldown Until</th><th>Failures</th><th>Last Error</th></tr>");
foreach (var node in data.Historian.Nodes)
{
var state = node.IsHealthy ? "healthy" : "cooldown";
var cooldown = node.CooldownUntil?.ToString("O") ?? "-";
var lastErr = WebUtility.HtmlEncode(node.LastError ?? "");
sb.AppendLine(
$"<tr><td>{WebUtility.HtmlEncode(node.Name)}</td><td>{state}</td>" +
$"<td>{cooldown}</td><td>{node.FailureCount}</td><td><code>{lastErr}</code></td></tr>");
}
sb.AppendLine("</table>");
}
else if (data.Historian.NodeCount == 1)
{
sb.AppendLine($"<p>Node: {WebUtility.HtmlEncode(data.Historian.Nodes[0].Name)}</p>");
}
}
sb.AppendLine("</div>");
// Alarms panel
var alarmPanelColor = !data.Alarms.TrackingEnabled ? "gray"
: data.Alarms.AckWriteFailures > 0 ? "yellow" : "green";
sb.AppendLine($"<div class='panel {alarmPanelColor}'><h2>Alarms</h2>");
sb.AppendLine(
$"<p>Tracking: <b>{data.Alarms.TrackingEnabled}</b> | Conditions: {data.Alarms.ConditionCount} | Active: <b>{data.Alarms.ActiveAlarmCount}</b></p>");
sb.AppendLine(
$"<p>Transitions: {data.Alarms.TransitionCount:N0} | Ack Events: {data.Alarms.AckEventCount:N0} | Ack Write Failures: {data.Alarms.AckWriteFailures}</p>");
if (data.Alarms.FilterEnabled)
{
sb.AppendLine(
$"<p>Filter: <b>{data.Alarms.FilterPatternCount}</b> pattern(s), <b>{data.Alarms.FilterIncludedObjectCount}</b> object(s) included</p>");
if (data.Alarms.FilterPatterns.Count > 0)
{
sb.AppendLine("<ul>");
foreach (var pattern in data.Alarms.FilterPatterns)
sb.AppendLine($"<li><code>{WebUtility.HtmlEncode(pattern)}</code></li>");
sb.AppendLine("</ul>");
}
}
else
{
sb.AppendLine("<p>Filter: <b>disabled</b> (all alarm-bearing objects tracked)</p>");
}
sb.AppendLine("</div>");
// Operations table
sb.AppendLine("<div class='panel gray'><h2>Operations</h2>");
sb.AppendLine(
"<table><tr><th>Operation</th><th>Count</th><th>Success Rate</th><th>Avg (ms)</th><th>Min (ms)</th><th>Max (ms)</th><th>P95 (ms)</th></tr>");
foreach (var kvp in data.Operations)
{
var s = kvp.Value;
sb.AppendLine($"<tr><td>{kvp.Key}</td><td>{s.TotalCount}</td><td>{s.SuccessRate:P1}</td>" +
$"<td>{s.AverageMilliseconds:F1}</td><td>{s.MinMilliseconds:F1}</td><td>{s.MaxMilliseconds:F1}</td><td>{s.Percentile95Milliseconds:F1}</td></tr>");
}
sb.AppendLine("</table></div>");
sb.AppendLine("</body></html>");
return sb.ToString();
}
/// <summary>
/// Generates an indented JSON status payload for API consumers.
/// </summary>
/// <returns>A JSON representation of the current dashboard snapshot.</returns>
public string GenerateJson()
{
var data = GetStatusData();
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
}
/// <summary>
/// Determines whether the bridge should currently be considered healthy for the dashboard health endpoint.
/// </summary>
/// <returns><see langword="true" /> when the bridge meets the health policy; otherwise, <see langword="false" />.</returns>
public bool IsHealthy()
{
var state = _mxAccessClient?.State ?? ConnectionState.Disconnected;
return _healthCheck.IsHealthy(state, _metrics);
}
/// <summary>
/// Builds the rich health endpoint data including component health, ServiceLevel, and redundancy state.
/// </summary>
public HealthEndpointData GetHealthData()
{
var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected;
var mxConnected = connectionState == ConnectionState.Connected;
var dbConnected = _galaxyStats?.DbConnected ?? false;
var historianInfo = BuildHistorianStatusInfo();
var alarmInfo = BuildAlarmStatusInfo();
var health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo);
var uptime = DateTime.UtcNow - _startTime;
var data = new HealthEndpointData
{
Status = health.Status,
RedundancyEnabled = _redundancyConfig?.Enabled ?? false,
Components = new ComponentHealth
{
MxAccess = connectionState.ToString(),
Database = dbConnected ? "Connected" : "Disconnected",
OpcUaServer = _serverHost?.IsRunning ?? false ? "Running" : "Stopped",
Historian = historianInfo.PluginStatus,
Alarms = alarmInfo.TrackingEnabled ? "Enabled" : "Disabled"
},
Uptime = FormatUptime(uptime),
Timestamp = DateTime.UtcNow
};
if (_redundancyConfig != null && _redundancyConfig.Enabled)
{
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
var baseLevel = isPrimary
? _redundancyConfig.ServiceLevelBase
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
var calculator = new ServiceLevelCalculator();
data.ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected);
data.RedundancyRole = _redundancyConfig.Role;
data.RedundancyMode = _redundancyConfig.Mode;
}
else
{
// Non-redundant: 255 when healthy, 0 when both down
data.ServiceLevel = mxConnected ? (byte)255 : (byte)0;
}
return data;
}
/// <summary>
/// Generates the JSON payload for the /api/health endpoint.
/// </summary>
public string GenerateHealthJson()
{
var data = GetHealthData();
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
}
/// <summary>
/// Generates a focused health status HTML page for operators and monitoring dashboards.
/// </summary>
public string GenerateHealthHtml()
{
var data = GetHealthData();
var sb = new StringBuilder();
var statusColor = data.Status == "Healthy" ? "#00cc66" : data.Status == "Degraded" ? "#cccc33" : "#cc3333";
var mxColor = data.Components.MxAccess == "Connected" ? "#00cc66" : "#cc3333";
var dbColor = data.Components.Database == "Connected" ? "#00cc66" : "#cc3333";
var uaColor = data.Components.OpcUaServer == "Running" ? "#00cc66" : "#cc3333";
sb.AppendLine("<!DOCTYPE html><html><head>");
sb.AppendLine("<meta charset='utf-8'>");
sb.AppendLine($"<meta http-equiv='refresh' content='{_refreshIntervalSeconds}'>");
sb.AppendLine("<title>LmxOpcUa Health</title>");
sb.AppendLine("<style>");
sb.AppendLine(
"body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; margin: 0; }");
sb.AppendLine(".header { text-align: center; padding: 30px 0; }");
sb.AppendLine(
".status-badge { display: inline-block; font-size: 2em; font-weight: bold; padding: 15px 40px; border-radius: 12px; letter-spacing: 2px; }");
sb.AppendLine(".service-level { text-align: center; font-size: 4em; font-weight: bold; margin: 20px 0; }");
sb.AppendLine(".service-level .label { font-size: 0.3em; color: #999; display: block; }");
sb.AppendLine(
".components { display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin: 30px auto; max-width: 800px; }");
sb.AppendLine(
".component { border: 2px solid #444; border-radius: 8px; padding: 20px; min-width: 200px; text-align: center; }");
sb.AppendLine(".component .name { font-size: 0.9em; color: #999; margin-bottom: 8px; }");
sb.AppendLine(".component .value { font-size: 1.3em; font-weight: bold; }");
sb.AppendLine(".meta { text-align: center; margin-top: 30px; color: #666; font-size: 0.85em; }");
sb.AppendLine(".redundancy { text-align: center; margin: 10px 0; color: #999; }");
sb.AppendLine(".redundancy b { color: #66ccff; }");
sb.AppendLine("</style></head><body>");
// Status badge
sb.AppendLine("<div class='header'>");
sb.AppendLine(
$"<div class='status-badge' style='background: {statusColor}; color: #000;'>{data.Status.ToUpperInvariant()}</div>");
sb.AppendLine("</div>");
// Service Level
sb.AppendLine($"<div class='service-level' style='color: {statusColor};'>");
sb.AppendLine("<span class='label'>SERVICE LEVEL</span>");
sb.AppendLine($"{data.ServiceLevel}");
sb.AppendLine("</div>");
// Redundancy info
if (data.RedundancyEnabled)
sb.AppendLine(
$"<div class='redundancy'>Role: <b>{data.RedundancyRole}</b> | Mode: <b>{data.RedundancyMode}</b></div>");
var historianColor = data.Components.Historian == "Loaded" ? "#00cc66"
: data.Components.Historian == "Disabled" ? "#666" : "#cc3333";
var alarmColor = data.Components.Alarms == "Enabled" ? "#00cc66" : "#666";
// Component health cards
sb.AppendLine("<div class='components'>");
sb.AppendLine(
$"<div class='component' style='border-color: {mxColor};'><div class='name'>MXAccess</div><div class='value' style='color: {mxColor};'>{data.Components.MxAccess}</div></div>");
sb.AppendLine(
$"<div class='component' style='border-color: {dbColor};'><div class='name'>Galaxy Database</div><div class='value' style='color: {dbColor};'>{data.Components.Database}</div></div>");
sb.AppendLine(
$"<div class='component' style='border-color: {uaColor};'><div class='name'>OPC UA Server</div><div class='value' style='color: {uaColor};'>{data.Components.OpcUaServer}</div></div>");
sb.AppendLine(
$"<div class='component' style='border-color: {historianColor};'><div class='name'>Historian</div><div class='value' style='color: {historianColor};'>{data.Components.Historian}</div></div>");
sb.AppendLine(
$"<div class='component' style='border-color: {alarmColor};'><div class='name'>Alarm Tracking</div><div class='value' style='color: {alarmColor};'>{data.Components.Alarms}</div></div>");
sb.AppendLine("</div>");
// Footer
sb.AppendLine($"<div class='meta'>Uptime: {data.Uptime} | {data.Timestamp:O}</div>");
sb.AppendLine("</body></html>");
return sb.ToString();
}
private static string FormatUptime(TimeSpan ts)
{
if (ts.TotalDays >= 1)
return $"{(int)ts.TotalDays}d {ts.Hours}h {ts.Minutes}m";
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours}h {ts.Minutes}m";
return $"{(int)ts.TotalMinutes}m {ts.Seconds}s";
}
}
}

View File

@@ -1,189 +0,0 @@
using System;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Host.Status
{
/// <summary>
/// HTTP server for status dashboard. Routes: / → HTML, /api/status → JSON, /api/health → 200/503. (DASH-001)
/// </summary>
public class StatusWebServer : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<StatusWebServer>();
private readonly int _port;
private readonly StatusReportService _reportService;
private CancellationTokenSource? _cts;
private HttpListener? _listener;
private Task? _listenTask;
/// <summary>
/// Initializes a new dashboard web server bound to the supplied report service and HTTP port.
/// </summary>
/// <param name="reportService">The report service used to generate dashboard responses.</param>
/// <param name="port">The HTTP port to listen on.</param>
public StatusWebServer(StatusReportService reportService, int port)
{
_reportService = reportService;
_port = port;
}
/// <summary>
/// Gets a value indicating whether the dashboard listener is currently accepting requests.
/// </summary>
public bool IsRunning => _listener?.IsListening ?? false;
/// <summary>
/// Stops the dashboard listener and releases its resources.
/// </summary>
public void Dispose()
{
Stop();
}
/// <summary>
/// Starts the HTTP listener and background request loop for the status dashboard.
/// </summary>
public bool Start()
{
try
{
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{_port}/");
_listener.Start();
_cts = new CancellationTokenSource();
_listenTask = Task.Run(() => ListenLoopAsync(_cts.Token));
Log.Information("Status dashboard started on http://localhost:{Port}/", _port);
return true;
}
catch (Exception ex)
{
Log.Error(ex, "Failed to start status dashboard on port {Port}", _port);
_listener = null;
return false;
}
}
/// <summary>
/// Stops the dashboard listener and releases its HTTP resources.
/// </summary>
public void Stop()
{
_cts?.Cancel();
try
{
_listener?.Stop();
_listener?.Close();
}
catch
{
/* ignore */
}
_listener = null;
try { _listenTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
_listenTask = null;
Log.Information("Status dashboard stopped");
}
private async Task ListenLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && _listener != null && _listener.IsListening)
try
{
var context = await _listener.GetContextAsync();
_ = HandleRequestAsync(context);
}
catch (ObjectDisposedException)
{
break;
}
catch (HttpListenerException)
{
break;
}
catch (Exception ex)
{
Log.Warning(ex, "Dashboard listener error");
}
}
private async Task HandleRequestAsync(HttpListenerContext context)
{
try
{
var request = context.Request;
var response = context.Response;
// Only allow GET
if (request.HttpMethod != "GET")
{
response.StatusCode = 405;
response.Close();
return;
}
// No-cache headers
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
var path = request.Url?.AbsolutePath ?? "/";
switch (path)
{
case "/":
await WriteResponse(response, _reportService.GenerateHtml(), "text/html", 200);
break;
case "/health":
await WriteResponse(response, _reportService.GenerateHealthHtml(), "text/html", 200);
break;
case "/api/status":
await WriteResponse(response, _reportService.GenerateJson(), "application/json", 200);
break;
case "/api/health":
var healthData = _reportService.GetHealthData();
var healthJson = _reportService.GenerateHealthJson();
var healthStatusCode = healthData.Status == "Unhealthy" ? 503 : 200;
await WriteResponse(response, healthJson, "application/json", healthStatusCode);
break;
default:
response.StatusCode = 404;
response.Close();
break;
}
}
catch (Exception ex)
{
Log.Warning(ex, "Error handling dashboard request");
try
{
context.Response.Close();
}
catch
{
}
}
}
private static async Task WriteResponse(HttpListenerResponse response, string body, string contentType,
int statusCode)
{
var buffer = Encoding.UTF8.GetBytes(body);
response.StatusCode = statusCode;
response.ContentType = contentType;
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.Close();
}
}
}

View File

@@ -1,53 +0,0 @@
using System;
using System.Threading.Tasks;
namespace ZB.MOM.WW.OtOpcUa.Host.Utilities
{
/// <summary>
/// Bounded safety wrappers for blocking on async tasks from synchronous OPC UA stack
/// callbacks (Read, Write, HistoryRead*, BuildAddressSpace). These are backstops: the
/// underlying MxAccess / Historian clients already enforce inner timeouts on the async
/// path, but an outer bound is still required so the stack thread cannot be parked
/// indefinitely by a hung scheduler, a slow reconnect, or any other non-returning
/// async path.
/// </summary>
/// <remarks>
/// On timeout, the underlying task is NOT cancelled — it runs to completion on the
/// thread pool and is abandoned. Callers must be comfortable with the fire-forget
/// semantics of the background continuation. This is acceptable for the current call
/// sites because MxAccess and Historian clients are shared singletons whose background
/// work does not capture request-scoped state.
/// </remarks>
internal static class SyncOverAsync
{
public static void WaitSync(Task task, TimeSpan timeout, string operation)
{
if (task == null) throw new ArgumentNullException(nameof(task));
try
{
if (!task.Wait(timeout))
throw new TimeoutException($"{operation} exceeded {timeout.TotalSeconds:0.#}s");
}
catch (AggregateException ae) when (ae.InnerExceptions.Count == 1)
{
// Unwrap the single inner exception so callers can write natural catch blocks.
throw ae.InnerExceptions[0];
}
}
public static T WaitSync<T>(Task<T> task, TimeSpan timeout, string operation)
{
if (task == null) throw new ArgumentNullException(nameof(task));
try
{
if (!task.Wait(timeout))
throw new TimeoutException($"{operation} exceeded {timeout.TotalSeconds:0.#}s");
return task.Result;
}
catch (AggregateException ae) when (ae.InnerExceptions.Count == 1)
{
throw ae.InnerExceptions[0];
}
}
}
}

View File

@@ -1,71 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Host</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Host</AssemblyName>
<!--
Phase 2 Stream D — V1 ARCHIVE. Functionally superseded by:
src/ZB.MOM.WW.OtOpcUa.Server (host process, .NET 10)
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host (out-of-process MXAccess, net48 x86)
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy (in-process driver, .NET 10)
Kept in the build graph because Historian.Aveva + IntegrationTests still
transitively reference it. Deletion is the subject of Phase 2 PR 3 (separate from
this PR 2). See docs/v2/V1_ARCHIVE_STATUS.md.
-->
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Tests"/>
</ItemGroup>
<ItemGroup>
<!-- Service hosting -->
<PackageReference Include="Topshelf" Version="4.3.0"/>
<PackageReference Include="Topshelf.Serilog" Version="4.3.0"/>
<!-- Logging -->
<PackageReference Include="Serilog" Version="2.10.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0"/>
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/>
<!-- OPC UA -->
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
<!-- Configuration -->
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1"/>
<!-- Single-EXE bundling -->
<PackageReference Include="Costura.Fody" Version="6.0.0-alpha0384" PrivateAssets="all"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0"/>
</ItemGroup>
<ItemGroup>
<!-- LDAP authentication -->
<Reference Include="System.DirectoryServices.Protocols"/>
</ItemGroup>
<ItemGroup>
<!-- MXAccess COM interop (unrelated to the historian SDK) -->
<Reference Include="ArchestrA.MxAccess">
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -1,91 +0,0 @@
{
"OpcUa": {
"BindAddress": "0.0.0.0",
"Port": 4840,
"EndpointPath": "/LmxOpcUa",
"ServerName": "LmxOpcUa",
"GalaxyName": "ZB",
"MaxSessions": 100,
"SessionTimeoutMinutes": 30,
"AlarmTrackingEnabled": false,
"AlarmFilter": {
"ObjectFilters": []
},
"ApplicationUri": null
},
"MxAccess": {
"ClientName": "LmxOpcUa",
"NodeName": null,
"GalaxyName": null,
"ReadTimeoutSeconds": 5,
"WriteTimeoutSeconds": 5,
"MaxConcurrentOperations": 10,
"MonitorIntervalSeconds": 5,
"AutoReconnect": true,
"ProbeTag": null,
"ProbeStaleThresholdSeconds": 60,
"RuntimeStatusProbesEnabled": true,
"RuntimeStatusUnknownTimeoutSeconds": 15
},
"GalaxyRepository": {
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;",
"ChangeDetectionIntervalSeconds": 30,
"CommandTimeoutSeconds": 30,
"ExtendedAttributes": false,
"Scope": "Galaxy",
"PlatformName": null
},
"Dashboard": {
"Enabled": true,
"Port": 8081,
"RefreshIntervalSeconds": 10
},
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": false,
"Ldap": {
"Enabled": false,
"Host": "localhost",
"Port": 3893,
"BaseDN": "dc=lmxopcua,dc=local",
"BindDnTemplate": "cn={username},dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123",
"TimeoutSeconds": 5,
"ReadOnlyGroup": "ReadOnly",
"WriteOperateGroup": "WriteOperate",
"WriteTuneGroup": "WriteTune",
"WriteConfigureGroup": "WriteConfigure",
"AlarmAckGroup": "AlarmAck"
}
},
"Security": {
"Profiles": [
"None"
],
"AutoAcceptClientCertificates": true,
"RejectSHA1Certificates": true,
"MinimumCertificateKeySize": 2048,
"PkiRootPath": null,
"CertificateSubject": null
},
"Redundancy": {
"Enabled": false,
"Mode": "Warm",
"Role": "Primary",
"ServerUris": [],
"ServiceLevelBase": 200
},
"Historian": {
"Enabled": false,
"ServerName": "localhost",
"ServerNames": [],
"FailureCooldownSeconds": 60,
"IntegratedSecurity": true,
"UserName": null,
"Password": null,
"Port": 32568,
"CommandTimeoutSeconds": 30,
"MaxValuesPerRead": 10000
}
}

View File

@@ -1,63 +0,0 @@
using System;
using System.Collections.Generic;
using ArchestrA;
using ZB.MOM.WW.OtOpcUa.Historian.Aveva;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests
{
/// <summary>
/// Fake Historian connection factory for tests. Controls whether connections
/// succeed, fail, or timeout without requiring the real Historian SDK runtime.
/// </summary>
internal sealed class FakeHistorianConnectionFactory : IHistorianConnectionFactory
{
/// <summary>
/// Exception thrown on every CreateAndConnect call unless a more specific rule in
/// <see cref="ServerBehaviors"/> or <see cref="OnConnect"/> fires first.
/// </summary>
public Exception? ConnectException { get; set; }
public int ConnectCallCount { get; private set; }
public Action<int>? OnConnect { get; set; }
/// <summary>
/// Per-server-name override: if the requested <c>config.ServerName</c> has an entry
/// whose value is non-null, that exception is thrown instead of the global
/// <see cref="ConnectException"/>. Lets tests script cluster failover behavior like
/// "node A always fails; node B always succeeds".
/// </summary>
public Dictionary<string, Exception?> ServerBehaviors { get; } =
new Dictionary<string, Exception?>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Ordered history of server names passed to CreateAndConnect so tests can assert the
/// picker's iteration order and failover sequence.
/// </summary>
public List<string> ConnectHistory { get; } = new List<string>();
public HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type)
{
ConnectCallCount++;
ConnectHistory.Add(config.ServerName);
if (ServerBehaviors.TryGetValue(config.ServerName, out var serverException) && serverException != null)
throw serverException;
if (OnConnect != null)
{
OnConnect(ConnectCallCount);
}
else if (ConnectException != null)
{
throw ConnectException;
}
// Return a HistorianAccess that is not actually connected.
// ReadRawAsync etc. will fail when they try to use it, which exercises
// the HandleConnectionError → reconnect path.
return new HistorianAccess();
}
}
}

View File

@@ -1,291 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests
{
/// <summary>
/// Exhaustive coverage of the cluster endpoint picker: config parsing, healthy-list ordering,
/// cooldown behavior with an injected clock, and thread-safety under concurrent writers.
/// </summary>
public class HistorianClusterEndpointPickerTests
{
// ---------- Construction / config parsing ----------
[Fact]
public void SingleServerName_FallbackWhenServerNamesEmpty()
{
var picker = new HistorianClusterEndpointPicker(Config(serverName: "host-a"));
picker.NodeCount.ShouldBe(1);
picker.GetHealthyNodes().ShouldBe(new[] { "host-a" });
}
[Fact]
public void ServerNames_TakesPrecedenceOverLegacyServerName()
{
var picker = new HistorianClusterEndpointPicker(
Config(serverName: "legacy", serverNames: new[] { "host-a", "host-b" }));
picker.NodeCount.ShouldBe(2);
picker.GetHealthyNodes().ShouldBe(new[] { "host-a", "host-b" });
}
[Fact]
public void ServerNames_OrderedAsConfigured()
{
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "c", "a", "b" }));
picker.GetHealthyNodes().ShouldBe(new[] { "c", "a", "b" });
}
[Fact]
public void ServerNames_WhitespaceTrimmedAndEmptyDropped()
{
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { " host-a ", "", " ", "host-b" }));
picker.GetHealthyNodes().ShouldBe(new[] { "host-a", "host-b" });
}
[Fact]
public void ServerNames_CaseInsensitiveDeduplication()
{
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "Host-A", "HOST-A", "host-a" }));
picker.NodeCount.ShouldBe(1);
}
[Fact]
public void EmptyConfig_ProducesEmptyPool()
{
var picker = new HistorianClusterEndpointPicker(
Config(serverName: "", serverNames: Array.Empty<string>()));
picker.NodeCount.ShouldBe(0);
picker.GetHealthyNodes().ShouldBeEmpty();
}
// ---------- MarkFailed / cooldown window ----------
[Fact]
public void MarkFailed_RemovesNodeFromHealthyList()
{
var clock = new FakeClock();
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 60), clock.Now);
picker.MarkFailed("a", "boom");
picker.GetHealthyNodes().ShouldBe(new[] { "b" });
picker.HealthyNodeCount.ShouldBe(1);
}
[Fact]
public void MarkFailed_RecordsErrorAndTimestamp()
{
var clock = new FakeClock { UtcNow = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) };
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a", "b" }), clock.Now);
picker.MarkFailed("a", "connection refused");
var states = picker.SnapshotNodeStates();
var a = states.First(s => s.Name == "a");
a.IsHealthy.ShouldBeFalse();
a.FailureCount.ShouldBe(1);
a.LastError.ShouldBe("connection refused");
a.LastFailureTime.ShouldBe(clock.UtcNow);
}
[Fact]
public void MarkFailed_CooldownExpiryRestoresNode()
{
var clock = new FakeClock { UtcNow = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) };
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 60), clock.Now);
picker.MarkFailed("a", "boom");
picker.GetHealthyNodes().ShouldBe(new[] { "b" });
// Advance clock just before expiry — still in cooldown
clock.UtcNow = clock.UtcNow.AddSeconds(59);
picker.GetHealthyNodes().ShouldBe(new[] { "b" });
// Advance past cooldown — node returns to pool
clock.UtcNow = clock.UtcNow.AddSeconds(2);
picker.GetHealthyNodes().ShouldBe(new[] { "a", "b" });
}
[Fact]
public void ZeroCooldown_NeverBenchesNode()
{
var clock = new FakeClock();
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 0), clock.Now);
picker.MarkFailed("a", "boom");
// Zero cooldown → node remains eligible immediately
picker.GetHealthyNodes().ShouldBe(new[] { "a", "b" });
var state = picker.SnapshotNodeStates().First(s => s.Name == "a");
state.FailureCount.ShouldBe(1);
state.LastError.ShouldBe("boom");
}
[Fact]
public void AllNodesFailed_HealthyListIsEmpty()
{
var clock = new FakeClock();
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 60), clock.Now);
picker.MarkFailed("a", "boom");
picker.MarkFailed("b", "boom");
picker.GetHealthyNodes().ShouldBeEmpty();
picker.HealthyNodeCount.ShouldBe(0);
}
[Fact]
public void MarkFailed_AccumulatesFailureCount()
{
var clock = new FakeClock();
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a" }, cooldownSeconds: 10), clock.Now);
picker.MarkFailed("a", "error 1");
clock.UtcNow = clock.UtcNow.AddSeconds(20); // recover
picker.MarkFailed("a", "error 2");
picker.SnapshotNodeStates().First().FailureCount.ShouldBe(2);
picker.SnapshotNodeStates().First().LastError.ShouldBe("error 2");
}
// ---------- MarkHealthy ----------
[Fact]
public void MarkHealthy_ClearsCooldownImmediately()
{
var clock = new FakeClock();
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 3600), clock.Now);
picker.MarkFailed("a", "boom");
picker.GetHealthyNodes().ShouldBe(new[] { "b" });
picker.MarkHealthy("a");
picker.GetHealthyNodes().ShouldBe(new[] { "a", "b" });
}
[Fact]
public void MarkHealthy_PreservesCumulativeFailureCount()
{
var clock = new FakeClock();
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a" }), clock.Now);
picker.MarkFailed("a", "boom");
picker.MarkHealthy("a");
var state = picker.SnapshotNodeStates().First();
state.IsHealthy.ShouldBeTrue();
state.FailureCount.ShouldBe(1); // history preserved
}
// ---------- Unknown node handling ----------
[Fact]
public void MarkFailed_UnknownNode_IsIgnored()
{
var clock = new FakeClock();
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a" }), clock.Now);
Should.NotThrow(() => picker.MarkFailed("not-configured", "boom"));
picker.GetHealthyNodes().ShouldBe(new[] { "a" });
}
[Fact]
public void MarkHealthy_UnknownNode_IsIgnored()
{
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a" }));
Should.NotThrow(() => picker.MarkHealthy("not-configured"));
}
// ---------- SnapshotNodeStates ----------
[Fact]
public void SnapshotNodeStates_ReflectsConfigurationOrder()
{
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "z", "m", "a" }));
picker.SnapshotNodeStates().Select(s => s.Name).ShouldBe(new[] { "z", "m", "a" });
}
[Fact]
public void SnapshotNodeStates_HealthyEntriesHaveNoCooldown()
{
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a" }));
var state = picker.SnapshotNodeStates().First();
state.IsHealthy.ShouldBeTrue();
state.CooldownUntil.ShouldBeNull();
state.LastError.ShouldBeNull();
state.LastFailureTime.ShouldBeNull();
}
// ---------- Thread safety smoke test ----------
[Fact]
public void ConcurrentMarkAndQuery_DoesNotCorrupt()
{
var clock = new FakeClock();
var picker = new HistorianClusterEndpointPicker(
Config(serverNames: new[] { "a", "b", "c", "d" }, cooldownSeconds: 5), clock.Now);
var tasks = new List<Task>();
for (var i = 0; i < 8; i++)
{
tasks.Add(Task.Run(() =>
{
for (var j = 0; j < 1000; j++)
{
picker.MarkFailed("a", "boom");
picker.MarkHealthy("a");
_ = picker.GetHealthyNodes();
_ = picker.SnapshotNodeStates();
}
}));
}
Task.WaitAll(tasks.ToArray());
// Just verify we can still read state after the storm.
picker.NodeCount.ShouldBe(4);
picker.GetHealthyNodes().Count.ShouldBeInRange(3, 4);
}
// ---------- Helpers ----------
private static HistorianConfiguration Config(
string serverName = "localhost",
string[]? serverNames = null,
int cooldownSeconds = 60)
{
return new HistorianConfiguration
{
ServerName = serverName,
ServerNames = (serverNames ?? Array.Empty<string>()).ToList(),
FailureCooldownSeconds = cooldownSeconds
};
}
private sealed class FakeClock
{
public DateTime UtcNow { get; set; } = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
public DateTime Now() => UtcNow;
}
}
}

View File

@@ -1,166 +0,0 @@
using System;
using System.Linq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests
{
/// <summary>
/// End-to-end behavior of the cluster endpoint picker wired into
/// <see cref="HistorianDataSource"/>. Verifies that a failing node is skipped on the next
/// attempt, that the picker state is shared across process + event silos, and that the
/// health snapshot surfaces the winning node.
/// </summary>
public class HistorianClusterFailoverTests
{
private static HistorianConfiguration ClusterConfig(params string[] nodes) => new()
{
Enabled = true,
ServerNames = nodes.ToList(),
Port = 32568,
IntegratedSecurity = true,
CommandTimeoutSeconds = 5,
FailureCooldownSeconds = 60
};
[Fact]
public void Connect_FirstNodeFails_PicksSecond()
{
// host-a fails during connect; host-b connects successfully. The fake returns an
// unconnected HistorianAccess on success, so the query phase will subsequently trip
// HandleConnectionError on host-b — that's expected. The observable signal is that
// the picker tried host-a first, skipped to host-b, and host-a's failure was recorded.
var factory = new FakeHistorianConnectionFactory();
factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down");
var config = ClusterConfig("host-a", "host-b");
using var ds = new HistorianDataSource(config, factory);
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
.GetAwaiter().GetResult();
factory.ConnectHistory.ShouldBe(new[] { "host-a", "host-b" });
var snap = ds.GetHealthSnapshot();
snap.NodeCount.ShouldBe(2);
snap.Nodes.Single(n => n.Name == "host-a").IsHealthy.ShouldBeFalse();
snap.Nodes.Single(n => n.Name == "host-a").FailureCount.ShouldBe(1);
snap.Nodes.Single(n => n.Name == "host-a").LastError.ShouldContain("A down");
}
[Fact]
public void Connect_AllNodesFail_ReturnsEmptyResults_AndAllInCooldown()
{
var factory = new FakeHistorianConnectionFactory();
factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down");
factory.ServerBehaviors["host-b"] = new InvalidOperationException("B down");
var config = ClusterConfig("host-a", "host-b");
using var ds = new HistorianDataSource(config, factory);
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
.GetAwaiter().GetResult();
results.Count.ShouldBe(0);
factory.ConnectHistory.ShouldBe(new[] { "host-a", "host-b" });
var snap = ds.GetHealthSnapshot();
snap.ActiveProcessNode.ShouldBeNull();
snap.HealthyNodeCount.ShouldBe(0);
snap.TotalFailures.ShouldBe(1); // one read call failed (after all cluster tries)
snap.LastError.ShouldContain("All 2 healthy historian candidate(s) failed");
snap.LastError.ShouldContain("B down"); // last inner exception preserved
}
[Fact]
public void Connect_SecondCall_SkipsCooledDownNode()
{
// After first call: host-a is in cooldown (60s), host-b is also marked failed via
// HandleConnectionError since the fake connection doesn't support real queries.
// Second call: both are in cooldown and the picker returns empty → the read method
// catches the "all nodes failed" exception and returns empty without retrying connect.
// We verify this by checking that the second call adds NOTHING to the connect history.
var factory = new FakeHistorianConnectionFactory();
factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down");
var config = ClusterConfig("host-a", "host-b"); // 60s cooldown
using var ds = new HistorianDataSource(config, factory);
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
.GetAwaiter().GetResult();
factory.ConnectHistory.Clear();
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
.GetAwaiter().GetResult();
// Both nodes are in cooldown → picker returns empty → factory is not called at all.
results.Count.ShouldBe(0);
factory.ConnectHistory.ShouldBeEmpty();
}
[Fact]
public void Connect_SingleNodeConfig_BehavesLikeLegacy()
{
var factory = new FakeHistorianConnectionFactory();
var config = new HistorianConfiguration
{
Enabled = true,
ServerName = "legacy-host",
Port = 32568,
FailureCooldownSeconds = 0
};
using var ds = new HistorianDataSource(config, factory);
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
.GetAwaiter().GetResult();
factory.ConnectHistory.ShouldBe(new[] { "legacy-host" });
var snap = ds.GetHealthSnapshot();
snap.NodeCount.ShouldBe(1);
snap.Nodes.Single().Name.ShouldBe("legacy-host");
}
[Fact]
public void Connect_PickerOrderRespected()
{
var factory = new FakeHistorianConnectionFactory();
factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down");
factory.ServerBehaviors["host-b"] = new InvalidOperationException("B down");
factory.ServerBehaviors["host-c"] = new InvalidOperationException("C down");
var config = ClusterConfig("host-a", "host-b", "host-c");
using var ds = new HistorianDataSource(config, factory);
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
.GetAwaiter().GetResult();
// Candidates are tried in configuration order.
factory.ConnectHistory.ShouldBe(new[] { "host-a", "host-b", "host-c" });
}
[Fact]
public void Connect_SharedPickerAcrossProcessAndEventSilos()
{
// Process path tries host-a, fails, then tries host-b. host-a is in cooldown. When
// the event path subsequently starts with a 0s cooldown, the picker state is shared:
// host-a is still marked failed (via its cooldown window) at the moment the event
// silo asks. The event path therefore must not retry host-a.
var factory = new FakeHistorianConnectionFactory();
factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down");
var config = ClusterConfig("host-a", "host-b");
using var ds = new HistorianDataSource(config, factory);
// Process path: host-a fails → host-b reached (then torn down mid-query via the fake).
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
.GetAwaiter().GetResult();
// At this point host-a and host-b are both in cooldown. ReadEvents will hit the
// picker's empty-healthy-list path and return empty without calling the factory.
factory.ConnectHistory.Clear();
var events = ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
.GetAwaiter().GetResult();
events.Count.ShouldBe(0);
factory.ConnectHistory.ShouldBeEmpty();
// Critical assertion: host-a was NOT retried by the event silo — it's in the
// shared cooldown from the process path's failure.
factory.ConnectHistory.ShouldNotContain("host-a");
}
}
}

View File

@@ -1,281 +0,0 @@
using System;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Historian.Aveva;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Historian;
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests
{
/// <summary>
/// Verifies Historian data source lifecycle behavior: dispose safety,
/// post-dispose rejection, connection failure handling, and reconnect-after-error.
/// </summary>
public class HistorianDataSourceLifecycleTests
{
private static HistorianConfiguration DefaultConfig => new()
{
Enabled = true,
ServerName = "test-historian",
Port = 32568,
IntegratedSecurity = true,
CommandTimeoutSeconds = 5,
// Zero cooldown so reconnect-after-error tests can retry through the cluster picker
// on the very next call, matching the pre-cluster behavior they were written against.
FailureCooldownSeconds = 0
};
[Fact]
public void ReadRawAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult());
}
[Fact]
public void ReadAggregateAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average")
.GetAwaiter().GetResult());
}
[Fact]
public void ReadAtTimeAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadAtTimeAsync("Tag1", new[] { DateTime.UtcNow })
.GetAwaiter().GetResult());
}
[Fact]
public void ReadEventsAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult());
}
[Fact]
public void Dispose_CalledTwice_DoesNotThrow()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.NotThrow(() => ds.Dispose());
}
[Fact]
public void HistorianAggregateMap_UnknownColumn_ReturnsNull()
{
HistorianAggregateMap.MapAggregateToColumn(new Opc.Ua.NodeId(99999)).ShouldBeNull();
}
[Fact]
public void ReadRawAsync_WhenConnectionFails_ReturnsEmptyResults()
{
var factory = new FakeHistorianConnectionFactory
{
ConnectException = new InvalidOperationException("Connection refused")
};
var ds = new HistorianDataSource(DefaultConfig, factory);
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
results.Count.ShouldBe(0);
factory.ConnectCallCount.ShouldBe(1);
}
[Fact]
public void ReadRawAsync_WhenConnectionTimesOut_ReturnsEmptyResults()
{
var factory = new FakeHistorianConnectionFactory
{
ConnectException = new TimeoutException("Connection timed out")
};
var ds = new HistorianDataSource(DefaultConfig, factory);
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
results.Count.ShouldBe(0);
}
[Fact]
public void ReadRawAsync_AfterConnectionError_AttemptsReconnect()
{
var factory = new FakeHistorianConnectionFactory();
var ds = new HistorianDataSource(DefaultConfig, factory);
// First call: factory returns a HistorianAccess that isn't actually connected,
// so the query will fail and HandleConnectionError will reset the connection.
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
// Second call: should attempt reconnection via the factory
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
// Factory should have been called twice — once for initial connect, once for reconnect
factory.ConnectCallCount.ShouldBe(2);
}
[Fact]
public void ReadRawAsync_ConnectionFailure_DoesNotCorruptState()
{
var callCount = 0;
var factory = new FakeHistorianConnectionFactory
{
OnConnect = count =>
{
callCount = count;
if (count == 1)
throw new InvalidOperationException("First connection fails");
// Second call succeeds (returns unconnected HistorianAccess, but that's OK for lifecycle testing)
}
};
var ds = new HistorianDataSource(DefaultConfig, factory);
// First read: connection fails
var r1 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
r1.Count.ShouldBe(0);
// Second read: should attempt new connection without throwing from internal state corruption
var r2 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
callCount.ShouldBe(2);
}
[Fact]
public void Dispose_DuringConnectionFailure_DoesNotThrow()
{
var factory = new FakeHistorianConnectionFactory
{
ConnectException = new InvalidOperationException("Connection refused")
};
var ds = new HistorianDataSource(DefaultConfig, factory);
// Trigger a failed connection attempt
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
// Dispose should handle the null connection gracefully
Should.NotThrow(() => ds.Dispose());
}
// ---------- HistorianHealthSnapshot instrumentation ----------
[Fact]
public void GetHealthSnapshot_FreshDataSource_ReportsZeroCounters()
{
var ds = new HistorianDataSource(DefaultConfig, new FakeHistorianConnectionFactory());
var snap = ds.GetHealthSnapshot();
snap.TotalQueries.ShouldBe(0);
snap.TotalSuccesses.ShouldBe(0);
snap.TotalFailures.ShouldBe(0);
snap.ConsecutiveFailures.ShouldBe(0);
snap.LastSuccessTime.ShouldBeNull();
snap.LastFailureTime.ShouldBeNull();
snap.LastError.ShouldBeNull();
snap.ProcessConnectionOpen.ShouldBeFalse();
snap.EventConnectionOpen.ShouldBeFalse();
}
[Fact]
public void GetHealthSnapshot_AfterConnectionFailure_RecordsFailure()
{
var factory = new FakeHistorianConnectionFactory
{
ConnectException = new InvalidOperationException("Connection refused")
};
var ds = new HistorianDataSource(DefaultConfig, factory);
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
var snap = ds.GetHealthSnapshot();
snap.TotalQueries.ShouldBe(1);
snap.TotalFailures.ShouldBe(1);
snap.TotalSuccesses.ShouldBe(0);
snap.ConsecutiveFailures.ShouldBe(1);
snap.LastFailureTime.ShouldNotBeNull();
snap.LastError.ShouldContain("Connection refused");
snap.ProcessConnectionOpen.ShouldBeFalse();
}
[Fact]
public void GetHealthSnapshot_AfterMultipleFailures_IncrementsConsecutive()
{
var factory = new FakeHistorianConnectionFactory
{
ConnectException = new InvalidOperationException("boom")
};
var ds = new HistorianDataSource(DefaultConfig, factory);
for (var i = 0; i < 4; i++)
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult();
var snap = ds.GetHealthSnapshot();
snap.TotalFailures.ShouldBe(4);
snap.ConsecutiveFailures.ShouldBe(4);
snap.TotalSuccesses.ShouldBe(0);
}
[Fact]
public void GetHealthSnapshot_AcrossReadPaths_CountsAllFailures()
{
var factory = new FakeHistorianConnectionFactory
{
ConnectException = new InvalidOperationException("sdk down")
};
var ds = new HistorianDataSource(DefaultConfig, factory);
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
.GetAwaiter().GetResult();
ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average")
.GetAwaiter().GetResult();
ds.ReadAtTimeAsync("Tag1", new[] { DateTime.UtcNow })
.GetAwaiter().GetResult();
ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
.GetAwaiter().GetResult();
var snap = ds.GetHealthSnapshot();
snap.TotalFailures.ShouldBe(4);
snap.TotalQueries.ShouldBe(4);
snap.LastError.ShouldContain("sdk down");
}
[Fact]
public void GetHealthSnapshot_ErrorMessageCarriesReadPath()
{
var factory = new FakeHistorianConnectionFactory
{
ConnectException = new InvalidOperationException("unreachable")
};
var ds = new HistorianDataSource(DefaultConfig, factory);
ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average")
.GetAwaiter().GetResult();
var snap = ds.GetHealthSnapshot();
snap.LastError.ShouldStartWith("aggregate:");
}
}
}

View File

@@ -1,41 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.2.1"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Historian.Aveva\ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
</ItemGroup>
<ItemGroup>
<!-- Tests reference aahClientManaged so the FakeHistorianConnectionFactory can
implement the SDK-typed IHistorianConnectionFactory interface. -->
<Reference Include="aahClientManaged">
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="aahClientCommon">
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
</Project>

View File

@@ -1,129 +0,0 @@
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
namespace ZB.MOM.WW.OtOpcUa.IntegrationTests
{
/// <summary>
/// Integration tests that exercise the real Galaxy repository queries against the test database configuration.
/// </summary>
public class GalaxyRepositoryServiceTests
{
/// <summary>
/// Loads repository configuration from the integration test settings and controls whether extended attributes are
/// enabled.
/// </summary>
/// <param name="extendedAttributes">A value indicating whether the extended attribute query path should be enabled.</param>
/// <returns>The repository configuration used by the integration test.</returns>
private static GalaxyRepositoryConfiguration LoadConfig(bool extendedAttributes = false)
{
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.test.json", false)
.Build();
var config = new GalaxyRepositoryConfiguration();
configuration.GetSection("GalaxyRepository").Bind(config);
config.ExtendedAttributes = extendedAttributes;
return config;
}
/// <summary>
/// Confirms that the standard attribute query returns rows from the repository.
/// </summary>
[Fact]
public async Task GetAttributesAsync_StandardMode_ReturnsRows()
{
var config = LoadConfig(false);
var service = new GalaxyRepositoryService(config);
var results = await service.GetAttributesAsync();
results.ShouldNotBeEmpty();
// Standard mode: PrimitiveName and AttributeSource should be empty
results.ShouldAllBe(r => r.PrimitiveName == "" && r.AttributeSource == "");
}
/// <summary>
/// Confirms that the extended attribute query returns more rows than the standard query path.
/// </summary>
[Fact]
public async Task GetAttributesAsync_ExtendedMode_ReturnsMoreRows()
{
var standardConfig = LoadConfig(false);
var extendedConfig = LoadConfig(true);
var standardService = new GalaxyRepositoryService(standardConfig);
var extendedService = new GalaxyRepositoryService(extendedConfig);
var standardResults = await standardService.GetAttributesAsync();
var extendedResults = await extendedService.GetAttributesAsync();
extendedResults.Count.ShouldBeGreaterThan(standardResults.Count);
}
/// <summary>
/// Confirms that the extended attribute query includes both primitive and dynamic attribute sources.
/// </summary>
[Fact]
public async Task GetAttributesAsync_ExtendedMode_IncludesPrimitiveAttributes()
{
var config = LoadConfig(true);
var service = new GalaxyRepositoryService(config);
var results = await service.GetAttributesAsync();
results.ShouldContain(r => r.AttributeSource == "primitive");
results.ShouldContain(r => r.AttributeSource == "dynamic");
}
/// <summary>
/// Confirms that extended mode populates attribute-source metadata across the result set.
/// </summary>
[Fact]
public async Task GetAttributesAsync_ExtendedMode_PrimitiveNamePopulated()
{
var config = LoadConfig(true);
var service = new GalaxyRepositoryService(config);
var results = await service.GetAttributesAsync();
// Some primitive attributes have non-empty primitive names
// (though many have empty primitive_name for the root UDO)
results.ShouldNotBeEmpty();
// All should have an attribute source
results.ShouldAllBe(r => r.AttributeSource == "primitive" || r.AttributeSource == "dynamic");
}
/// <summary>
/// Confirms that standard-mode results always include fully qualified tag references.
/// </summary>
[Fact]
public async Task GetAttributesAsync_StandardMode_AllHaveFullTagReference()
{
var config = LoadConfig(false);
var service = new GalaxyRepositoryService(config);
var results = await service.GetAttributesAsync();
results.ShouldAllBe(r => !string.IsNullOrEmpty(r.FullTagReference));
results.ShouldAllBe(r => r.FullTagReference.Contains("."));
}
/// <summary>
/// Confirms that extended-mode results always include fully qualified tag references.
/// </summary>
[Fact]
public async Task GetAttributesAsync_ExtendedMode_AllHaveFullTagReference()
{
var config = LoadConfig(true);
var service = new GalaxyRepositoryService(config);
var results = await service.GetAttributesAsync();
results.ShouldAllBe(r => !string.IsNullOrEmpty(r.FullTagReference));
results.ShouldAllBe(r => r.FullTagReference.Contains("."));
}
}
}

View File

@@ -1,45 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!--
Phase 2 Stream D — V1 ARCHIVE. References v1 OtOpcUa.Host directly.
Excluded from `dotnet test` solution runs; replaced by the v2
OtOpcUa.Driver.Galaxy.E2E suite. To run explicitly:
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
See docs/v2/V1_ARCHIVE_STATUS.md.
-->
<IsTestProject>false</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.2.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
</ItemGroup>
<ItemGroup>
<None Update="appsettings.test.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="xunit.runner.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -1,5 +0,0 @@
{
"GalaxyRepository": {
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;"
}
}

View File

@@ -1,4 +0,0 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false
}

View File

@@ -1,231 +0,0 @@
using System;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Tests.Authentication
{
public class UserAuthenticationTests
{
[Fact]
public void AuthenticationConfiguration_Defaults()
{
var config = new AuthenticationConfiguration();
config.AllowAnonymous.ShouldBeTrue();
config.AnonymousCanWrite.ShouldBeTrue();
}
[Fact]
public void AuthenticationConfiguration_LdapDefaults()
{
var config = new AuthenticationConfiguration();
config.Ldap.ShouldNotBeNull();
config.Ldap.Enabled.ShouldBeFalse();
config.Ldap.Host.ShouldBe("localhost");
config.Ldap.Port.ShouldBe(3893);
config.Ldap.BaseDN.ShouldBe("dc=lmxopcua,dc=local");
config.Ldap.ReadOnlyGroup.ShouldBe("ReadOnly");
config.Ldap.WriteOperateGroup.ShouldBe("WriteOperate");
config.Ldap.WriteTuneGroup.ShouldBe("WriteTune");
config.Ldap.WriteConfigureGroup.ShouldBe("WriteConfigure");
config.Ldap.AlarmAckGroup.ShouldBe("AlarmAck");
config.Ldap.TimeoutSeconds.ShouldBe(5);
}
[Fact]
public void LdapConfiguration_BindDnTemplate_Default()
{
var config = new LdapConfiguration();
config.BindDnTemplate.ShouldBe("cn={username},dc=lmxopcua,dc=local");
}
[Fact]
public void LdapAuthenticationProvider_ValidBind_ReturnsTrue()
{
// This test requires GLAuth running on localhost:3893
// Skip if not available
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue();
}
catch (Exception)
{
// GLAuth not running - skip gracefully
}
}
[Fact]
public void LdapAuthenticationProvider_InvalidPassword_ReturnsFalse()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("readonly", "wrongpassword").ShouldBeFalse();
}
catch (Exception)
{
}
}
[Fact]
public void LdapAuthenticationProvider_UnknownUser_ReturnsFalse()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("nonexistent", "anything").ShouldBeFalse();
}
catch (Exception)
{
}
}
[Fact]
public void LdapAuthenticationProvider_ReadOnlyUser_HasReadOnlyRole()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue();
var roles = provider.GetUserRoles("readonly");
roles.ShouldContain("ReadOnly");
roles.ShouldNotContain("WriteOperate");
roles.ShouldNotContain("AlarmAck");
}
catch (Exception)
{
}
}
[Fact]
public void LdapAuthenticationProvider_WriteOperateUser_HasWriteOperateRole()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("writeop", "writeop123").ShouldBeTrue();
var roles = provider.GetUserRoles("writeop");
roles.ShouldContain("WriteOperate");
roles.ShouldNotContain("AlarmAck");
}
catch (Exception)
{
}
}
[Fact]
public void LdapAuthenticationProvider_AlarmAckUser_HasAlarmAckRole()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("alarmack", "alarmack123").ShouldBeTrue();
var roles = provider.GetUserRoles("alarmack");
roles.ShouldContain("AlarmAck");
roles.ShouldNotContain("WriteOperate");
}
catch (Exception)
{
}
}
[Fact]
public void LdapAuthenticationProvider_AdminUser_HasAllRoles()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("admin", "admin123").ShouldBeTrue();
var roles = provider.GetUserRoles("admin");
roles.ShouldContain("ReadOnly");
roles.ShouldContain("WriteOperate");
roles.ShouldContain("WriteTune");
roles.ShouldContain("WriteConfigure");
roles.ShouldContain("AlarmAck");
}
catch (Exception)
{
}
}
[Fact]
public void LdapAuthenticationProvider_ImplementsIRoleProvider()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
(provider is IRoleProvider).ShouldBeTrue();
}
[Fact]
public void LdapAuthenticationProvider_ConnectionFailure_ReturnsFalse()
{
var ldapConfig = new LdapConfiguration
{
Enabled = true,
Host = "localhost",
Port = 19999, // no server here
TimeoutSeconds = 1
};
var provider = new LdapAuthenticationProvider(ldapConfig);
provider.ValidateCredentials("anyone", "anything").ShouldBeFalse();
}
[Fact]
public void LdapAuthenticationProvider_ConnectionFailure_GetUserRoles_FallsBackToReadOnly()
{
var ldapConfig = new LdapConfiguration
{
Enabled = true,
Host = "localhost",
Port = 19999, // no server here
TimeoutSeconds = 1,
ServiceAccountDn = "cn=svc,dc=test",
ServiceAccountPassword = "test"
};
var provider = new LdapAuthenticationProvider(ldapConfig);
var roles = provider.GetUserRoles("anyone");
roles.ShouldContain("ReadOnly");
}
private static LdapConfiguration CreateGlAuthConfig()
{
return new LdapConfiguration
{
Enabled = true,
Host = "localhost",
Port = 3893,
BaseDN = "dc=lmxopcua,dc=local",
BindDnTemplate = "cn={username},dc=lmxopcua,dc=local",
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
ServiceAccountPassword = "serviceaccount123",
TimeoutSeconds = 5,
ReadOnlyGroup = "ReadOnly",
WriteOperateGroup = "WriteOperate",
WriteTuneGroup = "WriteTune",
WriteConfigureGroup = "WriteConfigure",
AlarmAckGroup = "AlarmAck"
};
}
}
}

View File

@@ -1,427 +0,0 @@
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Tests.Configuration
{
/// <summary>
/// Verifies that application configuration binds correctly from appsettings and that validation catches invalid bridge
/// settings.
/// </summary>
public class ConfigurationLoadingTests
{
/// <summary>
/// Loads the application configuration from the repository appsettings file for binding tests.
/// </summary>
/// <returns>The bound application configuration snapshot.</returns>
private static AppConfiguration LoadFromJson()
{
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", false)
.Build();
var config = new AppConfiguration();
configuration.GetSection("OpcUa").Bind(config.OpcUa);
configuration.GetSection("MxAccess").Bind(config.MxAccess);
configuration.GetSection("GalaxyRepository").Bind(config.GalaxyRepository);
configuration.GetSection("Dashboard").Bind(config.Dashboard);
configuration.GetSection("Security").Bind(config.Security);
configuration.GetSection("Historian").Bind(config.Historian);
return config;
}
/// <summary>
/// Confirms that the OPC UA section binds the endpoint and session settings expected by the bridge.
/// </summary>
[Fact]
public void OpcUa_Section_BindsCorrectly()
{
var config = LoadFromJson();
config.OpcUa.BindAddress.ShouldBe("0.0.0.0");
config.OpcUa.Port.ShouldBe(4840);
config.OpcUa.EndpointPath.ShouldBe("/LmxOpcUa");
config.OpcUa.ServerName.ShouldBe("LmxOpcUa");
config.OpcUa.GalaxyName.ShouldBe("ZB");
config.OpcUa.MaxSessions.ShouldBe(100);
config.OpcUa.SessionTimeoutMinutes.ShouldBe(30);
}
/// <summary>
/// Confirms that the MXAccess section binds runtime timeout and reconnect settings correctly.
/// </summary>
[Fact]
public void MxAccess_Section_BindsCorrectly()
{
var config = LoadFromJson();
config.MxAccess.ClientName.ShouldBe("LmxOpcUa");
config.MxAccess.ReadTimeoutSeconds.ShouldBe(5);
config.MxAccess.WriteTimeoutSeconds.ShouldBe(5);
config.MxAccess.MaxConcurrentOperations.ShouldBe(10);
config.MxAccess.MonitorIntervalSeconds.ShouldBe(5);
config.MxAccess.AutoReconnect.ShouldBe(true);
config.MxAccess.ProbeStaleThresholdSeconds.ShouldBe(60);
}
/// <summary>
/// Confirms that the Galaxy repository section binds connection and polling settings correctly.
/// </summary>
[Fact]
public void GalaxyRepository_Section_BindsCorrectly()
{
var config = LoadFromJson();
config.GalaxyRepository.ConnectionString.ShouldContain("ZB");
config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30);
config.GalaxyRepository.CommandTimeoutSeconds.ShouldBe(30);
config.GalaxyRepository.ExtendedAttributes.ShouldBe(false);
}
/// <summary>
/// Confirms that extended-attribute loading defaults to disabled when not configured.
/// </summary>
[Fact]
public void GalaxyRepository_ExtendedAttributes_DefaultsFalse()
{
var config = new GalaxyRepositoryConfiguration();
config.ExtendedAttributes.ShouldBe(false);
}
/// <summary>
/// Confirms that the extended-attribute flag can be enabled through configuration binding.
/// </summary>
[Fact]
public void GalaxyRepository_ExtendedAttributes_BindsFromJson()
{
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", false)
.AddInMemoryCollection(new[]
{ new KeyValuePair<string, string>("GalaxyRepository:ExtendedAttributes", "true") })
.Build();
var config = new GalaxyRepositoryConfiguration();
configuration.GetSection("GalaxyRepository").Bind(config);
config.ExtendedAttributes.ShouldBe(true);
}
/// <summary>
/// Confirms that the dashboard section binds operator-dashboard settings correctly.
/// </summary>
[Fact]
public void Dashboard_Section_BindsCorrectly()
{
var config = LoadFromJson();
config.Dashboard.Enabled.ShouldBe(true);
config.Dashboard.Port.ShouldBe(8081);
config.Dashboard.RefreshIntervalSeconds.ShouldBe(10);
}
/// <summary>
/// Confirms that the default configuration objects start with the expected bridge defaults.
/// </summary>
[Fact]
public void DefaultValues_AreCorrect()
{
var config = new AppConfiguration();
config.OpcUa.BindAddress.ShouldBe("0.0.0.0");
config.OpcUa.Port.ShouldBe(4840);
config.MxAccess.ClientName.ShouldBe("LmxOpcUa");
config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30);
config.Dashboard.Enabled.ShouldBe(true);
}
/// <summary>
/// Confirms that BindAddress can be overridden to a specific hostname or IP.
/// </summary>
[Fact]
public void OpcUa_BindAddress_CanBeOverridden()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("OpcUa:BindAddress", "localhost")
})
.Build();
var config = new OpcUaConfiguration();
configuration.GetSection("OpcUa").Bind(config);
config.BindAddress.ShouldBe("localhost");
}
/// <summary>
/// Confirms that a valid configuration passes startup validation.
/// </summary>
[Fact]
public void Validator_ValidConfig_ReturnsTrue()
{
var config = LoadFromJson();
ConfigurationValidator.ValidateAndLog(config).ShouldBe(true);
}
/// <summary>
/// Confirms that an invalid OPC UA port is rejected by startup validation.
/// </summary>
[Fact]
public void Validator_InvalidPort_ReturnsFalse()
{
var config = new AppConfiguration();
config.OpcUa.Port = 0;
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
/// <summary>
/// Confirms that an empty Galaxy name is rejected because the bridge requires a namespace target.
/// </summary>
[Fact]
public void Validator_EmptyGalaxyName_ReturnsFalse()
{
var config = new AppConfiguration();
config.OpcUa.GalaxyName = "";
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
/// <summary>
/// Confirms that the Security section binds profile list from appsettings.json.
/// </summary>
[Fact]
public void Security_Section_BindsProfilesCorrectly()
{
var config = LoadFromJson();
config.Security.Profiles.ShouldContain("None");
config.Security.AutoAcceptClientCertificates.ShouldBe(true);
config.Security.MinimumCertificateKeySize.ShouldBe(2048);
}
/// <summary>
/// Stability review 2026-04-13 Finding 3: MxAccess.RequestTimeoutSeconds must be at
/// least 1. Zero or negative values disable the safety bound and are rejected.
/// </summary>
[Fact]
public void Validator_MxAccessRequestTimeoutZero_ReturnsFalse()
{
var config = LoadFromJson();
config.MxAccess.RequestTimeoutSeconds = 0;
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
/// <summary>
/// Stability review 2026-04-13 Finding 3: Historian.RequestTimeoutSeconds must be at
/// least 1 when historian is enabled.
/// </summary>
[Fact]
public void Validator_HistorianRequestTimeoutZero_ReturnsFalse()
{
var config = LoadFromJson();
config.Historian.Enabled = true;
config.Historian.ServerName = "localhost";
config.Historian.RequestTimeoutSeconds = 0;
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
/// <summary>
/// Confirms the bound AppConfiguration carries non-zero default request timeouts.
/// </summary>
[Fact]
public void Validator_DefaultRequestTimeouts_AreSensible()
{
var config = new AppConfiguration();
config.MxAccess.RequestTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1);
config.Historian.RequestTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1);
}
/// <summary>
/// Confirms that a minimum key size below 2048 is rejected by the validator.
/// </summary>
[Fact]
public void Validator_InvalidMinKeySize_ReturnsFalse()
{
var config = new AppConfiguration();
config.Security.MinimumCertificateKeySize = 1024;
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
/// <summary>
/// Confirms that a valid configuration with security defaults passes validation.
/// </summary>
[Fact]
public void Validator_DefaultSecurityConfig_ReturnsTrue()
{
var config = LoadFromJson();
ConfigurationValidator.ValidateAndLog(config).ShouldBe(true);
}
/// <summary>
/// Confirms that custom security profiles can be bound from in-memory configuration.
/// </summary>
[Fact]
public void Security_Section_BindsCustomProfiles()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("Security:Profiles:0", "None"),
new KeyValuePair<string, string>("Security:Profiles:1", "Basic256Sha256-SignAndEncrypt"),
new KeyValuePair<string, string>("Security:AutoAcceptClientCertificates", "false"),
new KeyValuePair<string, string>("Security:MinimumCertificateKeySize", "4096")
})
.Build();
// Clear default list before binding to match production behavior
var config = new AppConfiguration();
config.Security.Profiles.Clear();
configuration.GetSection("Security").Bind(config.Security);
config.Security.Profiles.Count.ShouldBe(2);
config.Security.Profiles.ShouldContain("None");
config.Security.Profiles.ShouldContain("Basic256Sha256-SignAndEncrypt");
config.Security.AutoAcceptClientCertificates.ShouldBe(false);
config.Security.MinimumCertificateKeySize.ShouldBe(4096);
}
[Fact]
public void Redundancy_Section_BindsFromJson()
{
var config = LoadFromJson();
config.Redundancy.Enabled.ShouldBe(false);
config.Redundancy.Mode.ShouldBe("Warm");
config.Redundancy.Role.ShouldBe("Primary");
config.Redundancy.ServiceLevelBase.ShouldBe(200);
}
[Fact]
public void Redundancy_Section_BindsCustomValues()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("Redundancy:Enabled", "true"),
new KeyValuePair<string, string>("Redundancy:Mode", "Hot"),
new KeyValuePair<string, string>("Redundancy:Role", "Secondary"),
new KeyValuePair<string, string>("Redundancy:ServiceLevelBase", "180"),
new KeyValuePair<string, string>("Redundancy:ServerUris:0", "urn:a"),
new KeyValuePair<string, string>("Redundancy:ServerUris:1", "urn:b")
})
.Build();
var config = new AppConfiguration();
configuration.GetSection("Redundancy").Bind(config.Redundancy);
config.Redundancy.Enabled.ShouldBe(true);
config.Redundancy.Mode.ShouldBe("Hot");
config.Redundancy.Role.ShouldBe("Secondary");
config.Redundancy.ServiceLevelBase.ShouldBe(180);
config.Redundancy.ServerUris.Count.ShouldBe(2);
}
[Fact]
public void Validator_RedundancyEnabled_NoApplicationUri_ReturnsFalse()
{
var config = new AppConfiguration();
config.Redundancy.Enabled = true;
config.Redundancy.ServerUris.Add("urn:a");
config.Redundancy.ServerUris.Add("urn:b");
// OpcUa.ApplicationUri is null
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
[Fact]
public void Validator_InvalidServiceLevelBase_ReturnsFalse()
{
var config = new AppConfiguration();
config.Redundancy.ServiceLevelBase = 0;
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
[Fact]
public void OpcUa_ApplicationUri_DefaultsToNull()
{
var config = new OpcUaConfiguration();
config.ApplicationUri.ShouldBeNull();
}
[Fact]
public void OpcUa_ApplicationUri_BindsFromConfig()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("OpcUa:ApplicationUri", "urn:test:app")
})
.Build();
var config = new OpcUaConfiguration();
configuration.GetSection("OpcUa").Bind(config);
config.ApplicationUri.ShouldBe("urn:test:app");
}
[Fact]
public void Historian_Section_BindsFromJson()
{
var config = LoadFromJson();
config.Historian.Enabled.ShouldBe(false);
config.Historian.ServerName.ShouldBe("localhost");
config.Historian.IntegratedSecurity.ShouldBe(true);
config.Historian.Port.ShouldBe(32568);
config.Historian.CommandTimeoutSeconds.ShouldBe(30);
config.Historian.MaxValuesPerRead.ShouldBe(10000);
}
[Fact]
public void Historian_Section_BindsCustomValues()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("Historian:Enabled", "true"),
new KeyValuePair<string, string>("Historian:ServerName", "historian-server"),
new KeyValuePair<string, string>("Historian:IntegratedSecurity", "false"),
new KeyValuePair<string, string>("Historian:UserName", "testuser"),
new KeyValuePair<string, string>("Historian:Password", "testpass"),
new KeyValuePair<string, string>("Historian:Port", "12345"),
new KeyValuePair<string, string>("Historian:CommandTimeoutSeconds", "60"),
new KeyValuePair<string, string>("Historian:MaxValuesPerRead", "5000")
})
.Build();
var config = new HistorianConfiguration();
configuration.GetSection("Historian").Bind(config);
config.Enabled.ShouldBe(true);
config.ServerName.ShouldBe("historian-server");
config.IntegratedSecurity.ShouldBe(false);
config.UserName.ShouldBe("testuser");
config.Password.ShouldBe("testpass");
config.Port.ShouldBe(12345);
config.CommandTimeoutSeconds.ShouldBe(60);
config.MaxValuesPerRead.ShouldBe(5000);
}
[Fact]
public void Validator_HistorianEnabled_EmptyServerName_ReturnsFalse()
{
var config = new AppConfiguration();
config.Historian.Enabled = true;
config.Historian.ServerName = "";
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
[Fact]
public void Validator_HistorianEnabled_InvalidPort_ReturnsFalse()
{
var config = new AppConfiguration();
config.Historian.Enabled = true;
config.Historian.Port = 0;
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
[Fact]
public void Validator_HistorianEnabled_NoIntegratedSecurity_EmptyUserName_ReturnsFalse()
{
var config = new AppConfiguration();
config.Historian.Enabled = true;
config.Historian.IntegratedSecurity = false;
config.Historian.UserName = "";
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
}
}

View File

@@ -1,65 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Tests.Configuration
{
public class HistorianConfigurationTests
{
[Fact]
public void DefaultConfig_Disabled()
{
var config = new HistorianConfiguration();
config.Enabled.ShouldBe(false);
}
[Fact]
public void DefaultConfig_ServerNameLocalhost()
{
var config = new HistorianConfiguration();
config.ServerName.ShouldBe("localhost");
}
[Fact]
public void DefaultConfig_IntegratedSecurityTrue()
{
var config = new HistorianConfiguration();
config.IntegratedSecurity.ShouldBe(true);
}
[Fact]
public void DefaultConfig_UserNameNull()
{
var config = new HistorianConfiguration();
config.UserName.ShouldBeNull();
}
[Fact]
public void DefaultConfig_PasswordNull()
{
var config = new HistorianConfiguration();
config.Password.ShouldBeNull();
}
[Fact]
public void DefaultConfig_Port32568()
{
var config = new HistorianConfiguration();
config.Port.ShouldBe(32568);
}
[Fact]
public void DefaultConfig_CommandTimeout30()
{
var config = new HistorianConfiguration();
config.CommandTimeoutSeconds.ShouldBe(30);
}
[Fact]
public void DefaultConfig_MaxValuesPerRead10000()
{
var config = new HistorianConfiguration();
config.MaxValuesPerRead.ShouldBe(10000);
}
}
}

View File

@@ -1,416 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
{
/// <summary>
/// Exhaustive coverage of the template-based alarm object filter's pattern parsing,
/// chain matching, and hierarchy-subtree propagation logic.
/// </summary>
public class AlarmObjectFilterTests
{
// ---------- Pattern parsing & compilation ----------
[Fact]
public void EmptyConfig_DisablesFilter()
{
var sut = new AlarmObjectFilter(new AlarmFilterConfiguration());
sut.Enabled.ShouldBeFalse();
sut.PatternCount.ShouldBe(0);
sut.ResolveIncludedObjects(SingleObject()).ShouldBeNull();
}
[Fact]
public void NullConfig_DisablesFilter()
{
var sut = new AlarmObjectFilter(null);
sut.Enabled.ShouldBeFalse();
sut.ResolveIncludedObjects(SingleObject()).ShouldBeNull();
}
[Fact]
public void WhitespaceEntries_AreSkipped()
{
var sut = new AlarmObjectFilter(Config("", " ", "\t"));
sut.Enabled.ShouldBeFalse();
sut.PatternCount.ShouldBe(0);
}
[Fact]
public void CommaSeparatedEntry_SplitsIntoMultiplePatterns()
{
var sut = new AlarmObjectFilter(Config("TestMachine*, Pump_*"));
sut.Enabled.ShouldBeTrue();
sut.PatternCount.ShouldBe(2);
}
[Fact]
public void CommaAndListForms_Combine()
{
var sut = new AlarmObjectFilter(Config("A*, B*", "C*"));
sut.PatternCount.ShouldBe(3);
}
[Fact]
public void WhitespaceAroundCommas_IsTrimmed()
{
var sut = new AlarmObjectFilter(Config(" TestMachine* , Pump_* "));
sut.PatternCount.ShouldBe(2);
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "Pump_A" }).ShouldBeTrue();
}
[Fact]
public void LiteralPattern_MatchesExactTemplate()
{
var sut = new AlarmObjectFilter(Config("TestMachine"));
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "TestMachine_001" }).ShouldBeFalse();
sut.MatchesTemplateChain(new List<string> { "OtherMachine" }).ShouldBeFalse();
}
[Fact]
public void StarAlonePattern_MatchesAnyNonEmptyChain()
{
var sut = new AlarmObjectFilter(Config("*"));
sut.MatchesTemplateChain(new List<string> { "Foo" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "Bar", "Baz" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string>()).ShouldBeFalse();
}
[Fact]
public void PrefixWildcard_MatchesSuffix()
{
var sut = new AlarmObjectFilter(Config("*Machine"));
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "BigMachine" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "MachineThing" }).ShouldBeFalse();
}
[Fact]
public void SuffixWildcard_MatchesPrefix()
{
var sut = new AlarmObjectFilter(Config("Test*"));
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "TestFoo" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "Machine" }).ShouldBeFalse();
}
[Fact]
public void BothWildcards_MatchesContains()
{
var sut = new AlarmObjectFilter(Config("*Machine*"));
sut.MatchesTemplateChain(new List<string> { "TestMachineWidget" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "Machine" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "Pump" }).ShouldBeFalse();
}
[Fact]
public void MiddleWildcard_MatchesWithInnerAnything()
{
var sut = new AlarmObjectFilter(Config("Test*Machine"));
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "TestCoolMachine" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "TestMachineX" }).ShouldBeFalse();
}
[Fact]
public void RegexMetacharacters_AreEscapedLiterally()
{
// The '.' in Pump.v2 is a regex metachar; it must be a literal dot.
var sut = new AlarmObjectFilter(Config("Pump.v2"));
sut.MatchesTemplateChain(new List<string> { "Pump.v2" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "PumpXv2" }).ShouldBeFalse();
}
[Fact]
public void Matching_IsCaseInsensitive()
{
var sut = new AlarmObjectFilter(Config("testmachine*"));
sut.MatchesTemplateChain(new List<string> { "TestMachine_001" }).ShouldBeTrue();
sut.MatchesTemplateChain(new List<string> { "TESTMACHINE_XYZ" }).ShouldBeTrue();
}
[Fact]
public void GalaxyDollarPrefix_IsNormalizedAway_OnBothSides()
{
var sut = new AlarmObjectFilter(Config("TestMachine*"));
sut.MatchesTemplateChain(new List<string> { "$TestMachine" }).ShouldBeTrue();
var withDollarInPattern = new AlarmObjectFilter(Config("$TestMachine*"));
withDollarInPattern.MatchesTemplateChain(new List<string> { "$TestMachine" }).ShouldBeTrue();
withDollarInPattern.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
}
// ---------- Template-chain matching ----------
[Fact]
public void ChainMatch_AtAncestorPosition_StillMatches()
{
var sut = new AlarmObjectFilter(Config("TestMachine"));
var chain = new List<string> { "TestCoolMachine", "TestMachine", "$UserDefined" };
sut.MatchesTemplateChain(chain).ShouldBeTrue();
}
[Fact]
public void ChainNoMatch_ReturnsFalse()
{
var sut = new AlarmObjectFilter(Config("TestMachine*"));
var chain = new List<string> { "FooBar", "$UserDefined" };
sut.MatchesTemplateChain(chain).ShouldBeFalse();
}
[Fact]
public void EmptyChain_NeverMatchesNonWildcard()
{
var sut = new AlarmObjectFilter(Config("TestMachine*"));
sut.MatchesTemplateChain(new List<string>()).ShouldBeFalse();
}
[Fact]
public void NullChain_NeverMatches()
{
var sut = new AlarmObjectFilter(Config("TestMachine*"));
sut.MatchesTemplateChain(null).ShouldBeFalse();
}
[Fact]
public void SystemTemplate_MatchesWhenOperatorOptsIn()
{
var sut = new AlarmObjectFilter(Config("Area*"));
sut.MatchesTemplateChain(new List<string> { "$Area" }).ShouldBeTrue();
}
[Fact]
public void DuplicateChainEntries_StillMatch()
{
var sut = new AlarmObjectFilter(Config("TestMachine"));
var chain = new List<string> { "TestMachine", "TestMachine", "$UserDefined" };
sut.MatchesTemplateChain(chain).ShouldBeTrue();
}
// ---------- Hierarchy subtree propagation ----------
[Fact]
public void FlatHierarchy_OnlyMatchingIdsIncluded()
{
var hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, template: "TestMachine"),
Obj(2, parent: 0, template: "Pump"),
Obj(3, parent: 0, template: "TestMachine")
};
var sut = new AlarmObjectFilter(Config("TestMachine*"));
var included = sut.ResolveIncludedObjects(hierarchy)!;
included.ShouldContain(1);
included.ShouldContain(3);
included.ShouldNotContain(2);
included.Count.ShouldBe(2);
}
[Fact]
public void MatchOnGrandparent_PropagatesToGrandchildren()
{
var hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, template: "TestMachine"), // root matches
Obj(2, parent: 1, template: "UnrelatedThing"), // child — inherited
Obj(3, parent: 2, template: "UnrelatedOtherThing") // grandchild — inherited
};
var sut = new AlarmObjectFilter(Config("TestMachine"));
var included = sut.ResolveIncludedObjects(hierarchy)!;
included.ShouldBe(new[] { 1, 2, 3 }, ignoreOrder: true);
}
[Fact]
public void GrandchildMatch_DoesNotIncludeAncestors()
{
var hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, template: "Unrelated"),
Obj(2, parent: 1, template: "Unrelated"),
Obj(3, parent: 2, template: "TestMachine")
};
var sut = new AlarmObjectFilter(Config("TestMachine"));
var included = sut.ResolveIncludedObjects(hierarchy)!;
included.ShouldBe(new[] { 3 });
}
[Fact]
public void OverlappingMatches_StillSingleInclude()
{
// Grandparent matches AND grandchild matches independently — grandchild still counted once.
var hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, template: "TestMachine"),
Obj(2, parent: 1, template: "Widget"),
Obj(3, parent: 2, template: "TestMachine")
};
var sut = new AlarmObjectFilter(Config("TestMachine"));
var included = sut.ResolveIncludedObjects(hierarchy)!;
included.Count.ShouldBe(3);
included.ShouldContain(3);
}
[Fact]
public void SiblingSubtrees_OnlyMatchedSideIncluded()
{
var hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, template: "TestMachine"), // match — left subtree
Obj(2, parent: 1, template: "Child"),
Obj(10, parent: 0, template: "Pump"), // no match — right subtree
Obj(11, parent: 10, template: "PumpChild")
};
var sut = new AlarmObjectFilter(Config("TestMachine"));
var included = sut.ResolveIncludedObjects(hierarchy)!;
included.ShouldBe(new[] { 1, 2 }, ignoreOrder: true);
}
// ---------- Defensive / edge cases ----------
[Fact]
public void OrphanObject_TreatedAsRoot()
{
// Object 2 claims parent 99 which isn't in the hierarchy — still reached as a root.
var hierarchy = new List<GalaxyObjectInfo>
{
Obj(2, parent: 99, template: "TestMachine")
};
var sut = new AlarmObjectFilter(Config("TestMachine"));
var included = sut.ResolveIncludedObjects(hierarchy)!;
included.ShouldContain(2);
}
[Fact]
public void SyntheticCycle_TerminatesWithoutStackOverflow()
{
// A→B→A cycle defended by the visited set.
var hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 2, template: "TestMachine"),
Obj(2, parent: 1, template: "Widget")
};
var sut = new AlarmObjectFilter(Config("TestMachine"));
// No object has a ParentGobjectId of 0, and each references an id that exists —
// neither qualifies as a root under the orphan rule. Empty result is acceptable;
// the critical assertion is that the call returns without crashing.
var included = sut.ResolveIncludedObjects(hierarchy);
included.ShouldNotBeNull();
}
[Fact]
public void NullTemplateChain_TreatedAsEmpty()
{
var hierarchy = new List<GalaxyObjectInfo>
{
new() { GobjectId = 1, ParentGobjectId = 0, TemplateChain = null! }
};
var sut = new AlarmObjectFilter(Config("TestMachine"));
var included = sut.ResolveIncludedObjects(hierarchy)!;
included.ShouldBeEmpty();
}
[Fact]
public void EmptyHierarchy_ReturnsEmptySet()
{
var sut = new AlarmObjectFilter(Config("TestMachine"));
var included = sut.ResolveIncludedObjects(new List<GalaxyObjectInfo>())!;
included.ShouldBeEmpty();
}
[Fact]
public void NullHierarchy_ReturnsEmptySet()
{
var sut = new AlarmObjectFilter(Config("TestMachine"));
var included = sut.ResolveIncludedObjects(null)!;
included.ShouldBeEmpty();
}
[Fact]
public void MultipleRoots_AllProcessed()
{
var hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, template: "TestMachine"),
Obj(2, parent: 0, template: "TestMachine"),
Obj(3, parent: 0, template: "Pump")
};
var sut = new AlarmObjectFilter(Config("TestMachine*"));
var included = sut.ResolveIncludedObjects(hierarchy)!;
included.Count.ShouldBe(2);
}
// ---------- UnmatchedPatterns ----------
[Fact]
public void UnmatchedPatterns_ListsPatternsWithZeroHits()
{
var hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, template: "TestMachine")
};
var sut = new AlarmObjectFilter(Config("TestMachine*", "NotThere*"));
sut.ResolveIncludedObjects(hierarchy);
sut.UnmatchedPatterns.ShouldContain("NotThere*");
sut.UnmatchedPatterns.ShouldNotContain("TestMachine*");
}
[Fact]
public void UnmatchedPatterns_EmptyWhenAllMatch()
{
var hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, template: "TestMachine"),
Obj(2, parent: 0, template: "Pump")
};
var sut = new AlarmObjectFilter(Config("TestMachine", "Pump"));
sut.ResolveIncludedObjects(hierarchy);
sut.UnmatchedPatterns.ShouldBeEmpty();
}
[Fact]
public void UnmatchedPatterns_EmptyWhenFilterDisabled()
{
var sut = new AlarmObjectFilter(new AlarmFilterConfiguration());
sut.UnmatchedPatterns.ShouldBeEmpty();
}
[Fact]
public void UnmatchedPatterns_ResetBetweenResolutions()
{
var hierarchyA = new List<GalaxyObjectInfo> { Obj(1, parent: 0, template: "TestMachine") };
var hierarchyB = new List<GalaxyObjectInfo> { Obj(1, parent: 0, template: "Pump") };
var sut = new AlarmObjectFilter(Config("TestMachine*"));
sut.ResolveIncludedObjects(hierarchyA);
sut.UnmatchedPatterns.ShouldBeEmpty();
sut.ResolveIncludedObjects(hierarchyB);
sut.UnmatchedPatterns.ShouldContain("TestMachine*");
}
// ---------- Helpers ----------
private static AlarmFilterConfiguration Config(params string[] filters) =>
new() { ObjectFilters = filters.ToList() };
private static GalaxyObjectInfo Obj(int id, int parent, string template) => new()
{
GobjectId = id,
ParentGobjectId = parent,
TagName = $"Obj_{id}",
BrowseName = $"Obj_{id}",
TemplateChain = new List<string> { template }
};
private static List<GalaxyObjectInfo> SingleObject() => new()
{
Obj(1, parent: 0, template: "Anything")
};
}
}

View File

@@ -1,63 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
{
/// <summary>
/// Verifies default and extended-field behavior for Galaxy attribute metadata objects.
/// </summary>
public class GalaxyAttributeInfoTests
{
/// <summary>
/// Confirms that a default attribute metadata object starts with empty strings for its text fields.
/// </summary>
[Fact]
public void DefaultValues_AreEmpty()
{
var info = new GalaxyAttributeInfo();
info.PrimitiveName.ShouldBe("");
info.AttributeSource.ShouldBe("");
info.TagName.ShouldBe("");
info.AttributeName.ShouldBe("");
info.FullTagReference.ShouldBe("");
info.DataTypeName.ShouldBe("");
info.SecurityClassification.ShouldBe(1);
info.IsHistorized.ShouldBeFalse();
info.IsAlarm.ShouldBeFalse();
}
/// <summary>
/// Confirms that primitive-name and attribute-source fields can be populated for extended metadata rows.
/// </summary>
[Fact]
public void ExtendedFields_CanBeSet()
{
var info = new GalaxyAttributeInfo
{
PrimitiveName = "UDO",
AttributeSource = "primitive"
};
info.PrimitiveName.ShouldBe("UDO");
info.AttributeSource.ShouldBe("primitive");
}
/// <summary>
/// Confirms that standard attribute rows leave the extended metadata fields empty.
/// </summary>
[Fact]
public void StandardAttributes_HaveEmptyExtendedFields()
{
var info = new GalaxyAttributeInfo
{
GobjectId = 1,
TagName = "TestObj",
AttributeName = "MachineID",
FullTagReference = "TestObj.MachineID",
MxDataType = 5
};
info.PrimitiveName.ShouldBe("");
info.AttributeSource.ShouldBe("");
}
}
}

View File

@@ -1,97 +0,0 @@
using System;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
{
/// <summary>
/// Verifies how Galaxy MX data types are mapped into OPC UA and CLR types by the bridge.
/// </summary>
public class MxDataTypeMapperTests
{
/// <summary>
/// Confirms that known Galaxy MX data types map to the expected OPC UA data type node identifiers.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <param name="expectedNodeId">The expected OPC UA data type node identifier.</param>
[Theory]
[InlineData(1, 1u)] // Boolean
[InlineData(2, 6u)] // Integer → Int32
[InlineData(3, 10u)] // Float
[InlineData(4, 11u)] // Double
[InlineData(5, 12u)] // String
[InlineData(6, 13u)] // DateTime
[InlineData(7, 11u)] // ElapsedTime → Double
[InlineData(8, 12u)] // Reference → String
[InlineData(13, 6u)] // Enumeration → Int32
[InlineData(14, 12u)] // Custom → String
[InlineData(15, 21u)] // InternationalizedString → LocalizedText
[InlineData(16, 12u)] // Custom → String
public void MapToOpcUaDataType_AllKnownTypes(int mxDataType, uint expectedNodeId)
{
MxDataTypeMapper.MapToOpcUaDataType(mxDataType).ShouldBe(expectedNodeId);
}
/// <summary>
/// Confirms that unknown MX data types default to the OPC UA string data type.
/// </summary>
/// <param name="mxDataType">The unsupported MX data type code.</param>
[Theory]
[InlineData(0)]
[InlineData(99)]
[InlineData(-1)]
public void MapToOpcUaDataType_UnknownDefaultsToString(int mxDataType)
{
MxDataTypeMapper.MapToOpcUaDataType(mxDataType).ShouldBe(12u); // String
}
/// <summary>
/// Confirms that known MX data types map to the expected CLR runtime types.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <param name="expectedType">The expected CLR type used by the bridge.</param>
[Theory]
[InlineData(1, typeof(bool))]
[InlineData(2, typeof(int))]
[InlineData(3, typeof(float))]
[InlineData(4, typeof(double))]
[InlineData(5, typeof(string))]
[InlineData(6, typeof(DateTime))]
[InlineData(7, typeof(double))]
[InlineData(8, typeof(string))]
[InlineData(13, typeof(int))]
[InlineData(15, typeof(string))]
public void MapToClrType_AllKnownTypes(int mxDataType, Type expectedType)
{
MxDataTypeMapper.MapToClrType(mxDataType).ShouldBe(expectedType);
}
/// <summary>
/// Confirms that unknown MX data types default to the CLR string type.
/// </summary>
[Fact]
public void MapToClrType_UnknownDefaultsToString()
{
MxDataTypeMapper.MapToClrType(999).ShouldBe(typeof(string));
}
/// <summary>
/// Confirms that the boolean MX type reports the expected OPC UA type name.
/// </summary>
[Fact]
public void GetOpcUaTypeName_Boolean()
{
MxDataTypeMapper.GetOpcUaTypeName(1).ShouldBe("Boolean");
}
/// <summary>
/// Confirms that unknown MX types report the fallback OPC UA type name of string.
/// </summary>
[Fact]
public void GetOpcUaTypeName_Unknown_ReturnsString()
{
MxDataTypeMapper.GetOpcUaTypeName(999).ShouldBe("String");
}
}
}

View File

@@ -1,65 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
{
/// <summary>
/// Verifies the operator-facing error messages and quality mappings derived from MXAccess error codes.
/// </summary>
public class MxErrorCodesTests
{
/// <summary>
/// Confirms that known MXAccess error codes produce readable operator-facing descriptions.
/// </summary>
/// <param name="code">The MXAccess error code.</param>
/// <param name="expectedSubstring">A substring expected in the returned description.</param>
[Theory]
[InlineData(1008, "Invalid reference")]
[InlineData(1012, "Wrong data type")]
[InlineData(1013, "Not writable")]
[InlineData(1014, "Request timed out")]
[InlineData(1015, "Communication failure")]
[InlineData(1016, "Not connected")]
public void GetMessage_KnownCodes_ContainsDescription(int code, string expectedSubstring)
{
MxErrorCodes.GetMessage(code).ShouldContain(expectedSubstring);
}
/// <summary>
/// Confirms that unknown MXAccess error codes are reported as unknown while preserving the numeric code.
/// </summary>
[Fact]
public void GetMessage_UnknownCode_ReturnsUnknown()
{
MxErrorCodes.GetMessage(9999).ShouldContain("Unknown");
MxErrorCodes.GetMessage(9999).ShouldContain("9999");
}
/// <summary>
/// Confirms that known MXAccess error codes map to the expected bridge quality values.
/// </summary>
/// <param name="code">The MXAccess error code.</param>
/// <param name="expected">The expected bridge quality value.</param>
[Theory]
[InlineData(1008, Quality.BadConfigError)]
[InlineData(1012, Quality.BadConfigError)]
[InlineData(1013, Quality.BadOutOfService)]
[InlineData(1014, Quality.BadCommFailure)]
[InlineData(1015, Quality.BadCommFailure)]
[InlineData(1016, Quality.BadNotConnected)]
public void MapToQuality_KnownCodes(int code, Quality expected)
{
MxErrorCodes.MapToQuality(code).ShouldBe(expected);
}
/// <summary>
/// Confirms that unknown MXAccess error codes map to the generic bad quality bucket.
/// </summary>
[Fact]
public void MapToQuality_UnknownCode_ReturnsBad()
{
MxErrorCodes.MapToQuality(9999).ShouldBe(Quality.Bad);
}
}
}

View File

@@ -1,149 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
{
/// <summary>
/// Verifies the mapping between MXAccess quality codes, bridge quality values, and OPC UA status codes.
/// </summary>
public class QualityMapperTests
{
/// <summary>
/// Confirms that bad-family MXAccess quality values map to the expected bridge quality values.
/// </summary>
/// <param name="input">The raw MXAccess quality code.</param>
/// <param name="expected">The bridge quality value expected for the code.</param>
[Theory]
[InlineData(0, Quality.Bad)]
[InlineData(4, Quality.BadConfigError)]
[InlineData(20, Quality.BadCommFailure)]
[InlineData(32, Quality.BadWaitingForInitialData)]
public void MapFromMxAccess_BadFamily(int input, Quality expected)
{
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
}
/// <summary>
/// Confirms that uncertain-family MXAccess quality values map to the expected bridge quality values.
/// </summary>
/// <param name="input">The raw MXAccess quality code.</param>
/// <param name="expected">The bridge quality value expected for the code.</param>
[Theory]
[InlineData(64, Quality.Uncertain)]
[InlineData(68, Quality.UncertainLastUsable)]
[InlineData(88, Quality.UncertainSubNormal)]
public void MapFromMxAccess_UncertainFamily(int input, Quality expected)
{
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
}
/// <summary>
/// Confirms that good-family MXAccess quality values map to the expected bridge quality values.
/// </summary>
/// <param name="input">The raw MXAccess quality code.</param>
/// <param name="expected">The bridge quality value expected for the code.</param>
[Theory]
[InlineData(192, Quality.Good)]
[InlineData(216, Quality.GoodLocalOverride)]
public void MapFromMxAccess_GoodFamily(int input, Quality expected)
{
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
}
/// <summary>
/// Confirms that unknown bad-family values collapse to the generic bad quality bucket.
/// </summary>
[Fact]
public void MapFromMxAccess_UnknownBadValue_ReturnsBad()
{
QualityMapper.MapFromMxAccessQuality(63).ShouldBe(Quality.Bad);
}
/// <summary>
/// Confirms that unknown uncertain-family values collapse to the generic uncertain quality bucket.
/// </summary>
[Fact]
public void MapFromMxAccess_UnknownUncertainValue_ReturnsUncertain()
{
QualityMapper.MapFromMxAccessQuality(100).ShouldBe(Quality.Uncertain);
}
/// <summary>
/// Confirms that unknown good-family values collapse to the generic good quality bucket.
/// </summary>
[Fact]
public void MapFromMxAccess_UnknownGoodValue_ReturnsGood()
{
QualityMapper.MapFromMxAccessQuality(200).ShouldBe(Quality.Good);
}
/// <summary>
/// Confirms that the generic good quality maps to the OPC UA good status code.
/// </summary>
[Fact]
public void MapToOpcUa_Good_Returns0()
{
QualityMapper.MapToOpcUaStatusCode(Quality.Good).ShouldBe(0x00000000u);
}
/// <summary>
/// Confirms that the generic bad quality maps to the OPC UA bad status code.
/// </summary>
[Fact]
public void MapToOpcUa_Bad_Returns80000000()
{
QualityMapper.MapToOpcUaStatusCode(Quality.Bad).ShouldBe(0x80000000u);
}
/// <summary>
/// Confirms that communication failures map to the OPC UA bad communication-failure status code.
/// </summary>
[Fact]
public void MapToOpcUa_BadCommFailure()
{
QualityMapper.MapToOpcUaStatusCode(Quality.BadCommFailure).ShouldBe(0x80050000u);
}
/// <summary>
/// Confirms that the generic uncertain quality maps to the OPC UA uncertain status code.
/// </summary>
[Fact]
public void MapToOpcUa_Uncertain()
{
QualityMapper.MapToOpcUaStatusCode(Quality.Uncertain).ShouldBe(0x40000000u);
}
/// <summary>
/// Confirms that good quality values are classified correctly by the quality extension helpers.
/// </summary>
[Fact]
public void QualityExtensions_IsGood()
{
Quality.Good.IsGood().ShouldBe(true);
Quality.Good.IsBad().ShouldBe(false);
Quality.Good.IsUncertain().ShouldBe(false);
}
/// <summary>
/// Confirms that bad quality values are classified correctly by the quality extension helpers.
/// </summary>
[Fact]
public void QualityExtensions_IsBad()
{
Quality.Bad.IsBad().ShouldBe(true);
Quality.Bad.IsGood().ShouldBe(false);
}
/// <summary>
/// Confirms that uncertain quality values are classified correctly by the quality extension helpers.
/// </summary>
[Fact]
public void QualityExtensions_IsUncertain()
{
Quality.Uncertain.IsUncertain().ShouldBe(true);
Quality.Uncertain.IsGood().ShouldBe(false);
Quality.Uncertain.IsBad().ShouldBe(false);
}
}
}

View File

@@ -1,54 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
{
public class SecurityClassificationMapperTests
{
/// <summary>
/// Verifies that Galaxy classifications intended for operator and engineering writes remain writable through OPC UA.
/// </summary>
/// <param name="classification">The Galaxy security classification value being evaluated for write access.</param>
/// <param name="expected">The expected writable result for the supplied Galaxy classification.</param>
[Theory]
[InlineData(0, true)] // FreeAccess
[InlineData(1, true)] // Operate
[InlineData(4, true)] // Tune
[InlineData(5, true)] // Configure
public void Writable_SecurityLevels(int classification, bool expected)
{
SecurityClassificationMapper.IsWritable(classification).ShouldBe(expected);
}
/// <summary>
/// Verifies that secured or view-only Galaxy classifications are exposed as read-only attributes.
/// </summary>
/// <param name="classification">The Galaxy security classification value expected to block writes.</param>
/// <param name="expected">The expected writable result for the supplied read-only Galaxy classification.</param>
[Theory]
[InlineData(2, false)] // SecuredWrite
[InlineData(3, false)] // VerifiedWrite
[InlineData(6, false)] // ViewOnly
public void ReadOnly_SecurityLevels(int classification, bool expected)
{
SecurityClassificationMapper.IsWritable(classification).ShouldBe(expected);
}
/// <summary>
/// Verifies that unknown security classifications do not accidentally block writes for unmapped Galaxy values.
/// </summary>
/// <param name="classification">
/// An unmapped Galaxy security classification value that should fall back to writable
/// behavior.
/// </param>
[Theory]
[InlineData(-1)]
[InlineData(7)]
[InlineData(99)]
public void Unknown_Values_DefaultToWritable(int classification)
{
SecurityClassificationMapper.IsWritable(classification).ShouldBeTrue();
}
}
}

Some files were not shown because too many files have changed in this diff Show More