64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
834 lines
36 KiB
C#
834 lines
36 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using StringCollection = System.Collections.Specialized.StringCollection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ArchestrA;
|
|
using Serilog;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
|
{
|
|
/// <summary>
|
|
/// Reads historical data from the Wonderware Historian via the aahClientManaged SDK.
|
|
/// OPC-UA-free — emits <see cref="HistorianSample"/>/<see cref="HistorianAggregateSample"/>
|
|
/// which the sidecar serialises onto the named-pipe wire (PR 3.3 contracts) for the
|
|
/// .NET 10 <c>WonderwareHistorianClient</c> to translate into OPC UA <c>DataValue</c>
|
|
/// on its side of the IPC. The v1 Galaxy.Host / Proxy architecture this class
|
|
/// originally lived in retired in PR 7.2.
|
|
/// </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;
|
|
|
|
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;
|
|
|
|
private readonly HistorianClusterEndpointPicker _picker;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="HistorianDataSource"/> class with the default connection factory.</summary>
|
|
/// <param name="config">The historian configuration.</param>
|
|
public HistorianDataSource(HistorianConfiguration config)
|
|
: this(config, new SdkHistorianConnectionFactory(), null) { }
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="HistorianDataSource"/> class with the specified connection factory and endpoint picker.</summary>
|
|
/// <param name="config">The historian configuration.</param>
|
|
/// <param name="factory">The historian connection factory.</param>
|
|
/// <param name="picker">The optional cluster endpoint picker.</param>
|
|
internal HistorianDataSource(
|
|
HistorianConfiguration config,
|
|
IHistorianConnectionFactory factory,
|
|
HistorianClusterEndpointPicker? picker = null)
|
|
{
|
|
_config = config;
|
|
_factory = factory;
|
|
_picker = picker ?? new HistorianClusterEndpointPicker(config);
|
|
}
|
|
|
|
// Error codes that signify the connection or server is the problem rather than the
|
|
// query itself. A query-class failure (bad tag name, unsupported aggregate, etc.) must
|
|
// not force us to tear down and re-open the (relatively expensive) historian
|
|
// connection — that would let a burst of bad-tag queries push an otherwise healthy
|
|
// cluster node into cooldown. See Driver.Historian.Wonderware-008.
|
|
private static readonly HashSet<HistorianAccessError.ErrorValue> ConnectionErrorCodes =
|
|
new HashSet<HistorianAccessError.ErrorValue>
|
|
{
|
|
HistorianAccessError.ErrorValue.FailedToConnect,
|
|
HistorianAccessError.ErrorValue.FailedToCreateSession,
|
|
HistorianAccessError.ErrorValue.NoReply,
|
|
HistorianAccessError.ErrorValue.NotReady,
|
|
HistorianAccessError.ErrorValue.NotInitialized,
|
|
HistorianAccessError.ErrorValue.Stopping,
|
|
HistorianAccessError.ErrorValue.Win32Exception,
|
|
HistorianAccessError.ErrorValue.InvalidResponse,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Whether an <c>aahClientManaged</c> error code indicates that the
|
|
/// <em>connection</em> (rather than the query payload) is the problem and the
|
|
/// shared SDK connection should therefore be reset. Internal for unit testing.
|
|
/// </summary>
|
|
/// <param name="code">The historian access error code.</param>
|
|
internal static bool IsConnectionClassError(HistorianAccessError.ErrorValue code)
|
|
=> ConnectionErrorCodes.Contains(code);
|
|
|
|
/// <summary>
|
|
/// Builds the per-read <see cref="CancellationTokenSource"/> linked into the
|
|
/// caller's <paramref name="ct"/> and pre-wired to fire after
|
|
/// <see cref="HistorianConfiguration.RequestTimeoutSeconds"/> if positive. The
|
|
/// read paths use the resulting token in their <c>ThrowIfCancellationRequested</c>
|
|
/// checks so a hung <c>StartQuery</c> or slow <c>MoveNext</c> cannot block the
|
|
/// single pipe-server connection thread indefinitely. See
|
|
/// Driver.Historian.Wonderware-010.
|
|
/// </summary>
|
|
/// <param name="cfg">The historian configuration.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
internal static CancellationTokenSource BuildRequestCts(HistorianConfiguration cfg, CancellationToken ct)
|
|
{
|
|
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
if (cfg.RequestTimeoutSeconds > 0)
|
|
{
|
|
cts.CancelAfter(TimeSpan.FromSeconds(cfg.RequestTimeoutSeconds));
|
|
}
|
|
return cts;
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>Gets a snapshot of the current health status.</summary>
|
|
public HistorianHealthSnapshot GetHealthSnapshot()
|
|
{
|
|
var nodeStates = _picker.SnapshotNodeStates();
|
|
var healthyCount = 0;
|
|
foreach (var n in nodeStates)
|
|
if (n.IsHealthy) healthyCount++;
|
|
|
|
// Driver.Historian.Wonderware-005: derive the connection-open booleans from the
|
|
// active-node strings, both of which live under _healthLock. _connection itself
|
|
// is published under _connectionLock — reading it here under a different lock
|
|
// could produce an internally inconsistent snapshot (open with no node, or
|
|
// closed with a non-null node) at the publish/clear boundary. Treating the
|
|
// active-node strings as the single source of truth makes the snapshot
|
|
// self-consistent by construction.
|
|
lock (_healthLock)
|
|
{
|
|
return new HistorianHealthSnapshot
|
|
{
|
|
TotalQueries = _totalSuccesses + _totalFailures,
|
|
TotalSuccesses = _totalSuccesses,
|
|
TotalFailures = _totalFailures,
|
|
ConsecutiveFailures = _consecutiveFailures,
|
|
LastSuccessTime = _lastSuccessTime,
|
|
LastFailureTime = _lastFailureTime,
|
|
LastError = _lastError,
|
|
ProcessConnectionOpen = _activeProcessNode != null,
|
|
EventConnectionOpen = _activeEventNode != 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));
|
|
|
|
if (Volatile.Read(ref _connection) != null) return;
|
|
|
|
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Process);
|
|
|
|
lock (_connectionLock)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
conn.CloseConnection(out _);
|
|
conn.Dispose();
|
|
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
|
}
|
|
|
|
if (_connection != null)
|
|
{
|
|
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})", 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Internal exception signalling that <c>StartQuery</c> returned an SDK error
|
|
/// whose code is <em>query-class</em> (bad tag name, unsupported aggregate, etc.)
|
|
/// and the shared SDK connection therefore must NOT be reset. The outer catch
|
|
/// re-throws this so the IPC frame handler surfaces <c>Success=false</c> without
|
|
/// touching the connection. See Driver.Historian.Wonderware-008.
|
|
/// </summary>
|
|
internal sealed class QueryClassStartQueryException : InvalidOperationException
|
|
{
|
|
/// <summary>Gets the error code that caused the exception.</summary>
|
|
public HistorianAccessError.ErrorValue Code { get; }
|
|
/// <summary>Initializes a new instance of the <see cref="QueryClassStartQueryException"/> class.</summary>
|
|
/// <param name="message">The exception message.</param>
|
|
/// <param name="code">The historian access error code.</param>
|
|
public QueryClassStartQueryException(string message, HistorianAccessError.ErrorValue code)
|
|
: base(message)
|
|
{
|
|
Code = code;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Centralised <c>StartQuery</c>-failure handler. Throws so the caller surfaces
|
|
/// <c>Success=false</c> in the IPC reply (the previous return-empty-with-success
|
|
/// behaviour made an SDK error look like "no data in range" to the client). The
|
|
/// connection is only reset when the error code is connection-class —
|
|
/// query-class failures (bad tag name, unsupported aggregate, etc.) must leave
|
|
/// the shared SDK connection intact, otherwise a burst of bad-tag queries cycles
|
|
/// the connection and pushes a healthy cluster node into cooldown.
|
|
/// See Driver.Historian.Wonderware-008.
|
|
/// </summary>
|
|
private void HandleStartQueryFailure(
|
|
string operation, HistorianAccessError error, bool isEventConnection)
|
|
{
|
|
var code = error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure;
|
|
var description = error?.ErrorDescription ?? string.Empty;
|
|
var connectionClass = IsConnectionClassError(code);
|
|
|
|
Log.Warning(
|
|
"Historian SDK StartQuery failed: {Operation} -> {Code} ({Desc}) [{Kind}]",
|
|
operation, code, description,
|
|
connectionClass ? "connection-class" : "query-class");
|
|
RecordFailure($"{operation}: {code}");
|
|
|
|
var message = $"Historian SDK StartQuery failed for {operation}: {code} ({description})";
|
|
|
|
if (connectionClass)
|
|
{
|
|
if (isEventConnection) HandleEventConnectionError();
|
|
else HandleConnectionError();
|
|
throw new InvalidOperationException(message);
|
|
}
|
|
|
|
// Query-class — the outer catch block must NOT call HandleConnectionError on this.
|
|
throw new QueryClassStartQueryException(message, code);
|
|
}
|
|
|
|
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})", failedNode ?? "(unknown)");
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads raw historical samples for the specified tag.</summary>
|
|
/// <param name="tagName">The tag name.</param>
|
|
/// <param name="startTime">The start time for the query.</param>
|
|
/// <param name="endTime">The end time for the query.</param>
|
|
/// <param name="maxValues">The maximum number of values to return.</param>
|
|
/// <param name="ct">Cancellation token for the operation.</param>
|
|
public Task<List<HistorianSample>> ReadRawAsync(
|
|
string tagName, DateTime startTime, DateTime endTime, int maxValues,
|
|
CancellationToken ct = default)
|
|
{
|
|
var results = new List<HistorianSample>();
|
|
|
|
// Driver.Historian.Wonderware-010: wire RequestTimeoutSeconds into the read path
|
|
// so a hung StartQuery / slow MoveNext can't block the connection thread forever.
|
|
using var requestCts = BuildRequestCts(_config, ct);
|
|
var token = requestCts.Token;
|
|
|
|
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))
|
|
{
|
|
HandleStartQueryFailure(
|
|
$"raw query for tag '{tagName}'", error, isEventConnection: false);
|
|
}
|
|
|
|
var count = 0;
|
|
var limit = maxValues > 0 ? maxValues : _config.MaxValuesPerRead;
|
|
|
|
while (query.MoveNext(out error))
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
var result = query.QueryResult;
|
|
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
|
|
|
|
results.Add(new HistorianSample
|
|
{
|
|
Value = SelectValue(result),
|
|
TimestampUtc = timestamp,
|
|
Quality = (byte)(result.OpcQuality & 0xFF),
|
|
});
|
|
|
|
count++;
|
|
if (limit > 0 && count >= limit) break;
|
|
}
|
|
|
|
query.EndQuery(out _);
|
|
RecordSuccess();
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (ObjectDisposedException) { throw; }
|
|
catch (QueryClassStartQueryException)
|
|
{
|
|
// Query-class StartQuery failure — HandleStartQueryFailure already logged
|
|
// and recorded. Re-throw so the IPC layer surfaces Success=false instead of
|
|
// returning an empty list (which would look like "no data in range"). The
|
|
// connection is deliberately NOT reset. See Driver.Historian.Wonderware-008.
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "HistoryRead raw failed for {Tag}", tagName);
|
|
RecordFailure($"raw: {ex.Message}");
|
|
HandleConnectionError(ex);
|
|
throw;
|
|
}
|
|
|
|
Log.Debug("HistoryRead raw: {Tag} returned {Count} values ({Start} to {End})",
|
|
tagName, results.Count, startTime, endTime);
|
|
|
|
return Task.FromResult(results);
|
|
}
|
|
|
|
/// <summary>Reads aggregate historical samples for the specified tag.</summary>
|
|
/// <param name="tagName">The tag name.</param>
|
|
/// <param name="startTime">The start time for the query.</param>
|
|
/// <param name="endTime">The end time for the query.</param>
|
|
/// <param name="intervalMs">The interval in milliseconds.</param>
|
|
/// <param name="aggregateColumn">The aggregate column name.</param>
|
|
/// <param name="ct">Cancellation token for the operation.</param>
|
|
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(
|
|
string tagName, DateTime startTime, DateTime endTime,
|
|
double intervalMs, string aggregateColumn,
|
|
CancellationToken ct = default)
|
|
{
|
|
var results = new List<HistorianAggregateSample>();
|
|
|
|
// Driver.Historian.Wonderware-010: outer safety timeout — see ReadRawAsync.
|
|
using var requestCts = BuildRequestCts(_config, ct);
|
|
var token = requestCts.Token;
|
|
|
|
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))
|
|
{
|
|
HandleStartQueryFailure(
|
|
$"aggregate query for tag '{tagName}'", error, isEventConnection: false);
|
|
}
|
|
|
|
// Apply the same bucket cap as the raw-read path so a wide time range with a
|
|
// small IntervalMs cannot produce an unbounded result set that would overflow
|
|
// the 16 MiB FrameWriter frame cap and lose the entire reply.
|
|
var bucketLimit = _config.MaxValuesPerRead;
|
|
var bucketCount = 0;
|
|
|
|
while (query.MoveNext(out error))
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
var result = query.QueryResult;
|
|
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
|
|
var value = ExtractAggregateValue(result, aggregateColumn);
|
|
|
|
results.Add(new HistorianAggregateSample
|
|
{
|
|
Value = value,
|
|
TimestampUtc = timestamp,
|
|
});
|
|
|
|
bucketCount++;
|
|
if (bucketLimit > 0 && bucketCount >= bucketLimit)
|
|
{
|
|
Log.Warning(
|
|
"HistoryRead aggregate ({Aggregate}): {Tag} truncated at {Limit} buckets — widen IntervalMs or reduce time range",
|
|
aggregateColumn, tagName, bucketLimit);
|
|
break;
|
|
}
|
|
}
|
|
|
|
query.EndQuery(out _);
|
|
RecordSuccess();
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (ObjectDisposedException) { throw; }
|
|
catch (QueryClassStartQueryException) { throw; } // see ReadRawAsync — keep connection
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "HistoryRead aggregate failed for {Tag}", tagName);
|
|
RecordFailure($"aggregate: {ex.Message}");
|
|
HandleConnectionError(ex);
|
|
throw;
|
|
}
|
|
|
|
Log.Debug("HistoryRead aggregate ({Aggregate}): {Tag} returned {Count} values",
|
|
aggregateColumn, tagName, results.Count);
|
|
|
|
return Task.FromResult(results);
|
|
}
|
|
|
|
/// <summary>Reads historical samples at specific timestamps for the specified tag.</summary>
|
|
/// <param name="tagName">The tag name.</param>
|
|
/// <param name="timestamps">The timestamps to read.</param>
|
|
/// <param name="ct">Cancellation token for the operation.</param>
|
|
public Task<List<HistorianSample>> ReadAtTimeAsync(
|
|
string tagName, DateTime[] timestamps,
|
|
CancellationToken ct = default)
|
|
{
|
|
var results = new List<HistorianSample>();
|
|
|
|
if (timestamps == null || timestamps.Length == 0)
|
|
return Task.FromResult(results);
|
|
|
|
// Driver.Historian.Wonderware-010: outer safety timeout — see ReadRawAsync.
|
|
using var requestCts = BuildRequestCts(_config, ct);
|
|
var token = requestCts.Token;
|
|
|
|
try
|
|
{
|
|
EnsureConnected();
|
|
|
|
foreach (var timestamp in timestamps)
|
|
{
|
|
token.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 HistorianSample
|
|
{
|
|
Value = null,
|
|
TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc),
|
|
Quality = 0, // Bad
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (query.MoveNext(out error))
|
|
{
|
|
var result = query.QueryResult;
|
|
results.Add(new HistorianSample
|
|
{
|
|
Value = SelectValue(result),
|
|
TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc),
|
|
Quality = (byte)(result.OpcQuality & 0xFF),
|
|
});
|
|
}
|
|
else
|
|
{
|
|
results.Add(new HistorianSample
|
|
{
|
|
Value = null,
|
|
TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc),
|
|
Quality = 0,
|
|
});
|
|
}
|
|
|
|
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);
|
|
throw;
|
|
}
|
|
|
|
Log.Debug("HistoryRead at-time: {Tag} returned {Count} values for {Timestamps} timestamps",
|
|
tagName, results.Count, timestamps.Length);
|
|
|
|
return Task.FromResult(results);
|
|
}
|
|
|
|
/// <summary>Reads historical events within the specified time range.</summary>
|
|
/// <param name="sourceName">The optional event source name filter.</param>
|
|
/// <param name="startTime">The start time for the query.</param>
|
|
/// <param name="endTime">The end time for the query.</param>
|
|
/// <param name="maxEvents">The maximum number of events to return.</param>
|
|
/// <param name="ct">Cancellation token for the operation.</param>
|
|
public Task<List<HistorianEventDto>> ReadEventsAsync(
|
|
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
|
|
CancellationToken ct = default)
|
|
{
|
|
var results = new List<HistorianEventDto>();
|
|
|
|
// Driver.Historian.Wonderware-010: outer safety timeout — see ReadRawAsync.
|
|
using var requestCts = BuildRequestCts(_config, ct);
|
|
var token = requestCts.Token;
|
|
|
|
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))
|
|
{
|
|
HandleStartQueryFailure(
|
|
$"event query for source '{sourceName ?? "(all)"}'", error, isEventConnection: true);
|
|
}
|
|
|
|
var count = 0;
|
|
while (query.MoveNext(out error))
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
results.Add(ToDto(query.QueryResult));
|
|
count++;
|
|
if (maxEvents > 0 && count >= maxEvents) break;
|
|
}
|
|
|
|
query.EndQuery(out _);
|
|
RecordSuccess();
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (ObjectDisposedException) { throw; }
|
|
catch (QueryClassStartQueryException) { throw; } // see ReadRawAsync — keep connection
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "HistoryRead events failed for source {Source}", sourceName ?? "(all)");
|
|
RecordFailure($"events: {ex.Message}");
|
|
HandleEventConnectionError(ex);
|
|
throw;
|
|
}
|
|
|
|
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)
|
|
{
|
|
// The ArchestrA SDK marks these properties obsolete but still returns them; their
|
|
// successors aren't wired in the version we bind against. Using them is the documented
|
|
// v1 behavior — suppressed locally instead of project-wide so any non-event use of
|
|
// deprecated SDK surface still surfaces as an error.
|
|
#pragma warning disable CS0618
|
|
return new HistorianEventDto
|
|
{
|
|
Id = evt.Id,
|
|
Source = evt.Source,
|
|
EventTime = evt.EventTime,
|
|
ReceivedTime = evt.ReceivedTime,
|
|
DisplayText = evt.DisplayText,
|
|
Severity = (ushort)evt.Severity
|
|
};
|
|
#pragma warning restore CS0618
|
|
}
|
|
|
|
/// <summary>
|
|
/// Selects the typed value from a <see cref="HistoryQueryResult"/> row.
|
|
/// <para>
|
|
/// <b>SDK limitation:</b> <c>HistoryQueryResult</c> exposes only <c>Value</c>
|
|
/// (double) and <c>StringValue</c> (string) — there is no tag data-type field on
|
|
/// the result. The correct approach would be to branch on the tag's declared
|
|
/// data type, but the bound version of <c>aahClientManaged</c> does not surface
|
|
/// it per query result. The heuristic below is the best available: prefer
|
|
/// <c>StringValue</c> only when it is non-empty AND <c>Value</c> is zero,
|
|
/// because string tags in the Historian SDK always project to <c>Value=0</c>
|
|
/// while numeric tags may legitimately sample to zero (in which case the SDK
|
|
/// does not populate <c>StringValue</c>). A numeric tag at exactly zero with a
|
|
/// non-empty formatted <c>StringValue</c> (e.g. "0.00") would be mis-reported
|
|
/// as a string; this is a known edge case of the SDK binding.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="result">The history query result.</param>
|
|
internal static object? SelectValue(HistoryQueryResult result)
|
|
=> SelectValueFromPair(result.Value, result.StringValue);
|
|
|
|
/// <summary>
|
|
/// SDK-independent overload of the string-vs-numeric heuristic. Exposed so unit
|
|
/// tests can pin the logic without having to instantiate the SDK
|
|
/// <see cref="HistoryQueryResult"/> (whose internal property initialisers make
|
|
/// it impractical to fake). See Driver.Historian.Wonderware-012.
|
|
/// </summary>
|
|
/// <param name="value">The numeric value.</param>
|
|
/// <param name="stringValue">The string value.</param>
|
|
internal static object? SelectValueFromPair(double value, string? stringValue)
|
|
{
|
|
if (!string.IsNullOrEmpty(stringValue) && value == 0)
|
|
return stringValue;
|
|
return value;
|
|
}
|
|
|
|
/// <summary>Extracts the specified aggregate value from an analog summary query result.</summary>
|
|
/// <param name="result">The analog summary query result.</param>
|
|
/// <param name="column">The aggregate column name.</param>
|
|
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>Disposes the historian data source and releases its 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;
|
|
}
|
|
}
|
|
}
|