refactor(historian-gateway): retire Wonderware historian projects (gateway is sole backend)
The HistorianGateway driver is now the sole historian read/write+alarm backend, so the Wonderware sidecar projects are dead code. Removes the 5 Wonderware projects (driver, .Client, .Client.Contracts, + their 2 test projects) from the solution and tree, and fully retires the vestigial 'Historian.Wonderware' driver type (UI/probe-only; it had no driver factory): the Host probe registration, the AdminUI driver-config surface (driver page, tag-config editor/model/validator entry, address picker/builder, driver-type catalog + dropdown + edit-router entries), and their tests. Prunes the now-unused Wonderware connection fields (Host/Port/UseTls/ServerCertThumbprint/SharedSecret) from AlarmHistorianOptions (keeping Enabled + the SQLite store-and-forward knobs) and refreshes the stale XML docs that named Wonderware as the production backend. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
@@ -24,10 +24,7 @@
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj" />
|
||||
@@ -86,9 +83,7 @@
|
||||
<Folder Name="/tests/Drivers/">
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" />
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
/// <summary>
|
||||
/// The historian sink contract — where qualifying alarm events land. Phase 7 plan
|
||||
/// decision #17: ingestion routes through the Wonderware historian sidecar
|
||||
/// (<c>WonderwareHistorianClient</c>), which owns the <c>aahClientManaged</c> DLLs
|
||||
/// and 32-bit constraints. Tests use an in-memory fake; production uses
|
||||
/// The historian sink contract — where qualifying alarm events land. Ingestion routes
|
||||
/// through the HistorianGateway alarm writer (the gateway's <c>SendEvent</c> gRPC path)
|
||||
/// behind the durable store-and-forward queue. Tests use an in-memory fake; production uses
|
||||
/// <see cref="SqliteStoreAndForwardSink"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -80,7 +79,7 @@ public enum HistorianDrainState
|
||||
BackingOff,
|
||||
}
|
||||
|
||||
/// <summary>Returned by the Wonderware historian sidecar per event — drain worker uses this to decide retry cadence.</summary>
|
||||
/// <summary>Returned by the historian alarm writer per event — drain worker uses this to decide retry cadence.</summary>
|
||||
public enum HistorianWriteOutcome
|
||||
{
|
||||
/// <summary>Successfully persisted to the historian. Remove from queue.</summary>
|
||||
@@ -91,7 +90,7 @@ public enum HistorianWriteOutcome
|
||||
PermanentFail,
|
||||
}
|
||||
|
||||
/// <summary>What the drain worker delegates writes to — production is <c>WonderwareHistorianClient</c> (the Wonderware historian sidecar).</summary>
|
||||
/// <summary>What the drain worker delegates writes to — production is the HistorianGateway alarm writer (the gateway's <c>SendEvent</c> gRPC path).</summary>
|
||||
public interface IAlarmHistorianWriter
|
||||
{
|
||||
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
|
||||
|
||||
+2
-1
@@ -261,7 +261,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
/// requested timestamp, in request order. Returned samples are indexed by timestamp ticks;
|
||||
/// any requested timestamp the gateway did not return is filled with a Bad-quality
|
||||
/// (<c>0x80000000</c>) snapshot stamped at the requested time rather than positionally
|
||||
/// misaligning values. Ported from <c>WonderwareHistorianClient.AlignAtTimeSnapshots</c>.
|
||||
/// misaligning values. The alignment logic was ported from the now-retired Wonderware
|
||||
/// client's at-time snapshot reconciliation.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<DataValueSnapshot> AlignAtTimeSnapshots(
|
||||
IReadOnlyList<DateTime> timestampsUtc, IReadOnlyList<HistorianSample> samples)
|
||||
|
||||
+4
-5
@@ -5,11 +5,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
/// uint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Byte-identical port of
|
||||
/// <c>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal.QualityMapper.Map</c> (itself a
|
||||
/// port of the sidecar's <c>HistorianQualityMapper.Map</c>). The table is duplicated rather than
|
||||
/// shared because the projects do not share an assembly; a change to the quality table must be
|
||||
/// applied in every copy and is kept in parity by the per-byte tests.
|
||||
/// Byte-identical port of the historical Wonderware client's <c>QualityMapper.Map</c> (itself a
|
||||
/// port of the original historian sidecar's <c>HistorianQualityMapper.Map</c>). Those projects have
|
||||
/// since been retired; this is now the canonical quality table. Parity with the OPC DA quality
|
||||
/// semantics is pinned by the per-byte tests.
|
||||
/// </remarks>
|
||||
internal static class GatewayQualityMapper
|
||||
{
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Connection options for <c>WonderwareHistorianClient</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Retry / backoff ownership (finding 006):</b> this module performs exactly one
|
||||
/// in-place transport reconnect inside <c>FrameChannel.InvokeAsync</c> with no delay,
|
||||
/// and does NOT implement exponential reconnect backoff. Broader retry/backoff is the
|
||||
/// caller's responsibility — the alarm drain worker
|
||||
/// (<c>Core.AlarmHistorian.SqliteStoreAndForwardSink</c>) and the read-side
|
||||
/// history router are expected to layer their own backoff on top.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Host">Sidecar TCP host (DNS name or IP) the client dials.</param>
|
||||
/// <param name="Port">Sidecar TCP port (matches the sidecar's <c>OTOPCUA_HISTORIAN_TCP_PORT</c>). Valid range: 1–65535.</param>
|
||||
/// <param name="SharedSecret">Per-process shared secret the sidecar will verify in the Hello frame.</param>
|
||||
/// <param name="PeerName">Diagnostic peer identifier sent in Hello — typically the OtOpcUa instance id.</param>
|
||||
/// <param name="ConnectTimeout">Cap on the TCP connect + Hello round trip on each (re)connect.</param>
|
||||
/// <param name="CallTimeout">Cap on a single read/write call once connected.</param>
|
||||
public sealed record WonderwareHistorianClientOptions(
|
||||
string Host,
|
||||
[Range(1, 65535)] int Port,
|
||||
string SharedSecret,
|
||||
string PeerName = "OtOpcUa",
|
||||
TimeSpan? ConnectTimeout = null,
|
||||
TimeSpan? CallTimeout = null)
|
||||
{
|
||||
/// <summary>Gets the effective connect timeout, using the default if not explicitly set.</summary>
|
||||
public TimeSpan EffectiveConnectTimeout => ConnectTimeout ?? TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Gets the effective call timeout, using the default if not explicitly set.</summary>
|
||||
public TimeSpan EffectiveCallTimeout => CallTimeout ?? TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
/// </summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 15s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = 15;
|
||||
|
||||
/// <summary>When true, the client wraps the TCP stream in TLS before the Hello handshake.</summary>
|
||||
public bool UseTls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional SHA-1 thumbprint (40 hex characters, no spaces, case-insensitive) the client
|
||||
/// pins the sidecar's TLS server cert against. When null/empty and
|
||||
/// <see cref="UseTls"/> is true, the client validates the cert chain normally
|
||||
/// (CA-issued cert).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The consumer matches against <c>X509Certificate.GetCertHashString()</c> (SHA-1, 40
|
||||
/// hex chars). Supplying a SHA-256 thumbprint (64 hex chars, the format shown by modern
|
||||
/// tooling such as <c>certutil</c> or Windows Certificate Manager) will never match and
|
||||
/// will cause the TLS handshake to fail silently. Only 40-character SHA-1 hex strings
|
||||
/// are accepted.
|
||||
/// </remarks>
|
||||
public string? ServerCertThumbprint { get; init; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Redacts <see cref="SharedSecret"/> so the value cannot appear in log output when the
|
||||
/// options object is passed to a structured-logging statement.
|
||||
/// </remarks>
|
||||
public override string ToString() =>
|
||||
$"WonderwareHistorianClientOptions {{ Host={Host}, Port={Port}, PeerName={PeerName}, UseTls={UseTls}, ServerCertThumbprint={ServerCertThumbprint ?? "<null>"} }}";
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<!-- NO PackageReference. NO ProjectReference. -->
|
||||
</Project>
|
||||
-230
@@ -1,230 +0,0 @@
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using MessagePack;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Owns one TCP connection to the Wonderware historian sidecar. Handles the Hello
|
||||
/// handshake, serializes outgoing requests + waits for the matching reply frame, and
|
||||
/// reconnects on transport failure with exponential backoff.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Single in-flight call at a time — the sidecar's TCP protocol is request/response
|
||||
/// over a single bidirectional stream, so multiple concurrent <see cref="InvokeAsync"/>
|
||||
/// calls would interleave replies. A <see cref="SemaphoreSlim"/> serializes them. PR 6.x
|
||||
/// can layer batching on top.
|
||||
/// </remarks>
|
||||
internal sealed class FrameChannel : IAsyncDisposable
|
||||
{
|
||||
private readonly WonderwareHistorianClientOptions _options;
|
||||
private readonly Func<CancellationToken, Task<Stream>> _connect;
|
||||
private readonly ILogger _logger;
|
||||
private readonly SemaphoreSlim _callGate = new(1, 1);
|
||||
|
||||
private Stream? _stream;
|
||||
private FrameReader? _reader;
|
||||
private FrameWriter? _writer;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Default TCP factory: connects to the sidecar over TCP, optionally wrapping the stream
|
||||
/// in TLS (server-auth; pinned-thumbprint or CA-chain validation). The Hello handshake +
|
||||
/// shared secret still authenticate the caller on top of this.
|
||||
/// </summary>
|
||||
public static readonly Func<WonderwareHistorianClientOptions, CancellationToken, Task<Stream>> DefaultTcpConnectFactory =
|
||||
async (opts, ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(opts.Host))
|
||||
throw new InvalidOperationException("WonderwareHistorianClientOptions.Host is required for the TCP transport.");
|
||||
|
||||
var tcp = new TcpClient();
|
||||
try
|
||||
{
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
connectCts.CancelAfter(opts.EffectiveConnectTimeout);
|
||||
await tcp.ConnectAsync(opts.Host, opts.Port, connectCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
tcp.Dispose();
|
||||
throw;
|
||||
}
|
||||
tcp.NoDelay = true;
|
||||
|
||||
// The returned NetworkStream owns the socket (TcpClient.GetStream() uses ownsSocket: true),
|
||||
// so FrameChannel.ResetTransport() disposing this stream closes the underlying socket.
|
||||
Stream stream = tcp.GetStream();
|
||||
if (!opts.UseTls) return stream;
|
||||
|
||||
var ssl = new SslStream(stream, leaveInnerStreamOpen: false, (_, cert, _, errors) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(opts.ServerCertThumbprint))
|
||||
return string.Equals(cert?.GetCertHashString(), opts.ServerCertThumbprint, StringComparison.OrdinalIgnoreCase);
|
||||
return errors == SslPolicyErrors.None;
|
||||
});
|
||||
try
|
||||
{
|
||||
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = opts.Host }, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ssl.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
return ssl;
|
||||
};
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="FrameChannel"/> class.</summary>
|
||||
/// <param name="options">Configuration options for the historian client.</param>
|
||||
/// <param name="connect">Function to establish a connection stream.</param>
|
||||
/// <param name="logger">Logger instance for diagnostics.</param>
|
||||
public FrameChannel(
|
||||
WonderwareHistorianClientOptions options,
|
||||
Func<CancellationToken, Task<Stream>> connect,
|
||||
ILogger logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_connect = connect ?? throw new ArgumentNullException(nameof(connect));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether the channel is currently connected.</summary>
|
||||
public bool IsConnected => _stream is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Connects + performs the Hello handshake. Returns when the sidecar has accepted the
|
||||
/// hello. Throws on rejection (bad secret, version mismatch, or transport failure).
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token to stop the operation.</param>
|
||||
/// <returns>A task representing the asynchronous connection operation.</returns>
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
await _callGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ConnectInternalAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally { _callGate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends one request, waits for the matching reply. On transport failure, reconnects
|
||||
/// once and retries — broader retry policy lives in the calling layer.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The type of the request payload.</typeparam>
|
||||
/// <typeparam name="TReply">The type of the reply payload.</typeparam>
|
||||
/// <param name="requestKind">The message kind of the request.</param>
|
||||
/// <param name="expectedReplyKind">The expected message kind of the reply.</param>
|
||||
/// <param name="request">The request payload to send.</param>
|
||||
/// <param name="cancellationToken">Cancellation token to stop the operation.</param>
|
||||
/// <returns>A task that returns the reply payload.</returns>
|
||||
public async Task<TReply> InvokeAsync<TRequest, TReply>(
|
||||
MessageKind requestKind,
|
||||
MessageKind expectedReplyKind,
|
||||
TRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
where TReply : class
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(_options.EffectiveCallTimeout);
|
||||
|
||||
await _callGate.WaitAsync(timeout.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Lazy connect on first call.
|
||||
if (_stream is null) await ConnectInternalAsync(timeout.Token).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
return await ExchangeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or EndOfStreamException or ObjectDisposedException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Sidecar TCP transport failure on {Kind}; reconnecting", requestKind);
|
||||
ResetTransport();
|
||||
await ConnectInternalAsync(timeout.Token).ConfigureAwait(false);
|
||||
// One retry. If the second attempt also fails, propagate.
|
||||
return await ExchangeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally { _callGate.Release(); }
|
||||
}
|
||||
|
||||
private async Task<TReply> ExchangeAsync<TRequest, TReply>(
|
||||
MessageKind requestKind, MessageKind expectedReplyKind, TRequest request, CancellationToken ct)
|
||||
{
|
||||
await _writer!.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
|
||||
var frame = await _reader!.ReadFrameAsync(ct).ConfigureAwait(false)
|
||||
?? throw new EndOfStreamException("Sidecar closed connection before reply.");
|
||||
if (frame.Kind != expectedReplyKind)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Sidecar replied with kind {frame.Kind}; expected {expectedReplyKind}.");
|
||||
}
|
||||
return MessagePackSerializer.Deserialize<TReply>(frame.Body);
|
||||
}
|
||||
|
||||
private async Task ConnectInternalAsync(CancellationToken ct)
|
||||
{
|
||||
ResetTransport();
|
||||
|
||||
_stream = await _connect(ct).ConfigureAwait(false);
|
||||
_reader = new FrameReader(_stream, leaveOpen: true);
|
||||
_writer = new FrameWriter(_stream, leaveOpen: true);
|
||||
|
||||
var hello = new Hello
|
||||
{
|
||||
ProtocolMajor = Hello.CurrentMajor,
|
||||
ProtocolMinor = Hello.CurrentMinor,
|
||||
PeerName = _options.PeerName,
|
||||
SharedSecret = _options.SharedSecret,
|
||||
};
|
||||
await _writer.WriteAsync(MessageKind.Hello, hello, ct).ConfigureAwait(false);
|
||||
|
||||
var ackFrame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false)
|
||||
?? throw new EndOfStreamException("Sidecar closed connection before HelloAck.");
|
||||
if (ackFrame.Kind != MessageKind.HelloAck)
|
||||
{
|
||||
ResetTransport();
|
||||
throw new InvalidDataException($"Sidecar replied to Hello with kind {ackFrame.Kind}; expected HelloAck.");
|
||||
}
|
||||
|
||||
var ack = MessagePackSerializer.Deserialize<HelloAck>(ackFrame.Body);
|
||||
if (!ack.Accepted)
|
||||
{
|
||||
ResetTransport();
|
||||
throw new UnauthorizedAccessException(
|
||||
$"Sidecar rejected Hello: {ack.RejectReason ?? "<no reason>"}.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Sidecar TCP connected — host={Host}", ack.HostName);
|
||||
}
|
||||
|
||||
private void ResetTransport()
|
||||
{
|
||||
_writer?.Dispose();
|
||||
_reader?.Dispose();
|
||||
_stream?.Dispose();
|
||||
_writer = null;
|
||||
_reader = null;
|
||||
_stream = null;
|
||||
}
|
||||
|
||||
/// <summary>Releases all resources associated with this channel.</summary>
|
||||
/// <returns>A task representing the asynchronous disposal operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return ValueTask.CompletedTask;
|
||||
_disposed = true;
|
||||
ResetTransport();
|
||||
_callGate.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's <c>OpcQuality</c>)
|
||||
/// to an OPC UA <c>StatusCode</c> uint. Byte-identical port of the sidecar's
|
||||
/// <c>HistorianQualityMapper.Map</c> — kept in sync via parity tests rather than a
|
||||
/// shared assembly because the sidecar is .NET 4.8 (x64) and the client is .NET 10 (x64).
|
||||
/// </summary>
|
||||
internal static class QualityMapper
|
||||
{
|
||||
/// <summary>Maps an OPC DA quality byte to an OPC UA StatusCode.</summary>
|
||||
/// <param name="q">The OPC DA quality byte value.</param>
|
||||
/// <returns>An OPC UA StatusCode as a uint.</returns>
|
||||
public static uint Map(byte q) => q switch
|
||||
{
|
||||
// Good family (192+)
|
||||
192 => 0x00000000u, // Good
|
||||
216 => 0x00D80000u, // Good_LocalOverride
|
||||
|
||||
// Uncertain family (64-191)
|
||||
64 => 0x40000000u, // Uncertain
|
||||
68 => 0x40900000u, // Uncertain_LastUsableValue
|
||||
80 => 0x40930000u, // Uncertain_SensorNotAccurate
|
||||
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
|
||||
88 => 0x40950000u, // Uncertain_SubNormal
|
||||
|
||||
// Bad family (0-63)
|
||||
0 => 0x80000000u, // Bad
|
||||
4 => 0x80890000u, // Bad_ConfigurationError
|
||||
8 => 0x808A0000u, // Bad_NotConnected
|
||||
12 => 0x808B0000u, // Bad_DeviceFailure
|
||||
16 => 0x808C0000u, // Bad_SensorFailure
|
||||
20 => 0x80050000u, // Bad_CommunicationError
|
||||
24 => 0x808D0000u, // Bad_OutOfService
|
||||
32 => 0x80320000u, // Bad_WaitingForInitialData
|
||||
|
||||
// Unknown — fall back to category bucket so callers still get something usable.
|
||||
_ when q >= 192 => 0x00000000u,
|
||||
_ when q >= 64 => 0x40000000u,
|
||||
_ => 0x80000000u,
|
||||
};
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
// ============================================================================
|
||||
// Wire DTOs for the sidecar pipe protocol — byte-identical mirror of the
|
||||
// sidecar's Contracts.cs. The sidecar is .NET 4.8 x64; this client is .NET 10
|
||||
// x64. Both ends carry their own copy of these MessagePack DTOs and stay in
|
||||
// sync via the round-trip tests in PR 3.4 + the byte-equality parity test.
|
||||
//
|
||||
// MessagePack [Key] indices MUST match the sidecar's exactly. Adding a field
|
||||
// is an additive change as long as it lands at a fresh index on both sides;
|
||||
// reordering or removing keys is a wire break.
|
||||
//
|
||||
// Timestamps cross the wire as DateTime ticks (long) to dodge MessagePack's
|
||||
// DateTime kind/timezone quirks; both sides convert with DateTime(ticks, Utc).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>Single historical data point. Quality is the raw OPC DA byte; client maps to OPC UA StatusCode.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianSampleDto
|
||||
{
|
||||
/// <summary>MessagePack-serialized value bytes. Client deserializes per the tag's mx_data_type.</summary>
|
||||
[Key(0)] public byte[]? ValueBytes { get; set; }
|
||||
|
||||
/// <summary>Raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality).</summary>
|
||||
[Key(1)] public byte Quality { get; set; }
|
||||
|
||||
/// <summary>Gets the UTC timestamp in ticks.</summary>
|
||||
[Key(2)] public long TimestampUtcTicks { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Aggregate bucket; <c>Value</c> is null when the aggregate is unavailable for the bucket.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianAggregateSampleDto
|
||||
{
|
||||
/// <summary>Gets or sets the aggregate value.</summary>
|
||||
[Key(0)] public double? Value { get; set; }
|
||||
/// <summary>Gets or sets the UTC timestamp in ticks.</summary>
|
||||
[Key(1)] public long TimestampUtcTicks { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Historian event row.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianEventDto
|
||||
{
|
||||
/// <summary>Gets or sets the event identifier.</summary>
|
||||
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the event source name.</summary>
|
||||
[Key(1)] public string? Source { get; set; }
|
||||
/// <summary>Gets or sets the event time in UTC ticks.</summary>
|
||||
[Key(2)] public long EventTimeUtcTicks { get; set; }
|
||||
/// <summary>Gets or sets the received time in UTC ticks.</summary>
|
||||
[Key(3)] public long ReceivedTimeUtcTicks { get; set; }
|
||||
/// <summary>Gets or sets the event display text.</summary>
|
||||
[Key(4)] public string? DisplayText { get; set; }
|
||||
/// <summary>Gets or sets the event severity.</summary>
|
||||
[Key(5)] public ushort Severity { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Alarm event to persist back into the historian event store.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class AlarmHistorianEventDto
|
||||
{
|
||||
/// <summary>Gets or sets the event identifier.</summary>
|
||||
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the source name.</summary>
|
||||
[Key(1)] public string SourceName { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the condition identifier.</summary>
|
||||
[Key(2)] public string? ConditionId { get; set; }
|
||||
/// <summary>Gets or sets the alarm type.</summary>
|
||||
[Key(3)] public string AlarmType { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the alarm message.</summary>
|
||||
[Key(4)] public string? Message { get; set; }
|
||||
/// <summary>Gets or sets the alarm severity.</summary>
|
||||
[Key(5)] public ushort Severity { get; set; }
|
||||
/// <summary>Gets or sets the event time in UTC ticks.</summary>
|
||||
[Key(6)] public long EventTimeUtcTicks { get; set; }
|
||||
/// <summary>Gets or sets the acknowledgment comment.</summary>
|
||||
[Key(7)] public string? AckComment { get; set; }
|
||||
}
|
||||
|
||||
// ===== Read Raw =====
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadRawRequest
|
||||
{
|
||||
/// <summary>Gets or sets the tag name.</summary>
|
||||
[Key(0)] public string TagName { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the start time in UTC ticks.</summary>
|
||||
[Key(1)] public long StartUtcTicks { get; set; }
|
||||
/// <summary>Gets or sets the end time in UTC ticks.</summary>
|
||||
[Key(2)] public long EndUtcTicks { get; set; }
|
||||
/// <summary>Gets or sets the maximum number of values to read.</summary>
|
||||
[Key(3)] public int MaxValues { get; set; }
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadRawReply
|
||||
{
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets a value indicating whether the operation succeeded.</summary>
|
||||
[Key(1)] public bool Success { get; set; }
|
||||
/// <summary>Gets or sets the error message if the operation failed.</summary>
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
/// <summary>Gets or sets the historian samples.</summary>
|
||||
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
|
||||
}
|
||||
|
||||
// ===== Read Processed =====
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadProcessedRequest
|
||||
{
|
||||
/// <summary>Gets or sets the tag name.</summary>
|
||||
[Key(0)] public string TagName { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the start time in UTC ticks.</summary>
|
||||
[Key(1)] public long StartUtcTicks { get; set; }
|
||||
/// <summary>Gets or sets the end time in UTC ticks.</summary>
|
||||
[Key(2)] public long EndUtcTicks { get; set; }
|
||||
/// <summary>Gets or sets the interval in milliseconds.</summary>
|
||||
[Key(3)] public double IntervalMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Wonderware AnalogSummary column name: "Average", "Minimum", "Maximum", "ValueCount".
|
||||
/// The .NET 10 client maps OPC UA aggregate enum → column.
|
||||
/// </summary>
|
||||
[Key(4)] public string AggregateColumn { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(5)] public string CorrelationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadProcessedReply
|
||||
{
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets a value indicating whether the operation succeeded.</summary>
|
||||
[Key(1)] public bool Success { get; set; }
|
||||
/// <summary>Gets or sets the error message if the operation failed.</summary>
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
/// <summary>Gets or sets the aggregate sample buckets.</summary>
|
||||
[Key(3)] public HistorianAggregateSampleDto[] Buckets { get; set; } = Array.Empty<HistorianAggregateSampleDto>();
|
||||
}
|
||||
|
||||
// ===== Read At-Time =====
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadAtTimeRequest
|
||||
{
|
||||
/// <summary>Gets or sets the tag name.</summary>
|
||||
[Key(0)] public string TagName { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the timestamps in UTC ticks.</summary>
|
||||
[Key(1)] public long[] TimestampsUtcTicks { get; set; } = Array.Empty<long>();
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(2)] public string CorrelationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadAtTimeReply
|
||||
{
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets a value indicating whether the operation succeeded.</summary>
|
||||
[Key(1)] public bool Success { get; set; }
|
||||
/// <summary>Gets or sets the error message if the operation failed.</summary>
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
/// <summary>Gets or sets the historian samples.</summary>
|
||||
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
|
||||
}
|
||||
|
||||
// ===== Read Events =====
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadEventsRequest
|
||||
{
|
||||
/// <summary>Gets or sets the source name.</summary>
|
||||
[Key(0)] public string? SourceName { get; set; }
|
||||
/// <summary>Gets or sets the start time in UTC ticks.</summary>
|
||||
[Key(1)] public long StartUtcTicks { get; set; }
|
||||
/// <summary>Gets or sets the end time in UTC ticks.</summary>
|
||||
[Key(2)] public long EndUtcTicks { get; set; }
|
||||
/// <summary>Gets or sets the maximum number of events to read.</summary>
|
||||
[Key(3)] public int MaxEvents { get; set; }
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadEventsReply
|
||||
{
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets a value indicating whether the operation succeeded.</summary>
|
||||
[Key(1)] public bool Success { get; set; }
|
||||
/// <summary>Gets or sets the error message if the operation failed.</summary>
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
/// <summary>Gets or sets the historian events.</summary>
|
||||
[Key(3)] public HistorianEventDto[] Events { get; set; } = Array.Empty<HistorianEventDto>();
|
||||
}
|
||||
|
||||
// ===== Write Alarm Events =====
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteAlarmEventsRequest
|
||||
{
|
||||
/// <summary>Gets or sets the alarm historian events to write.</summary>
|
||||
[Key(0)] public AlarmHistorianEventDto[] Events { get; set; } = Array.Empty<AlarmHistorianEventDto>();
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(1)] public string CorrelationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteAlarmEventsReply
|
||||
{
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets a value indicating whether the operation succeeded.</summary>
|
||||
[Key(1)] public bool Success { get; set; }
|
||||
/// <summary>Gets or sets the error message if the operation failed.</summary>
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
|
||||
/// <summary>Per-event success flag, parallel to <see cref="WriteAlarmEventsRequest.Events"/>.</summary>
|
||||
[Key(3)] public bool[] PerEventOk { get; set; } = Array.Empty<bool>();
|
||||
|
||||
/// <summary>Per-event status parallel to the request's Events: 0=Ack, 1=Retry, 2=Permanent.
|
||||
/// Empty ⇒ an older sidecar that only sent <see cref="PerEventOk"/>; the client falls back to it.</summary>
|
||||
[Key(4)] public byte[] PerEventStatus { get; set; } = Array.Empty<byte>();
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
|
||||
/// <see cref="ReadFrameAsync"/> from multiple threads against the same instance. Mirror of
|
||||
/// the sidecar's <c>FrameReader</c>; kept byte-identical so the wire protocol stays stable.
|
||||
/// </summary>
|
||||
public sealed class FrameReader : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly bool _leaveOpen;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="FrameReader"/> class.</summary>
|
||||
/// <param name="stream">The stream to read frames from.</param>
|
||||
/// <param name="leaveOpen">True to leave the stream open after disposal; false to dispose it.</param>
|
||||
public FrameReader(Stream stream, bool leaveOpen = false)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
/// <summary>Reads a single frame from the stream.</summary>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
/// <returns>A tuple of the message kind and body bytes, or null at end-of-stream.</returns>
|
||||
public async Task<(MessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||
if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
|
||||
return null; // clean EOF on frame boundary
|
||||
|
||||
var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
|
||||
if (length < 0 || length > Framing.MaxFrameBodyBytes)
|
||||
throw new InvalidDataException($"Sidecar IPC frame length {length} out of range.");
|
||||
|
||||
// Read the kind byte asynchronously and cancellably — a synchronous ReadByte()
|
||||
// blocks the thread-pool thread and cannot be interrupted by the call-timeout token
|
||||
// if the peer stalls mid-frame (finding 005).
|
||||
var kindBuffer = new byte[Framing.KindByteSize];
|
||||
if (!await ReadExactAsync(kindBuffer, ct).ConfigureAwait(false))
|
||||
throw new EndOfStreamException("EOF after length prefix, before kind byte.");
|
||||
|
||||
var body = new byte[length];
|
||||
if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
|
||||
throw new EndOfStreamException("EOF mid-frame.");
|
||||
|
||||
return ((MessageKind)kindBuffer[0], body);
|
||||
}
|
||||
|
||||
/// <summary>Deserializes a frame body from MessagePack binary format.</summary>
|
||||
/// <typeparam name="T">The target type to deserialize the body into.</typeparam>
|
||||
/// <param name="body">The frame body bytes to deserialize.</param>
|
||||
/// <returns>The deserialized object of the specified type.</returns>
|
||||
public static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
|
||||
|
||||
private async Task<bool> ReadExactAsync(byte[] buffer, CancellationToken ct)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await _stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), ct).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
if (offset == 0) return false;
|
||||
throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Releases the stream resources if <c>leaveOpen</c> was false.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_leaveOpen) _stream.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
|
||||
/// <see cref="SemaphoreSlim"/>. Byte-identical mirror of the sidecar's FrameWriter.
|
||||
/// </summary>
|
||||
public sealed class FrameWriter : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly bool _leaveOpen;
|
||||
|
||||
/// <summary>Initializes a new instance of the FrameWriter class.</summary>
|
||||
/// <param name="stream">The underlying stream to write frames to.</param>
|
||||
/// <param name="leaveOpen">If true, the stream is not disposed when this writer is disposed.</param>
|
||||
public FrameWriter(Stream stream, bool leaveOpen = false)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
/// <summary>Writes a length-prefixed, kind-tagged MessagePack frame to the stream.</summary>
|
||||
/// <typeparam name="T">The type of the message to serialize.</typeparam>
|
||||
/// <param name="kind">The frame message kind tag.</param>
|
||||
/// <param name="message">The message object to serialize and write.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public async Task WriteAsync<T>(MessageKind kind, T message, CancellationToken ct)
|
||||
{
|
||||
var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
|
||||
if (body.Length > Framing.MaxFrameBodyBytes)
|
||||
throw new InvalidOperationException(
|
||||
$"Sidecar IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
|
||||
|
||||
// 5-byte header: [4-byte big-endian body length][1-byte message kind].
|
||||
// The kind byte is folded into the header array so every write inside the gate
|
||||
// is async+cancellable — a synchronous Stream.WriteByte() blocks the calling
|
||||
// thread-pool thread and cannot be interrupted by the call-timeout token when
|
||||
// the peer's receive window is full (same class of bug as finding 005 on reads).
|
||||
var header = new byte[Framing.LengthPrefixSize + Framing.KindByteSize];
|
||||
header[0] = (byte)((body.Length >> 24) & 0xFF);
|
||||
header[1] = (byte)((body.Length >> 16) & 0xFF);
|
||||
header[2] = (byte)((body.Length >> 8) & 0xFF);
|
||||
header[3] = (byte)( body.Length & 0xFF);
|
||||
header[4] = (byte)kind;
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(header, ct).ConfigureAwait(false);
|
||||
await _stream.WriteAsync(body, ct).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>Disposes the writer and underlying stream (if not left open).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_gate.Dispose();
|
||||
if (!_leaveOpen) _stream.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Length-prefixed framing constants for the Wonderware historian sidecar pipe protocol.
|
||||
/// Each frame on the wire is:
|
||||
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
|
||||
/// Length is the body size only; the kind byte is not part of the prefixed length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Byte-identical mirror of the sidecar's <c>Driver.Historian.Wonderware.Ipc.Framing</c>.
|
||||
/// The sidecar is .NET 4.8 x64; this client is .NET 10 x64 — the differing target
|
||||
/// frameworks mean they cannot share an assembly, so the wire constants are duplicated
|
||||
/// here. PR 3.4 ships round-trip tests that pin the byte-level parity.
|
||||
/// </remarks>
|
||||
public static class Framing
|
||||
{
|
||||
public const int LengthPrefixSize = 4;
|
||||
public const int KindByteSize = 1;
|
||||
|
||||
/// <summary>16 MiB cap protects the receiver from a hostile or buggy peer.</summary>
|
||||
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire identifier for each historian sidecar message. Values are stable — never reorder;
|
||||
/// append new contracts at the end. The .NET 10 client and the .NET 4.8 sidecar must
|
||||
/// agree on every value here. Byte-identical with the sidecar enum.
|
||||
/// </summary>
|
||||
public enum MessageKind : byte
|
||||
{
|
||||
Hello = 0x01,
|
||||
HelloAck = 0x02,
|
||||
|
||||
ReadRawRequest = 0x10,
|
||||
ReadRawReply = 0x11,
|
||||
|
||||
ReadProcessedRequest = 0x12,
|
||||
ReadProcessedReply = 0x13,
|
||||
|
||||
ReadAtTimeRequest = 0x14,
|
||||
ReadAtTimeReply = 0x15,
|
||||
|
||||
ReadEventsRequest = 0x16,
|
||||
ReadEventsReply = 0x17,
|
||||
|
||||
WriteAlarmEventsRequest = 0x20,
|
||||
WriteAlarmEventsReply = 0x21,
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// First frame of every connection. Advertises the sidecar protocol version and the
|
||||
/// per-process shared secret the supervisor passed at spawn time. Byte-identical mirror
|
||||
/// of the sidecar's <c>Hello</c> contract.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class Hello
|
||||
{
|
||||
public const int CurrentMajor = 1;
|
||||
public const int CurrentMinor = 0;
|
||||
|
||||
/// <summary>Gets or sets the protocol major version.</summary>
|
||||
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
|
||||
/// <summary>Gets or sets the protocol minor version.</summary>
|
||||
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
|
||||
/// <summary>Gets or sets the peer name identifying the client.</summary>
|
||||
[Key(2)] public string PeerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Per-process shared secret — verified against the value the supervisor passed at spawn time.</summary>
|
||||
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledgment response to a <see cref="Hello"/> frame. Indicates acceptance and the remote host name.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HelloAck
|
||||
{
|
||||
/// <summary>Gets or sets the protocol major version.</summary>
|
||||
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
|
||||
/// <summary>Gets or sets the protocol minor version.</summary>
|
||||
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the connection was accepted.</summary>
|
||||
[Key(2)] public bool Accepted { get; set; }
|
||||
/// <summary>Gets or sets the rejection reason if the connection was not accepted.</summary>
|
||||
[Key(3)] public string? RejectReason { get; set; }
|
||||
/// <summary>Gets or sets the host name of the remote server.</summary>
|
||||
[Key(4)] public string HostName { get; set; } = string.Empty;
|
||||
}
|
||||
-607
@@ -1,607 +0,0 @@
|
||||
using MessagePack;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
using ClientHistorianEventDto = ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc.HistorianEventDto;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
||||
|
||||
/// <summary>
|
||||
/// .NET 10 client for the Wonderware historian sidecar (PR 3.3 protocol). Implements both
|
||||
/// <see cref="IHistorianDataSource"/> (read paths consumed by
|
||||
/// <c>Server.History.IHistoryRouter</c>) and <see cref="IAlarmHistorianWriter"/>
|
||||
/// (alarm-event drain consumed by <c>Core.AlarmHistorian.SqliteStoreAndForwardSink</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The client owns a single <see cref="FrameChannel"/> with one in-flight call at a time;
|
||||
/// concurrent calls serialize on the channel's gate. Reconnect is handled inside the
|
||||
/// channel — transient transport failures retry once before propagating.
|
||||
/// </remarks>
|
||||
public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHistorianWriter, IAsyncDisposable
|
||||
{
|
||||
private readonly FrameChannel _channel;
|
||||
private readonly object _healthLock = new();
|
||||
private DateTime? _lastSuccessUtc;
|
||||
private DateTime? _lastFailureUtc;
|
||||
private string? _lastError;
|
||||
private long _totalQueries;
|
||||
private long _totalSuccesses;
|
||||
private long _totalFailures;
|
||||
private int _consecutiveFailures;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a client that connects to the Wonderware historian sidecar over TCP.
|
||||
/// Tests that need an in-process duplex pair use the <see cref="ForTests"/> factory.
|
||||
/// </summary>
|
||||
/// <param name="options">The client connection options.</param>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
public WonderwareHistorianClient(WonderwareHistorianClientOptions options, ILogger<WonderwareHistorianClient>? logger = null)
|
||||
: this(options, ct => FrameChannel.DefaultTcpConnectFactory(options, ct), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — inject an arbitrary connect callback.</summary>
|
||||
/// <param name="options">The client connection options.</param>
|
||||
/// <param name="connect">A callback that establishes the connection stream.</param>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
/// <returns>A new WonderwareHistorianClient configured for testing.</returns>
|
||||
public static WonderwareHistorianClient ForTests(
|
||||
WonderwareHistorianClientOptions options,
|
||||
Func<CancellationToken, Task<Stream>> connect,
|
||||
ILogger<WonderwareHistorianClient>? logger = null)
|
||||
=> new(options, connect, logger);
|
||||
|
||||
private WonderwareHistorianClient(
|
||||
WonderwareHistorianClientOptions options,
|
||||
Func<CancellationToken, Task<Stream>> connect,
|
||||
ILogger<WonderwareHistorianClient>? logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var log = (ILogger?)logger ?? NullLogger.Instance;
|
||||
_channel = new FrameChannel(options, connect, log);
|
||||
}
|
||||
|
||||
// ===== IHistorianDataSource =====
|
||||
|
||||
/// <summary>Asynchronously reads raw historical data for a tag within a time range.</summary>
|
||||
/// <param name="fullReference">The full reference path of the tag to read.</param>
|
||||
/// <param name="startUtc">The start time in UTC for the read range.</param>
|
||||
/// <param name="endUtc">The end time in UTC for the read range.</param>
|
||||
/// <param name="maxValuesPerNode">The maximum number of values to return.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that returns the historical read result.</returns>
|
||||
public async Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var req = new ReadRawRequest
|
||||
{
|
||||
TagName = fullReference,
|
||||
StartUtcTicks = startUtc.Ticks,
|
||||
EndUtcTicks = endUtc.Ticks,
|
||||
MaxValues = (int)Math.Min(maxValuesPerNode, int.MaxValue),
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
var reply = await InvokeAndClassifyAsync<ReadRawRequest, ReadRawReply>(
|
||||
MessageKind.ReadRawRequest, MessageKind.ReadRawReply, req,
|
||||
r => (r.Success, r.Error), "ReadRaw", cancellationToken).ConfigureAwait(false);
|
||||
return new HistoryReadResult(ToSnapshots(reply.Samples), ContinuationPoint: null);
|
||||
}
|
||||
|
||||
/// <summary>Asynchronously reads processed historical data with aggregation for a tag within a time range.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="HistoryAggregateType.Total"/> is derived client-side as the time-weighted
|
||||
/// Average × interval-seconds; Wonderware AnalogSummary exposes no Total column. The wire
|
||||
/// request is issued with the Average column and each returned bucket value is scaled by
|
||||
/// <c>interval.TotalSeconds</c>, preserving the bucket's status code and timestamp. All
|
||||
/// other aggregates pass through unchanged.
|
||||
/// </remarks>
|
||||
/// <param name="fullReference">The full reference path of the tag to read.</param>
|
||||
/// <param name="startUtc">The start time in UTC for the read range.</param>
|
||||
/// <param name="endUtc">The end time in UTC for the read range.</param>
|
||||
/// <param name="interval">The time interval for aggregation.</param>
|
||||
/// <param name="aggregate">The type of aggregation to apply.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that returns the historical read result with aggregated data.</returns>
|
||||
public async Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
{
|
||||
// Total has no AnalogSummary column — request the time-weighted Average and scale
|
||||
// client-side below (Total = Average × interval-seconds).
|
||||
var isDerivedTotal = aggregate == HistoryAggregateType.Total;
|
||||
var wireAggregate = isDerivedTotal ? HistoryAggregateType.Average : aggregate;
|
||||
|
||||
var req = new ReadProcessedRequest
|
||||
{
|
||||
TagName = fullReference,
|
||||
StartUtcTicks = startUtc.Ticks,
|
||||
EndUtcTicks = endUtc.Ticks,
|
||||
IntervalMs = interval.TotalMilliseconds,
|
||||
AggregateColumn = MapAggregate(wireAggregate),
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
var reply = await InvokeAndClassifyAsync<ReadProcessedRequest, ReadProcessedReply>(
|
||||
MessageKind.ReadProcessedRequest, MessageKind.ReadProcessedReply, req,
|
||||
r => (r.Success, r.Error), "ReadProcessed", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var buckets = isDerivedTotal
|
||||
? ScaleAverageToTotal(reply.Buckets, interval.TotalSeconds)
|
||||
: reply.Buckets;
|
||||
return new HistoryReadResult(ToAggregateSnapshots(buckets), ContinuationPoint: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives <see cref="HistoryAggregateType.Total"/> buckets from time-weighted Average
|
||||
/// buckets using the time-integral identity Total = Average × interval-seconds. Null
|
||||
/// (unavailable) buckets are carried through unscaled so the downstream null→BadNoData
|
||||
/// mapping still fires; non-null values are multiplied by <paramref name="intervalSeconds"/>.
|
||||
/// </summary>
|
||||
private static HistorianAggregateSampleDto[] ScaleAverageToTotal(
|
||||
HistorianAggregateSampleDto[] averages, double intervalSeconds)
|
||||
{
|
||||
if (averages.Length == 0) return averages;
|
||||
var totals = new HistorianAggregateSampleDto[averages.Length];
|
||||
for (var i = 0; i < averages.Length; i++)
|
||||
{
|
||||
var avg = averages[i];
|
||||
totals[i] = new HistorianAggregateSampleDto
|
||||
{
|
||||
// Null (unavailable) average → null total (→ BadNoData downstream).
|
||||
Value = avg.Value is { } v ? v * intervalSeconds : null,
|
||||
TimestampUtcTicks = avg.TimestampUtcTicks,
|
||||
};
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
/// <summary>Asynchronously reads historical data at specific timestamps for a tag.</summary>
|
||||
/// <param name="fullReference">The full reference path of the tag to read.</param>
|
||||
/// <param name="timestampsUtc">The specific timestamps in UTC to read values for.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that returns the historical read result with values at the specified times.</returns>
|
||||
public async Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
var ticks = new long[timestampsUtc.Count];
|
||||
for (var i = 0; i < timestampsUtc.Count; i++) ticks[i] = timestampsUtc[i].Ticks;
|
||||
|
||||
var req = new ReadAtTimeRequest
|
||||
{
|
||||
TagName = fullReference,
|
||||
TimestampsUtcTicks = ticks,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
var reply = await InvokeAndClassifyAsync<ReadAtTimeRequest, ReadAtTimeReply>(
|
||||
MessageKind.ReadAtTimeRequest, MessageKind.ReadAtTimeReply, req,
|
||||
r => (r.Success, r.Error), "ReadAtTime", cancellationToken).ConfigureAwait(false);
|
||||
return new HistoryReadResult(AlignAtTimeSnapshots(timestampsUtc, reply.Samples), ContinuationPoint: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles a <c>ReadAtTime</c> sidecar reply against the requested timestamps to
|
||||
/// honour the <see cref="IHistorianDataSource.ReadAtTimeAsync"/> contract: the result
|
||||
/// MUST have exactly one snapshot per requested timestamp, in request order. The sidecar
|
||||
/// is not required to return a sample for every timestamp (e.g. it may drop
|
||||
/// boundary-less timestamps) nor to preserve order, so each requested timestamp is
|
||||
/// matched by ticks; any timestamp the sidecar did not return is filled with a
|
||||
/// Bad-quality (<c>0x80000000</c>) snapshot rather than positionally misaligning values.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<DataValueSnapshot> AlignAtTimeSnapshots(
|
||||
IReadOnlyList<DateTime> timestampsUtc, HistorianSampleDto[] samples)
|
||||
{
|
||||
// Index returned samples by timestamp ticks. Duplicate timestamps keep the first.
|
||||
var byTicks = new Dictionary<long, HistorianSampleDto>(samples.Length);
|
||||
foreach (var sample in samples)
|
||||
byTicks.TryAdd(sample.TimestampUtcTicks, sample);
|
||||
|
||||
var result = new DataValueSnapshot[timestampsUtc.Count];
|
||||
for (var i = 0; i < timestampsUtc.Count; i++)
|
||||
{
|
||||
var requested = DateTime.SpecifyKind(timestampsUtc[i], DateTimeKind.Utc);
|
||||
if (byTicks.TryGetValue(requested.Ticks, out var dto))
|
||||
{
|
||||
result[i] = new DataValueSnapshot(
|
||||
Value: DeserializeSampleValue(dto.ValueBytes),
|
||||
StatusCode: QualityMapper.Map(dto.Quality),
|
||||
SourceTimestampUtc: requested,
|
||||
ServerTimestampUtc: DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Gap — sidecar returned no sample for this timestamp. Per the contract this
|
||||
// is a Bad-quality snapshot stamped at the requested time, not a dropped row.
|
||||
result[i] = new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: 0x80000000u, // Bad
|
||||
SourceTimestampUtc: requested,
|
||||
ServerTimestampUtc: DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Asynchronously reads historical events within a time range.</summary>
|
||||
/// <param name="sourceName">The source name filter for events, or null to read all sources.</param>
|
||||
/// <param name="startUtc">The start time in UTC for the read range.</param>
|
||||
/// <param name="endUtc">The end time in UTC for the read range.</param>
|
||||
/// <param name="maxEvents">The maximum number of events to return.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that returns the historical events result.</returns>
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var req = new ReadEventsRequest
|
||||
{
|
||||
SourceName = sourceName,
|
||||
StartUtcTicks = startUtc.Ticks,
|
||||
EndUtcTicks = endUtc.Ticks,
|
||||
MaxEvents = maxEvents,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
var reply = await InvokeAndClassifyAsync<ReadEventsRequest, ReadEventsReply>(
|
||||
MessageKind.ReadEventsRequest, MessageKind.ReadEventsReply, req,
|
||||
r => (r.Success, r.Error), "ReadEvents", cancellationToken).ConfigureAwait(false);
|
||||
return new HistoricalEventsResult(ToHistoricalEvents(reply.Events), ContinuationPoint: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of operation counters and the single TCP channel's connection
|
||||
/// state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This client owns one TCP channel to the sidecar — it has no notion of
|
||||
/// separate process / event connections and no per-node telemetry. The single channel's
|
||||
/// connected state is reported for both <see cref="HistorianHealthSnapshot.ProcessConnectionOpen"/>
|
||||
/// and <see cref="HistorianHealthSnapshot.EventConnectionOpen"/>, and
|
||||
/// <see cref="HistorianHealthSnapshot.ActiveProcessNode"/> /
|
||||
/// <see cref="HistorianHealthSnapshot.ActiveEventNode"/> /
|
||||
/// <see cref="HistorianHealthSnapshot.Nodes"/> are intentionally null/empty. Consumers
|
||||
/// that need to distinguish two connections should read another driver. (Finding 010.)
|
||||
/// <para>
|
||||
/// All six counter fields (TotalQueries, TotalSuccesses, TotalFailures,
|
||||
/// ConsecutiveFailures, LastSuccessTime, LastFailureTime, LastError) are mutated
|
||||
/// exclusively under <c>_healthLock</c>, so the snapshot is internally consistent —
|
||||
/// in particular <c>TotalSuccesses + TotalFailures == TotalQueries</c> at every
|
||||
/// observed snapshot (a call that has started but not yet completed has not
|
||||
/// incremented any counter). (Finding 003 / 004.)
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public HistorianHealthSnapshot GetHealthSnapshot()
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
return new HistorianHealthSnapshot(
|
||||
TotalQueries: _totalQueries,
|
||||
TotalSuccesses: _totalSuccesses,
|
||||
TotalFailures: _totalFailures,
|
||||
ConsecutiveFailures: _consecutiveFailures,
|
||||
LastSuccessTime: _lastSuccessUtc,
|
||||
LastFailureTime: _lastFailureUtc,
|
||||
LastError: _lastError,
|
||||
ProcessConnectionOpen: _channel.IsConnected,
|
||||
EventConnectionOpen: _channel.IsConnected,
|
||||
ActiveProcessNode: null,
|
||||
ActiveEventNode: null,
|
||||
Nodes: []);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== IAlarmHistorianWriter =====
|
||||
|
||||
/// <summary>
|
||||
/// Writes a batch of alarm events to the Wonderware historian via the sidecar.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Per-event status:</b> when the sidecar populates the additive
|
||||
/// <see cref="WriteAlarmEventsReply.PerEventStatus"/> wire field (0=Ack, 1=Retry,
|
||||
/// 2=Permanent), each slot maps directly to <see cref="HistorianWriteOutcome.Ack"/> /
|
||||
/// <see cref="HistorianWriteOutcome.RetryPlease"/> / <see cref="HistorianWriteOutcome.PermanentFail"/>.
|
||||
/// The sidecar emits <c>Permanent</c> for structurally-malformed (poison) events,
|
||||
/// so the store-and-forward drain worker dead-letters them immediately instead of
|
||||
/// looping to the retry cap. An older sidecar that sends only the legacy
|
||||
/// <see cref="WriteAlarmEventsReply.PerEventOk"/> boolean is handled by the
|
||||
/// fallback path below (true→Ack, false→RetryPlease) for rolling-deploy back-compat.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Documented boundary:</b> only <i>structurally</i>-malformed events surface as
|
||||
/// <see cref="HistorianWriteOutcome.PermanentFail"/>. A structurally-valid event that
|
||||
/// the AAH historian SDK rejects for a deeper, semantic reason still maps to
|
||||
/// <see cref="HistorianWriteOutcome.RetryPlease"/> (→ retry cap), because the sidecar's
|
||||
/// writer returns only a transient/persisted boolean for events it actually attempts.
|
||||
/// Surfacing richer SDK-semantic permanent rejections requires the infra-gated
|
||||
/// <c>AahClientManagedAlarmEventWriter</c> to report a status code rather than a bool.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Transport or deserialization failures, and any whole-call failure
|
||||
/// (<c>Success=false</c>), return <see cref="HistorianWriteOutcome.RetryPlease"/> for
|
||||
/// every event in the batch; the drain worker's backoff controls recovery.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="batch">The batch of alarm historian events to write.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that returns per-event write outcomes.</returns>
|
||||
public async Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(batch);
|
||||
if (batch.Count == 0) return [];
|
||||
|
||||
var dtos = new AlarmHistorianEventDto[batch.Count];
|
||||
for (var i = 0; i < batch.Count; i++) dtos[i] = ToDto(batch[i]);
|
||||
|
||||
var req = new WriteAlarmEventsRequest
|
||||
{
|
||||
Events = dtos,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var reply = await InvokeAsync<WriteAlarmEventsRequest, WriteAlarmEventsReply>(
|
||||
MessageKind.WriteAlarmEventsRequest, MessageKind.WriteAlarmEventsReply, req,
|
||||
r => (r.Success, r.Error), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Whole-call failure → transient retry for every event in the batch.
|
||||
if (!reply.Success)
|
||||
{
|
||||
var fail = new HistorianWriteOutcome[batch.Count];
|
||||
Array.Fill(fail, HistorianWriteOutcome.RetryPlease);
|
||||
return fail;
|
||||
}
|
||||
|
||||
// Prefer the granular per-event status when the sidecar provides it (new wire
|
||||
// field); fall back to the legacy PerEventOk bool for older sidecars. The sidecar
|
||||
// emits status 2 (Permanent) for structurally-malformed poison events so they
|
||||
// dead-letter immediately rather than retrying to the cap.
|
||||
if (reply.PerEventStatus is { Length: > 0 } status && status.Length == batch.Count)
|
||||
{
|
||||
var statusOutcomes = new HistorianWriteOutcome[batch.Count];
|
||||
for (var i = 0; i < batch.Count; i++)
|
||||
statusOutcomes[i] = status[i] switch
|
||||
{
|
||||
0 => HistorianWriteOutcome.Ack,
|
||||
2 => HistorianWriteOutcome.PermanentFail,
|
||||
_ => HistorianWriteOutcome.RetryPlease, // 1 or unknown
|
||||
};
|
||||
return statusOutcomes;
|
||||
}
|
||||
|
||||
// Legacy fallback: PerEventOk[i] = true → Ack; false → RetryPlease. An older
|
||||
// sidecar without PerEventStatus can never signal PermanentFail through this
|
||||
// path, so a poison event retries to the drain worker's cap.
|
||||
var outcomes = new HistorianWriteOutcome[batch.Count];
|
||||
for (var i = 0; i < batch.Count; i++)
|
||||
{
|
||||
var ok = i < reply.PerEventOk.Length && reply.PerEventOk[i];
|
||||
outcomes[i] = ok ? HistorianWriteOutcome.Ack : HistorianWriteOutcome.RetryPlease;
|
||||
}
|
||||
return outcomes;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Transport / deserialization failure — every event is retry-please. The drain
|
||||
// worker's backoff handles recovery. PermanentFail is only emitted from the
|
||||
// success path's PerEventStatus mapping, never from a transport failure.
|
||||
var fail = new HistorianWriteOutcome[batch.Count];
|
||||
Array.Fill(fail, HistorianWriteOutcome.RetryPlease);
|
||||
return fail;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Constants =====
|
||||
|
||||
/// <summary>
|
||||
/// Per-sample ValueBytes size cap. MessagePack with the default
|
||||
/// <see cref="MessagePack.Resolvers.StandardResolver"/> (primitive-only — no typeless
|
||||
/// or dynamic-type resolution) is not susceptible to type-confusion gadget chains, but
|
||||
/// we still cap the per-sample byte budget to guard against a buggy or unexpectedly
|
||||
/// large peer payload. 64 KiB is well above any primitive historian value.
|
||||
/// (Finding 007 — NuGetAuditSuppress GHSA-37gx-xxp4-5rgx / GHSA-w3x6-4m5h-cxqf.)
|
||||
/// </summary>
|
||||
private const int MaxValueBytesPerSample = 64 * 1024;
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
/// <summary>
|
||||
/// Sends one request through the channel and records the outcome (transport success or
|
||||
/// transport failure) under a single <c>_healthLock</c> acquisition that also bumps
|
||||
/// <c>_totalQueries</c>. Sidecar-level success / failure is NOT classified here — the
|
||||
/// caller passes that through <see cref="InvokeAndClassifyAsync"/> instead. (Finding
|
||||
/// 003 / 004: all six counter fields share one synchronization mechanism so a snapshot
|
||||
/// can never observe a torn state.)
|
||||
/// </summary>
|
||||
private async Task<TReply> InvokeAsync<TRequest, TReply>(
|
||||
MessageKind requestKind, MessageKind expectedReplyKind, TRequest request,
|
||||
Func<TReply, (bool ok, string? error)> evaluate, CancellationToken ct)
|
||||
where TReply : class
|
||||
{
|
||||
try
|
||||
{
|
||||
var reply = await _channel.InvokeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, ct).ConfigureAwait(false);
|
||||
// Classify transport+sidecar in one lock so TotalQueries/TotalSuccesses/
|
||||
// TotalFailures move together and no intermediate "success-then-undo" state is
|
||||
// visible to a concurrent GetHealthSnapshot.
|
||||
var (ok, error) = evaluate(reply);
|
||||
RecordOutcome(ok, error);
|
||||
return reply;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordOutcome(success: false, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience wrapper around <see cref="InvokeAsync"/> that throws
|
||||
/// <see cref="InvalidOperationException"/> on a sidecar-reported failure. Used by the
|
||||
/// <see cref="IHistorianDataSource"/> read methods.
|
||||
/// </summary>
|
||||
private async Task<TReply> InvokeAndClassifyAsync<TRequest, TReply>(
|
||||
MessageKind requestKind, MessageKind expectedReplyKind, TRequest request,
|
||||
Func<TReply, (bool ok, string? error)> evaluate, string op, CancellationToken ct)
|
||||
where TReply : class
|
||||
{
|
||||
var reply = await InvokeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, evaluate, ct).ConfigureAwait(false);
|
||||
var (ok, error) = evaluate(reply);
|
||||
if (!ok)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sidecar {op} failed: {error ?? "<no message>"}.");
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the outcome of a single call — increments <c>_totalQueries</c> and exactly
|
||||
/// one of <c>_totalSuccesses</c> / <c>_totalFailures</c> under a single
|
||||
/// <c>_healthLock</c> acquisition. (Findings 003 + 004.)
|
||||
/// </summary>
|
||||
private void RecordOutcome(bool success, string? error)
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
_totalQueries++;
|
||||
if (success)
|
||||
{
|
||||
_totalSuccesses++;
|
||||
_consecutiveFailures = 0;
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
_totalFailures++;
|
||||
_consecutiveFailures++;
|
||||
_lastFailureUtc = DateTime.UtcNow;
|
||||
_lastError = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a sample's value bytes using the MessagePack default
|
||||
/// <see cref="MessagePack.Resolvers.StandardResolver"/> (primitive types only — no
|
||||
/// typeless or dynamic-type resolution). A per-sample size cap guards against a
|
||||
/// hostile or buggy peer sending an unexpectedly large payload before deserialization
|
||||
/// allocates memory for it. (Finding 007.)
|
||||
/// </summary>
|
||||
private static object? DeserializeSampleValue(byte[]? valueBytes)
|
||||
{
|
||||
if (valueBytes is null) return null;
|
||||
if (valueBytes.Length > MaxValueBytesPerSample)
|
||||
throw new InvalidDataException(
|
||||
$"Sidecar sample ValueBytes length {valueBytes.Length} exceeds the {MaxValueBytesPerSample}-byte cap.");
|
||||
// Deserializes using the default resolver which only handles primitive types
|
||||
// (bool, int, long, float, double, string, byte[], DateTime, etc.). The resolver
|
||||
// does NOT support TypelessContractlessStandardResolver so no type-confusion gadget
|
||||
// chains are reachable from this call site.
|
||||
return MessagePackSerializer.Deserialize<object>(valueBytes);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DataValueSnapshot> ToSnapshots(HistorianSampleDto[] dtos)
|
||||
{
|
||||
if (dtos.Length == 0) return [];
|
||||
var snapshots = new DataValueSnapshot[dtos.Length];
|
||||
for (var i = 0; i < dtos.Length; i++)
|
||||
{
|
||||
var dto = dtos[i];
|
||||
snapshots[i] = new DataValueSnapshot(
|
||||
Value: DeserializeSampleValue(dto.ValueBytes),
|
||||
StatusCode: QualityMapper.Map(dto.Quality),
|
||||
SourceTimestampUtc: new DateTime(dto.TimestampUtcTicks, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: DateTime.UtcNow);
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DataValueSnapshot> ToAggregateSnapshots(HistorianAggregateSampleDto[] dtos)
|
||||
{
|
||||
if (dtos.Length == 0) return [];
|
||||
var snapshots = new DataValueSnapshot[dtos.Length];
|
||||
for (var i = 0; i < dtos.Length; i++)
|
||||
{
|
||||
var dto = dtos[i];
|
||||
// Null aggregate value → BadNoData per Core.Abstractions HistoryReadResult convention.
|
||||
snapshots[i] = new DataValueSnapshot(
|
||||
Value: dto.Value,
|
||||
StatusCode: dto.Value is null ? 0x800E0000u /* BadNoData */ : 0x00000000u /* Good */,
|
||||
SourceTimestampUtc: new DateTime(dto.TimestampUtcTicks, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: DateTime.UtcNow);
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<HistoricalEvent> ToHistoricalEvents(ClientHistorianEventDto[] dtos)
|
||||
{
|
||||
if (dtos.Length == 0) return [];
|
||||
var events = new HistoricalEvent[dtos.Length];
|
||||
for (var i = 0; i < dtos.Length; i++)
|
||||
{
|
||||
var dto = dtos[i];
|
||||
events[i] = new HistoricalEvent(
|
||||
EventId: dto.EventId,
|
||||
SourceName: dto.Source,
|
||||
EventTimeUtc: new DateTime(dto.EventTimeUtcTicks, DateTimeKind.Utc),
|
||||
ReceivedTimeUtc: new DateTime(dto.ReceivedTimeUtcTicks, DateTimeKind.Utc),
|
||||
Message: dto.DisplayText,
|
||||
Severity: dto.Severity);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private static AlarmHistorianEventDto ToDto(AlarmHistorianEvent evt) => new()
|
||||
{
|
||||
EventId = evt.AlarmId,
|
||||
SourceName = evt.EquipmentPath,
|
||||
ConditionId = evt.AlarmName,
|
||||
AlarmType = evt.AlarmTypeName + ":" + evt.EventKind,
|
||||
Message = evt.Message,
|
||||
Severity = MapSeverity(evt.Severity),
|
||||
EventTimeUtcTicks = evt.TimestampUtc.Ticks,
|
||||
AckComment = evt.Comment,
|
||||
};
|
||||
|
||||
private static ushort MapSeverity(AlarmSeverity severity) => severity switch
|
||||
{
|
||||
AlarmSeverity.Low => 250,
|
||||
AlarmSeverity.Medium => 500,
|
||||
AlarmSeverity.High => 700,
|
||||
AlarmSeverity.Critical => 900,
|
||||
_ => 500,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps an OPC UA aggregate to its Wonderware AnalogSummary column name. There is no
|
||||
/// Total column — <see cref="HistoryAggregateType.Total"/> is derived client-side in
|
||||
/// <see cref="ReadProcessedAsync"/> by requesting Average, so it is never passed here.
|
||||
/// </summary>
|
||||
private static string MapAggregate(HistoryAggregateType aggregate) => aggregate switch
|
||||
{
|
||||
HistoryAggregateType.Average => "Average",
|
||||
HistoryAggregateType.Minimum => "Minimum",
|
||||
HistoryAggregateType.Maximum => "Maximum",
|
||||
HistoryAggregateType.Count => "ValueCount",
|
||||
_ => throw new NotSupportedException($"Unknown HistoryAggregateType {aggregate}"),
|
||||
};
|
||||
|
||||
/// <summary>Asynchronously disposes the client and its underlying TCP channel.</summary>
|
||||
/// <returns>A task that completes when the client has been disposed.</returns>
|
||||
public ValueTask DisposeAsync() => _channel.DisposeAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Synchronous dispose required by <see cref="IDisposable"/> on
|
||||
/// <see cref="IHistorianDataSource"/>. The underlying channel's async cleanup runs the
|
||||
/// TCP socket teardown, which can block briefly on OS handle release — strictly speaking
|
||||
/// it is not non-blocking — but the <c>GetAwaiter()/GetResult()</c> bridge is
|
||||
/// deadlock-safe because the cleanup never awaits a captured
|
||||
/// <see cref="System.Threading.SynchronizationContext"/> nor takes any lock that the
|
||||
/// caller could hold. (Finding 010.)
|
||||
/// </summary>
|
||||
public void Dispose() => _channel.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
||||
|
||||
/// <summary>
|
||||
/// TCP-connect probe for the <see cref="WonderwareHistorianClientOptions"/>-shaped driver
|
||||
/// config. Opens a socket to the configured <c>Host:Port</c> (optionally performing the TLS
|
||||
/// client handshake when <c>UseTls</c> is set, reusing the same pinned-thumbprint / CA-chain
|
||||
/// validation as <see cref="FrameChannel.DefaultTcpConnectFactory"/>), then sends a
|
||||
/// <see cref="Hello"/> with the configured shared secret and confirms the sidecar's
|
||||
/// <see cref="HelloAck"/> is accepted — a true end-to-end reachability + auth check.
|
||||
/// Surfaces a green tick + latency on success; a clear red message on timeout / connection
|
||||
/// refused / TLS failure / rejected Hello.
|
||||
/// </summary>
|
||||
public sealed class WonderwareHistorianDriverProbe : IDriverProbe
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "Historian.Wonderware";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
WonderwareHistorianClientOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(configJson, _opts); }
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(opts.Host) || opts.Port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
Stream? stream = null;
|
||||
try
|
||||
{
|
||||
// Reuse the runtime connect factory so the probe exercises the exact TCP + TLS
|
||||
// (pinned-thumbprint or CA-chain) path the client uses in production.
|
||||
stream = await FrameChannel.DefaultTcpConnectFactory(opts, ct).ConfigureAwait(false);
|
||||
|
||||
using var reader = new FrameReader(stream, leaveOpen: true);
|
||||
using var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
|
||||
var hello = new Hello
|
||||
{
|
||||
ProtocolMajor = Hello.CurrentMajor,
|
||||
ProtocolMinor = Hello.CurrentMinor,
|
||||
PeerName = opts.PeerName,
|
||||
SharedSecret = opts.SharedSecret,
|
||||
};
|
||||
await writer.WriteAsync(MessageKind.Hello, hello, ct).ConfigureAwait(false);
|
||||
|
||||
var ackFrame = await reader.ReadFrameAsync(ct).ConfigureAwait(false)
|
||||
?? throw new EndOfStreamException("Sidecar closed connection before HelloAck.");
|
||||
if (ackFrame.Kind != MessageKind.HelloAck)
|
||||
return new(false, $"Sidecar replied to Hello with kind {ackFrame.Kind}; expected HelloAck.", null);
|
||||
|
||||
var ack = FrameReader.Deserialize<HelloAck>(ackFrame.Body);
|
||||
if (!ack.Accepted)
|
||||
return new(false, $"Sidecar rejected Hello: {ack.RejectReason ?? "<no reason>"}.", null);
|
||||
|
||||
sw.Stop();
|
||||
return new(true, $"Connected to {opts.Host}:{opts.Port} (tls={opts.UseTls})", sw.Elapsed);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (stream is not null) await stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <summary>
|
||||
/// IPC-side <see cref="IAlarmEventWriter"/> implementation that delegates to an
|
||||
/// <see cref="IAlarmHistorianWriteBackend"/> (production: aahClientManaged-bound)
|
||||
/// and maps the trinary <see cref="AlarmHistorianWriteOutcome"/> down to the
|
||||
/// <c>bool[]</c> the IPC reply contract carries. Per-event outcomes:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="AlarmHistorianWriteOutcome.Ack"/> → <c>true</c> (drop from sender's queue).</description></item>
|
||||
/// <item><description><see cref="AlarmHistorianWriteOutcome.RetryPlease"/> → <c>false</c> (sender retries on next drain tick).</description></item>
|
||||
/// <item><description><see cref="AlarmHistorianWriteOutcome.PermanentFail"/> → <c>false</c> (sender's B.4 widens the IPC bool back into the trinary outcome by inspecting structured diagnostics; this slot intentionally collapses to "not-ok" at the wire).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class AahClientManagedAlarmEventWriter : IAlarmEventWriter
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<AahClientManagedAlarmEventWriter>();
|
||||
|
||||
private readonly IAlarmHistorianWriteBackend _backend;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AahClientManagedAlarmEventWriter class.
|
||||
/// </summary>
|
||||
/// <param name="backend">The alarm historian write backend to delegate to.</param>
|
||||
public AahClientManagedAlarmEventWriter(IAlarmHistorianWriteBackend backend)
|
||||
{
|
||||
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an array of alarm historian events asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="events">The alarm events to write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<bool[]> WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken)
|
||||
{
|
||||
if (events is null || events.Length == 0)
|
||||
{
|
||||
return new bool[0];
|
||||
}
|
||||
|
||||
AlarmHistorianWriteOutcome[] outcomes;
|
||||
try
|
||||
{
|
||||
outcomes = await _backend.WriteBatchAsync(events, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Backend-level failure (cluster unreachable, transport error). Treat the
|
||||
// whole batch as RetryPlease so the sender's queue holds the rows for
|
||||
// the next drain tick — preferable to dropping them on a transient.
|
||||
Log.Warning(ex,
|
||||
"Alarm historian backend WriteBatchAsync threw — marking entire {Count}-event batch RetryPlease.",
|
||||
events.Length);
|
||||
var fallback = new bool[events.Length];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (outcomes.Length != events.Length)
|
||||
{
|
||||
// Backend contract violation — defensive degrade so a bug in the backend
|
||||
// doesn't desync the sender's queue accounting. Treat as RetryPlease.
|
||||
Log.Warning(
|
||||
"Alarm historian backend returned {ReturnedCount} outcomes for a batch of {InputCount} events; degrading to RetryPlease for the whole batch.",
|
||||
outcomes.Length, events.Length);
|
||||
return new bool[events.Length];
|
||||
}
|
||||
|
||||
var perEventOk = new bool[outcomes.Length];
|
||||
for (var i = 0; i < outcomes.Length; i++)
|
||||
{
|
||||
perEventOk[i] = outcomes[i] == AlarmHistorianWriteOutcome.Ack;
|
||||
}
|
||||
return perEventOk;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translate the outcome of a single SDK call (raw HRESULT + diagnostic) into the
|
||||
/// trinary <see cref="AlarmHistorianWriteOutcome"/>. Exposed for the production
|
||||
/// <see cref="SdkAlarmHistorianWriteBackend"/> to share the mapping with tests.
|
||||
/// </summary>
|
||||
/// <param name="hresult">The HRESULT code from the SDK call.</param>
|
||||
/// <param name="isCommunicationError">Indicates whether the error is a communication-class error.</param>
|
||||
/// <param name="isMalformedInput">Indicates whether the input was malformed.</param>
|
||||
public static AlarmHistorianWriteOutcome MapOutcome(int hresult, bool isCommunicationError, bool isMalformedInput)
|
||||
{
|
||||
// Order matters: malformed input is permanent regardless of HRESULT pattern;
|
||||
// communication-class errors are transient regardless of which specific
|
||||
// HRESULT bit fired.
|
||||
if (isMalformedInput)
|
||||
{
|
||||
return AlarmHistorianWriteOutcome.PermanentFail;
|
||||
}
|
||||
if (hresult == 0)
|
||||
{
|
||||
return AlarmHistorianWriteOutcome.Ack;
|
||||
}
|
||||
if (isCommunicationError)
|
||||
{
|
||||
return AlarmHistorianWriteOutcome.RetryPlease;
|
||||
}
|
||||
// Default: unknown HRESULT failure — be conservative and let the sender retry.
|
||||
// The sender's drain worker has its own dead-letter cap so a permanently-broken
|
||||
// event won't loop forever.
|
||||
return AlarmHistorianWriteOutcome.RetryPlease;
|
||||
}
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-event outcome from <see cref="IAlarmHistorianWriteBackend.WriteBatchAsync"/>.
|
||||
/// Sidecar-local twin of <c>Core.AlarmHistorian.HistorianWriteOutcome</c> (the
|
||||
/// sidecar runs net48 and cannot reference the net10 Core project; the IPC
|
||||
/// contract narrows this to <c>bool</c> per slot, so the lmxopcua-side consumer
|
||||
/// widens that back into the trinary outcome at the IPC boundary in PR B.4).
|
||||
/// </summary>
|
||||
public enum AlarmHistorianWriteOutcome
|
||||
{
|
||||
/// <summary>Event accepted by the historian. Drop from the store-and-forward queue.</summary>
|
||||
Ack,
|
||||
/// <summary>Transient failure (server busy, disconnected, timeout). Leave queued; retry on next drain tick.</summary>
|
||||
RetryPlease,
|
||||
/// <summary>Permanent failure (malformed event, unrecoverable SDK error). Move to dead-letter on the lmxopcua side.</summary>
|
||||
PermanentFail,
|
||||
}
|
||||
}
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <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>
|
||||
internal sealed class HistorianClusterEndpointPicker
|
||||
{
|
||||
private readonly Func<DateTime> _clock;
|
||||
private readonly TimeSpan _cooldown;
|
||||
private readonly object _lock = new object();
|
||||
private readonly List<NodeEntry> _nodes;
|
||||
|
||||
/// <summary>Initializes the picker with default system clock.</summary>
|
||||
/// <param name="config">Historian configuration.</param>
|
||||
public HistorianClusterEndpointPicker(HistorianConfiguration config)
|
||||
: this(config, () => DateTime.UtcNow) { }
|
||||
|
||||
/// <summary>Initializes the picker with custom clock function.</summary>
|
||||
/// <param name="config">Historian configuration.</param>
|
||||
/// <param name="clock">Clock function for testing.</param>
|
||||
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 count of configured nodes.</summary>
|
||||
public int NodeCount
|
||||
{
|
||||
get { lock (_lock) return _nodes.Count; }
|
||||
}
|
||||
|
||||
/// <summary>Gets the list of currently healthy nodes.</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 currently healthy nodes.</summary>
|
||||
public int HealthyNodeCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = _clock();
|
||||
return _nodes.Count(n => IsHealthyAt(n, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Marks a node as failed and starts its cooldown.</summary>
|
||||
/// <param name="node">Node name.</param>
|
||||
/// <param name="error">Optional error message.</param>
|
||||
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 a node as healthy and clears its cooldown.</summary>
|
||||
/// <param name="node">Node name.</param>
|
||||
public void MarkHealthy(string node)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = FindEntry(node);
|
||||
if (entry == null) return;
|
||||
entry.CooldownUntil = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns a snapshot of all node states.</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
|
||||
{
|
||||
/// <summary>Gets or sets the node name.</summary>
|
||||
public string Name { get; set; } = "";
|
||||
/// <summary>Gets or sets when cooldown expires.</summary>
|
||||
public DateTime? CooldownUntil { get; set; }
|
||||
/// <summary>Gets or sets the failure count.</summary>
|
||||
public int FailureCount { get; set; }
|
||||
/// <summary>Gets or sets the last error message.</summary>
|
||||
public string? LastError { get; set; }
|
||||
/// <summary>Gets or sets the last failure time.</summary>
|
||||
public DateTime? LastFailureTime { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <summary>
|
||||
/// Point-in-time state of a single historian cluster node. One entry per configured node
|
||||
/// appears inside <see cref="HistorianHealthSnapshot"/>.
|
||||
/// </summary>
|
||||
public sealed class HistorianClusterNodeState
|
||||
{
|
||||
/// <summary>Gets or sets the node name.</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the node is healthy.</summary>
|
||||
public bool IsHealthy { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the time until the node exits cooldown mode.</summary>
|
||||
public DateTime? CooldownUntil { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the count of recent failures.</summary>
|
||||
public int FailureCount { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the last error message.</summary>
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the time of the last failure.</summary>
|
||||
public DateTime? LastFailureTime { get; set; }
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <summary>
|
||||
/// Wonderware Historian SDK configuration. Populated from environment variables at
|
||||
/// sidecar startup (see <c>Program.cs</c>): the supervisor (lmxopcua-side
|
||||
/// <c>WonderwareHistorianClient</c>) spawns the sidecar with these env vars; UA
|
||||
/// translation lives on the client side of the TCP IPC, so this surface is
|
||||
/// kept OPC-UA-free. The legacy v1 Galaxy.Host / Proxy host this lived in retired
|
||||
/// in PR 7.2.
|
||||
/// </summary>
|
||||
public sealed class HistorianConfiguration
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether Historian integration is enabled.</summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>Single-node fallback when <see cref="ServerNames"/> is empty.</summary>
|
||||
public string ServerName { get; set; } = "localhost";
|
||||
|
||||
/// <summary>
|
||||
/// Ordered cluster nodes. When non-empty, the data source tries each 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 failure cooldown period in seconds.</summary>
|
||||
public int FailureCooldownSeconds { get; set; } = 60;
|
||||
/// <summary>Gets or sets a value indicating whether to use integrated security.</summary>
|
||||
public bool IntegratedSecurity { get; set; } = true;
|
||||
/// <summary>Gets or sets the user name for authentication.</summary>
|
||||
public string? UserName { get; set; }
|
||||
/// <summary>Gets or sets the password for authentication.</summary>
|
||||
public string? Password { get; set; }
|
||||
/// <summary>Gets or sets the Historian server port.</summary>
|
||||
public int Port { get; set; } = 32568;
|
||||
/// <summary>Gets or sets the command timeout in seconds.</summary>
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
/// <summary>Gets or sets the maximum number of values per read operation.</summary>
|
||||
public int MaxValuesPerRead { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Outer safety timeout applied to sync-over-async Historian operations. Must be
|
||||
/// comfortably larger than <see cref="CommandTimeoutSeconds"/>.
|
||||
/// </summary>
|
||||
public int RequestTimeoutSeconds { get; set; } = 60;
|
||||
}
|
||||
}
|
||||
-863
@@ -1,863 +0,0 @@
|
||||
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 TCP 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>
|
||||
/// Whether a failed <c>StartQuery</c> in the per-timestamp at-time loop should reset
|
||||
/// the shared SDK connection (and abort the read) rather than record a per-timestamp
|
||||
/// Bad sample and continue. Returns <c>true</c> only for connection-class error
|
||||
/// codes; query-class / no-data codes (and a missing error) return <c>false</c> so
|
||||
/// a single bad/empty timestamp does not tear down a connection that is still serving
|
||||
/// the other timestamps. The <c>HistoryQuery</c> SDK type is non-virtual and has no
|
||||
/// interface, so the at-time loop can't be driven offline — this pure helper is the
|
||||
/// unit-testable seam for the classification. See Driver.Historian.Wonderware-014.
|
||||
/// </summary>
|
||||
/// <param name="error">The SDK error returned by the failed <c>StartQuery</c>.</param>
|
||||
internal static bool ShouldResetConnectionForStartQueryFailure(HistorianAccessError? error)
|
||||
=> IsConnectionClassError(error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure);
|
||||
|
||||
/// <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 TCP-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 TCP 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))
|
||||
{
|
||||
// Driver.Historian.Wonderware-014: classify the failure like the raw /
|
||||
// aggregate / event paths. A connection-class code means the shared
|
||||
// connection is dead — throw so the whole at-time read aborts and the IPC
|
||||
// layer surfaces Success=false (the outer catch resets the connection and
|
||||
// marks the node failed). Without this, every remaining timestamp would
|
||||
// re-fail StartQuery on the dead connection and the method would still
|
||||
// report Success=true with an all-Bad result, never failing over. A
|
||||
// query-class / no-data code keeps the connection and records a Bad sample
|
||||
// for just this timestamp.
|
||||
if (ShouldResetConnectionForStartQueryFailure(error))
|
||||
{
|
||||
var code = error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure;
|
||||
throw new InvalidOperationException(
|
||||
$"Historian SDK StartQuery failed for at-time query of tag '{tagName}': {code} ({error?.ErrorDescription})");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK-free representation of a Historian event record. Prevents ArchestrA types from
|
||||
/// leaking beyond <c>HistorianDataSource</c>.
|
||||
/// </summary>
|
||||
public sealed class HistorianEventDto
|
||||
{
|
||||
/// <summary>Gets or sets the unique identifier for the event.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the source of the event.</summary>
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the time when the event occurred.</summary>
|
||||
public DateTime EventTime { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the time when the event was received.</summary>
|
||||
public DateTime ReceivedTime { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the display text for the event.</summary>
|
||||
public string? DisplayText { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the severity level of the event.</summary>
|
||||
public ushort Severity { get; set; }
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <summary>
|
||||
/// Point-in-time runtime health of the historian subsystem — consumed by the status dashboard
|
||||
/// via an IPC health query (not wired in PR #5; deferred).
|
||||
/// </summary>
|
||||
public sealed class HistorianHealthSnapshot
|
||||
{
|
||||
/// <summary>Gets or sets the total number of queries executed.</summary>
|
||||
public long TotalQueries { get; set; }
|
||||
/// <summary>Gets or sets the total number of successful queries.</summary>
|
||||
public long TotalSuccesses { get; set; }
|
||||
/// <summary>Gets or sets the total number of failed queries.</summary>
|
||||
public long TotalFailures { get; set; }
|
||||
/// <summary>Gets or sets the number of consecutive failures.</summary>
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
/// <summary>Gets or sets the time of the last successful query.</summary>
|
||||
public DateTime? LastSuccessTime { get; set; }
|
||||
/// <summary>Gets or sets the time of the last failed query.</summary>
|
||||
public DateTime? LastFailureTime { get; set; }
|
||||
/// <summary>Gets or sets the last error message, if any.</summary>
|
||||
public string? LastError { get; set; }
|
||||
/// <summary>Gets or sets a value indicating whether the process connection is open.</summary>
|
||||
public bool ProcessConnectionOpen { get; set; }
|
||||
/// <summary>Gets or sets a value indicating whether the event connection is open.</summary>
|
||||
public bool EventConnectionOpen { get; set; }
|
||||
/// <summary>Gets or sets the name of the active process node.</summary>
|
||||
public string? ActiveProcessNode { get; set; }
|
||||
/// <summary>Gets or sets the name of the active event node.</summary>
|
||||
public string? ActiveEventNode { get; set; }
|
||||
/// <summary>Gets or sets the total number of cluster nodes.</summary>
|
||||
public int NodeCount { get; set; }
|
||||
/// <summary>Gets or sets the number of healthy cluster nodes.</summary>
|
||||
public int HealthyNodeCount { get; set; }
|
||||
/// <summary>Gets or sets the list of cluster node states.</summary>
|
||||
public List<HistorianClusterNodeState> Nodes { get; set; } = new();
|
||||
}
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's <c>OpcQuality</c>)
|
||||
/// to an OPC UA <c>StatusCode</c> uint. Preserves specific codes (BadNotConnected,
|
||||
/// UncertainSubNormal, etc.) instead of collapsing to Good/Uncertain/Bad categories.
|
||||
/// Mirrors v1 <c>QualityMapper.MapToOpcUaStatusCode</c> without pulling in OPC UA types —
|
||||
/// the returned value is the 32-bit OPC UA <c>StatusCode</c> wire encoding that the Proxy
|
||||
/// surfaces directly as <c>DataValueSnapshot.StatusCode</c>.
|
||||
/// </summary>
|
||||
public static class HistorianQualityMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Map an 8-bit OPC DA quality byte to the corresponding OPC UA StatusCode. The byte
|
||||
/// family bits decide the category (Good >= 192, Uncertain 64-191, Bad 0-63); the
|
||||
/// low-nibble subcode selects the specific code.
|
||||
/// </summary>
|
||||
/// <param name="q">The OPC DA quality byte.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint Map(byte q) => q switch
|
||||
{
|
||||
// Good family (192+)
|
||||
192 => 0x00000000u, // Good
|
||||
216 => 0x00D80000u, // Good_LocalOverride
|
||||
|
||||
// Uncertain family (64-191)
|
||||
64 => 0x40000000u, // Uncertain
|
||||
68 => 0x40900000u, // Uncertain_LastUsableValue
|
||||
80 => 0x40930000u, // Uncertain_SensorNotAccurate
|
||||
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
|
||||
88 => 0x40950000u, // Uncertain_SubNormal
|
||||
|
||||
// Bad family (0-63)
|
||||
0 => 0x80000000u, // Bad
|
||||
4 => 0x80890000u, // Bad_ConfigurationError
|
||||
8 => 0x808A0000u, // Bad_NotConnected
|
||||
12 => 0x808B0000u, // Bad_DeviceFailure
|
||||
16 => 0x808C0000u, // Bad_SensorFailure
|
||||
20 => 0x80050000u, // Bad_CommunicationError
|
||||
24 => 0x808D0000u, // Bad_OutOfService
|
||||
32 => 0x80320000u, // Bad_WaitingForInitialData
|
||||
|
||||
// Unknown code — fall back to the category so callers still get a sensible bucket.
|
||||
_ when q >= 192 => 0x00000000u,
|
||||
_ when q >= 64 => 0x40000000u,
|
||||
_ => 0x80000000u,
|
||||
};
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <summary>
|
||||
/// OPC-UA-free representation of a single historical data point. The sidecar serialises
|
||||
/// these onto the TCP wire (<c>HistorianSampleDto</c>) for the .NET 10
|
||||
/// <c>WonderwareHistorianClient</c>, which maps quality and value into OPC UA
|
||||
/// <c>DataValue</c> on its side. Raw OPC DA quality byte is preserved so the client
|
||||
/// can reuse the same quality mapper it already uses for live reads.
|
||||
/// </summary>
|
||||
public sealed class HistorianSample
|
||||
{
|
||||
/// <summary>Gets or sets the historical data value.</summary>
|
||||
public object? Value { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality).</summary>
|
||||
public byte Quality { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp of the historical sample.</summary>
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="IHistorianDataSource.ReadAggregateAsync"/>. When <see cref="Value"/> is
|
||||
/// null the aggregate is unavailable for that bucket — the client maps to <c>BadNoData</c>.
|
||||
/// </summary>
|
||||
public sealed class HistorianAggregateSample
|
||||
{
|
||||
/// <summary>Gets or sets the aggregate value, or null if unavailable.</summary>
|
||||
public double? Value { get; set; }
|
||||
/// <summary>Gets or sets the UTC timestamp of the aggregate sample.</summary>
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <summary>
|
||||
/// The actual aahClientManaged-bound writer. Extracted so unit tests can
|
||||
/// substitute a fake without touching the SDK; the production
|
||||
/// implementation lives in <see cref="SdkAlarmHistorianWriteBackend"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations are responsible for connection management + cluster
|
||||
/// failover. The wrapping <see cref="AahClientManagedAlarmEventWriter"/>
|
||||
/// handles batch-level orchestration but delegates the per-event SDK call
|
||||
/// here so the unit tests can drive every documented MxStatus outcome
|
||||
/// without an installed AVEVA Historian.
|
||||
/// </remarks>
|
||||
public interface IAlarmHistorianWriteBackend
|
||||
{
|
||||
/// <summary>
|
||||
/// Persist the supplied events to the historian. Returns one outcome per
|
||||
/// input slot in the same order — must always return an array of the same
|
||||
/// length as <paramref name="events"/>.
|
||||
/// </summary>
|
||||
/// <param name="events">The events to write to the historian.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<AlarmHistorianWriteOutcome[]> WriteBatchAsync(
|
||||
AlarmHistorianEventDto[] events,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using ArchestrA;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <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>
|
||||
/// Opens a Historian SDK connection. <paramref name="readOnly"/> defaults to
|
||||
/// <c>true</c> for the query path; the alarm-event write backend passes
|
||||
/// <c>false</c> because <c>HistorianAccess.AddStreamedValue</c> fails with
|
||||
/// <c>WriteToReadOnlyFile</c> on a read-only session.
|
||||
/// </summary>
|
||||
/// <param name="config">The historian configuration.</param>
|
||||
/// <param name="type">The type of connection to create.</param>
|
||||
/// <param name="readOnly">Whether the connection should be read-only.</param>
|
||||
/// <returns>An open HistorianAccess connection.</returns>
|
||||
HistorianAccess CreateAndConnect(
|
||||
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true);
|
||||
}
|
||||
|
||||
/// <summary>Production implementation — opens real Historian SDK connections.</summary>
|
||||
internal sealed class SdkHistorianConnectionFactory : IHistorianConnectionFactory
|
||||
{
|
||||
/// <summary>Creates and connects a Historian SDK connection.</summary>
|
||||
/// <param name="config">The historian configuration.</param>
|
||||
/// <param name="type">The type of connection to create.</param>
|
||||
/// <param name="readOnly">Whether the connection should be read-only.</param>
|
||||
/// <returns>An open HistorianAccess connection.</returns>
|
||||
public HistorianAccess CreateAndConnect(
|
||||
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
|
||||
{
|
||||
var conn = new HistorianAccess();
|
||||
var args = BuildConnectionArgs(config, type, readOnly);
|
||||
|
||||
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}");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="HistorianConnectionArgs"/> for a connection. Pure (no SDK
|
||||
/// side effects) so the read-only-vs-write argument shaping is unit-testable.
|
||||
/// </summary>
|
||||
/// <param name="config">The historian configuration.</param>
|
||||
/// <param name="type">The type of connection to create.</param>
|
||||
/// <param name="readOnly">Whether the connection should be read-only.</param>
|
||||
/// <returns>The configured connection arguments.</returns>
|
||||
internal static HistorianConnectionArgs BuildConnectionArgs(
|
||||
HistorianConfiguration config, HistorianConnectionType type, bool readOnly)
|
||||
{
|
||||
var args = new HistorianConnectionArgs
|
||||
{
|
||||
ServerName = config.ServerName,
|
||||
TcpPort = (ushort)config.Port,
|
||||
IntegratedSecurity = config.IntegratedSecurity,
|
||||
UseArchestrAUser = config.IntegratedSecurity,
|
||||
ConnectionType = type,
|
||||
ReadOnly = readOnly,
|
||||
PacketTimeout = (uint)(config.CommandTimeoutSeconds * 1000)
|
||||
};
|
||||
|
||||
if (!config.IntegratedSecurity)
|
||||
{
|
||||
args.UserName = config.UserName ?? string.Empty;
|
||||
args.Password = config.Password ?? string.Empty;
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <summary>
|
||||
/// OPC-UA-free surface for the Wonderware Historian subsystem inside the historian
|
||||
/// sidecar process. Implementations read via the aahClient* SDK; the .NET 10
|
||||
/// <c>WonderwareHistorianClient</c> on the other side of the TCP IPC maps
|
||||
/// returned samples to OPC UA <c>DataValue</c>. The v1 Galaxy.Host / Proxy hosts
|
||||
/// this lived in retired in PR 7.2.
|
||||
/// </summary>
|
||||
public interface IHistorianDataSource : IDisposable
|
||||
{
|
||||
/// <summary>Reads raw historical samples asynchronously.</summary>
|
||||
/// <param name="tagName">The tag name to read from.</param>
|
||||
/// <param name="startTime">The start time of the time range.</param>
|
||||
/// <param name="endTime">The end time of the time range.</param>
|
||||
/// <param name="maxValues">The maximum number of values to return.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation that returns a list of historian samples.</returns>
|
||||
Task<List<HistorianSample>> ReadRawAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime, int maxValues,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Reads aggregate historical samples asynchronously.</summary>
|
||||
/// <param name="tagName">The tag name to read from.</param>
|
||||
/// <param name="startTime">The start time of the time range.</param>
|
||||
/// <param name="endTime">The end time of the time range.</param>
|
||||
/// <param name="intervalMs">The interval in milliseconds for aggregation.</param>
|
||||
/// <param name="aggregateColumn">The column to aggregate.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation that returns a list of aggregate samples.</returns>
|
||||
Task<List<HistorianAggregateSample>> ReadAggregateAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime,
|
||||
double intervalMs, string aggregateColumn,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Reads historical samples at specific times asynchronously.</summary>
|
||||
/// <param name="tagName">The tag name to read from.</param>
|
||||
/// <param name="timestamps">The array of timestamps at which to read values.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation that returns a list of historian samples.</returns>
|
||||
Task<List<HistorianSample>> ReadAtTimeAsync(
|
||||
string tagName, DateTime[] timestamps,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Reads historical events asynchronously.</summary>
|
||||
/// <param name="sourceName">The source name to filter events, or null for all sources.</param>
|
||||
/// <param name="startTime">The start time of the time range.</param>
|
||||
/// <param name="endTime">The end time of the time range.</param>
|
||||
/// <param name="maxEvents">The maximum number of events to return.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation that returns a list of historian events.</returns>
|
||||
Task<List<HistorianEventDto>> ReadEventsAsync(
|
||||
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Gets a health snapshot of the data source.</summary>
|
||||
/// <returns>A HistorianHealthSnapshot containing the current health information.</returns>
|
||||
HistorianHealthSnapshot GetHealthSnapshot();
|
||||
}
|
||||
}
|
||||
-398
@@ -1,398 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
|
||||
{
|
||||
/// <summary>
|
||||
/// Production <see cref="IAlarmHistorianWriteBackend"/> backed by AVEVA Historian's
|
||||
/// <c>aahClientManaged</c> SDK. Each <see cref="AlarmHistorianEventDto"/> is written via
|
||||
/// <c>HistorianAccess.AddStreamedValue(HistorianEvent, out HistorianAccessError)</c> —
|
||||
/// the alarm-event write entry point pinned during PR C.1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The write path needs its <b>own</b> connection. The query-side
|
||||
/// <see cref="HistorianDataSource"/> opens <c>ReadOnly</c> sessions, and
|
||||
/// <c>AddStreamedValue</c> on a read-only session fails with
|
||||
/// <c>WriteToReadOnlyFile</c>. This backend therefore opens a dedicated
|
||||
/// <c>ReadOnly = false</c> connection; it shares
|
||||
/// <see cref="HistorianClusterEndpointPicker"/> for node selection and failover but
|
||||
/// not the connection object itself.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per-event <c>HistorianAccessError.ErrorValue</c> codes map onto
|
||||
/// <see cref="AlarmHistorianWriteOutcome"/> via
|
||||
/// <see cref="AahClientManagedAlarmEventWriter.MapOutcome"/>. A connection-class
|
||||
/// error aborts the remainder of the batch as
|
||||
/// <see cref="AlarmHistorianWriteOutcome.RetryPlease"/> and resets the connection so
|
||||
/// the next drain tick reconnects — possibly to a different cluster node.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The exact <c>HistorianEvent</c> field set required by the Historian is confirmed
|
||||
/// against a live install during the PR D.1 rollout smoke; <see cref="ToHistorianEvent"/>
|
||||
/// maps the unambiguous fields and carries operator comment / condition id as event
|
||||
/// properties.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SdkAlarmHistorianWriteBackend : IAlarmHistorianWriteBackend, IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<SdkAlarmHistorianWriteBackend>();
|
||||
|
||||
// ErrorValue codes that mean the connection/server is the problem (transient) rather
|
||||
// than the event payload. These abort the rest of the batch and trigger a reconnect.
|
||||
private static readonly HashSet<HistorianAccessError.ErrorValue> ConnectionErrors =
|
||||
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,
|
||||
// WriteToReadOnlyFile is a connection-configuration fault, not an event-payload
|
||||
// fault: the session was opened without ReadOnly = false (a misconfiguration or
|
||||
// a regression). The event itself is fine, so it must NOT be dead-lettered.
|
||||
// Classifying it here aborts the batch and resets the connection so the
|
||||
// reconnect path re-opens a writable (ReadOnly = false) session; the deferred
|
||||
// events drain on the next tick. See Driver.Historian.Wonderware-001.
|
||||
HistorianAccessError.ErrorValue.WriteToReadOnlyFile,
|
||||
};
|
||||
|
||||
// ErrorValue codes that mean the event itself is malformed — permanent, never retried.
|
||||
private static readonly HashSet<HistorianAccessError.ErrorValue> MalformedErrors =
|
||||
new HashSet<HistorianAccessError.ErrorValue>
|
||||
{
|
||||
HistorianAccessError.ErrorValue.InvalidArgument,
|
||||
HistorianAccessError.ErrorValue.ValidationFailed,
|
||||
HistorianAccessError.ErrorValue.NullPointerArgument,
|
||||
HistorianAccessError.ErrorValue.NotImplemented,
|
||||
HistorianAccessError.ErrorValue.NotApplicable,
|
||||
};
|
||||
|
||||
private readonly HistorianConfiguration _config;
|
||||
private readonly IHistorianConnectionFactory _factory;
|
||||
private readonly HistorianClusterEndpointPicker _picker;
|
||||
private readonly object _connectionLock = new object();
|
||||
private HistorianAccess? _connection;
|
||||
private string? _activeNode;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance using the default SDK connection factory.</summary>
|
||||
/// <param name="config">The historian configuration.</param>
|
||||
public SdkAlarmHistorianWriteBackend(HistorianConfiguration config)
|
||||
: this(config, new SdkHistorianConnectionFactory(), null) { }
|
||||
|
||||
/// <summary>Initializes a new instance with injected dependencies (for testing).</summary>
|
||||
/// <param name="config">The historian configuration.</param>
|
||||
/// <param name="factory">The connection factory.</param>
|
||||
/// <param name="picker">The cluster endpoint picker, or null to use a new instance.</param>
|
||||
internal SdkAlarmHistorianWriteBackend(
|
||||
HistorianConfiguration config,
|
||||
IHistorianConnectionFactory factory,
|
||||
HistorianClusterEndpointPicker? picker = null)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
_picker = picker ?? new HistorianClusterEndpointPicker(config);
|
||||
}
|
||||
|
||||
/// <summary>Writes a batch of alarm events to the historian, returning outcomes for each event.</summary>
|
||||
/// <param name="events">The alarm events to write.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>An array of outcomes corresponding to each input event.</returns>
|
||||
public Task<AlarmHistorianWriteOutcome[]> WriteBatchAsync(
|
||||
AlarmHistorianEventDto[] events,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (events is null || events.Length == 0)
|
||||
{
|
||||
return Task.FromResult(new AlarmHistorianWriteOutcome[0]);
|
||||
}
|
||||
|
||||
var outcomes = new AlarmHistorianWriteOutcome[events.Length];
|
||||
|
||||
HistorianAccess connection;
|
||||
try
|
||||
{
|
||||
connection = EnsureConnected();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// No reachable node — defer the whole batch so the lmxopcua-side SQLite
|
||||
// store-and-forward sink retains the rows for the next drain tick.
|
||||
Log.Warning(ex,
|
||||
"Alarm historian write connection unavailable — deferring {Count} event(s) as RetryPlease",
|
||||
events.Length);
|
||||
FillRemaining(outcomes, 0, AlarmHistorianWriteOutcome.RetryPlease);
|
||||
return Task.FromResult(outcomes);
|
||||
}
|
||||
|
||||
for (var i = 0; i < events.Length; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var historianEvent = ToHistorianEvent(events[i]);
|
||||
if (connection.AddStreamedValue(historianEvent, out var error))
|
||||
{
|
||||
outcomes[i] = AlarmHistorianWriteOutcome.Ack;
|
||||
continue;
|
||||
}
|
||||
|
||||
var code = error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure;
|
||||
if (ConnectionErrors.Contains(code))
|
||||
{
|
||||
// Connection died mid-batch — drop it and defer this event + the rest.
|
||||
Log.Warning(
|
||||
"Alarm historian write hit connection-level error {Code} ({Desc}); resetting connection, deferring {Remaining} event(s)",
|
||||
code, error?.ErrorDescription, events.Length - i);
|
||||
HandleConnectionError(error?.ErrorDescription);
|
||||
FillRemaining(outcomes, i, AlarmHistorianWriteOutcome.RetryPlease);
|
||||
return Task.FromResult(outcomes);
|
||||
}
|
||||
|
||||
outcomes[i] = ClassifyOutcome(code);
|
||||
Log.Warning(
|
||||
"Alarm historian write rejected event {EventId}: {Code} ({Desc}) -> {Outcome}",
|
||||
events[i].EventId, code, error?.ErrorDescription, outcomes[i]);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport-level throw (SDK marshalling fault, broken connection) —
|
||||
// reset and defer this event + the rest.
|
||||
Log.Warning(ex,
|
||||
"Alarm historian write threw for event {EventId}; resetting connection, deferring {Remaining} event(s)",
|
||||
events[i].EventId, events.Length - i);
|
||||
HandleConnectionError(ex.Message);
|
||||
FillRemaining(outcomes, i, AlarmHistorianWriteOutcome.RetryPlease);
|
||||
return Task.FromResult(outcomes);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(outcomes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an <see cref="AlarmHistorianEventDto"/> onto the SDK's
|
||||
/// <c>HistorianEvent</c>. Operator comment and originating condition id ride as
|
||||
/// event properties — operator-comment fidelity is the field the value-driven
|
||||
/// fallback path cannot carry.
|
||||
/// </summary>
|
||||
/// <param name="dto">The alarm event data transfer object.</param>
|
||||
/// <returns>The mapped HistorianEvent.</returns>
|
||||
internal static HistorianEvent ToHistorianEvent(AlarmHistorianEventDto dto)
|
||||
{
|
||||
// The ArchestrA SDK marks these HistorianEvent members obsolete but still honours
|
||||
// them on write; their successors aren't wired in the version we bind against.
|
||||
// Using them is the documented v1 behaviour — mirrors HistorianDataSource.ToDto,
|
||||
// suppressed locally so any other deprecated-surface use still surfaces as an error.
|
||||
#pragma warning disable CS0618
|
||||
var historianEvent = new HistorianEvent
|
||||
{
|
||||
IsAlarm = true,
|
||||
Source = dto.SourceName ?? string.Empty,
|
||||
EventType = string.IsNullOrEmpty(dto.AlarmType) ? "Alarm" : dto.AlarmType,
|
||||
EventTime = new DateTime(dto.EventTimeUtcTicks, DateTimeKind.Utc),
|
||||
ReceivedTime = DateTime.UtcNow,
|
||||
Severity = dto.Severity,
|
||||
DisplayText = dto.Message ?? string.Empty,
|
||||
};
|
||||
|
||||
if (Guid.TryParse(dto.EventId, out var id))
|
||||
{
|
||||
historianEvent.Id = id;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Driver.Historian.Wonderware-004: an unparseable / empty EventId previously
|
||||
// left Id as Guid.Empty, which made every such alarm collide on the same id
|
||||
// with no diagnostic. Synthesize a fresh Guid so each event still gets a
|
||||
// unique identifier (the historian still accepts the write — outcome stays
|
||||
// Ack — and the sender can correlate the synthesized id via the warning log).
|
||||
var synthesized = Guid.NewGuid();
|
||||
Log.Warning(
|
||||
"Alarm historian event has non-parseable EventId {EventId} for source {Source}; synthesizing Id={SynthesizedId}",
|
||||
dto.EventId ?? "(null)", dto.SourceName ?? "(none)", synthesized);
|
||||
historianEvent.Id = synthesized;
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
if (!string.IsNullOrEmpty(dto.AckComment))
|
||||
{
|
||||
historianEvent.AddProperty("Comment", dto.AckComment, out _);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(dto.ConditionId))
|
||||
{
|
||||
historianEvent.AddProperty("ConditionId", dto.ConditionId, out _);
|
||||
}
|
||||
|
||||
return historianEvent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a non-connection-class <c>HistorianAccessError.ErrorValue</c> into an
|
||||
/// <see cref="AlarmHistorianWriteOutcome"/> by routing it through the shared
|
||||
/// <see cref="AahClientManagedAlarmEventWriter.MapOutcome"/> mapping. Exposed for
|
||||
/// unit tests — connection-class codes are handled separately by the batch loop.
|
||||
/// </summary>
|
||||
/// <param name="code">The error code to classify.</param>
|
||||
/// <returns>The corresponding write outcome.</returns>
|
||||
internal static AlarmHistorianWriteOutcome ClassifyOutcome(HistorianAccessError.ErrorValue code)
|
||||
=> AahClientManagedAlarmEventWriter.MapOutcome(
|
||||
(int)code,
|
||||
isCommunicationError: ConnectionErrors.Contains(code),
|
||||
isMalformedInput: MalformedErrors.Contains(code));
|
||||
|
||||
private static void FillRemaining(
|
||||
AlarmHistorianWriteOutcome[] outcomes, int from, AlarmHistorianWriteOutcome value)
|
||||
{
|
||||
for (var i = from; i < outcomes.Length; i++)
|
||||
{
|
||||
outcomes[i] = value;
|
||||
}
|
||||
}
|
||||
|
||||
private HistorianAccess EnsureConnected()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SdkAlarmHistorianWriteBackend));
|
||||
}
|
||||
|
||||
var existing = Volatile.Read(ref _connection);
|
||||
if (existing != null) return existing;
|
||||
|
||||
var (conn, node) = ConnectToAnyHealthyNode();
|
||||
|
||||
lock (_connectionLock)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
SafeClose(conn);
|
||||
throw new ObjectDisposedException(nameof(SdkAlarmHistorianWriteBackend));
|
||||
}
|
||||
|
||||
if (_connection != null)
|
||||
{
|
||||
SafeClose(conn);
|
||||
return _connection;
|
||||
}
|
||||
|
||||
_connection = conn;
|
||||
_activeNode = node;
|
||||
Log.Information("Alarm historian write connection opened to {Server}:{Port}", node, _config.Port);
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
|
||||
private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode()
|
||||
{
|
||||
var candidates = _picker.GetHealthyNodes();
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
_picker.NodeCount == 0
|
||||
? "No historian nodes configured"
|
||||
: $"All {_picker.NodeCount} historian nodes are in cooldown — no healthy endpoints");
|
||||
}
|
||||
|
||||
Exception? lastException = null;
|
||||
foreach (var node in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var conn = _factory.CreateAndConnect(
|
||||
CloneConfigWithServerName(node), HistorianConnectionType.Event, readOnly: false);
|
||||
_picker.MarkHealthy(node);
|
||||
return (conn, node);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_picker.MarkFailed(node, ex.Message);
|
||||
lastException = ex;
|
||||
Log.Warning(ex, "Alarm historian node {Node} failed during write-connect; trying next", node);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"All {candidates.Count} healthy historian candidate(s) failed during write-connect: " +
|
||||
(lastException?.Message ?? "(no detail)"),
|
||||
lastException);
|
||||
}
|
||||
|
||||
private void HandleConnectionError(string? detail)
|
||||
{
|
||||
lock (_connectionLock)
|
||||
{
|
||||
if (_connection == null) return;
|
||||
|
||||
SafeClose(_connection);
|
||||
_connection = null;
|
||||
|
||||
var failedNode = _activeNode;
|
||||
_activeNode = null;
|
||||
if (failedNode != null) _picker.MarkFailed(failedNode, detail ?? "mid-batch failure");
|
||||
Log.Warning("Alarm historian write connection reset (node={Node})", failedNode ?? "(unknown)");
|
||||
}
|
||||
}
|
||||
|
||||
private static void SafeClose(HistorianAccess conn)
|
||||
{
|
||||
try
|
||||
{
|
||||
conn.CloseConnection(out _);
|
||||
conn.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Error closing alarm historian write connection");
|
||||
}
|
||||
}
|
||||
|
||||
private HistorianConfiguration CloneConfigWithServerName(string serverName) => 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,
|
||||
RequestTimeoutSeconds = _config.RequestTimeoutSeconds,
|
||||
};
|
||||
|
||||
/// <summary>Disposes the connection and releases resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
lock (_connectionLock)
|
||||
{
|
||||
if (_connection != null)
|
||||
{
|
||||
SafeClose(_connection);
|
||||
_connection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
using System;
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
// ============================================================================
|
||||
// Wire DTOs for the sidecar TCP protocol. The sidecar speaks its own legacy
|
||||
// shape (List<HistorianSample> etc.) — the .NET 10 client (PR 3.4) translates
|
||||
// to / from Core.Abstractions.DataValueSnapshot + HistoricalEvent.
|
||||
//
|
||||
// Timestamps cross the wire as DateTime ticks (long) to dodge MessagePack's
|
||||
// DateTime kind/timezone quirks; both sides convert with DateTime(ticks, Utc).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>Single historical data point. Quality is the raw OPC DA byte; client maps to OPC UA StatusCode.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianSampleDto
|
||||
{
|
||||
/// <summary>MessagePack-serialized value bytes. Client deserializes per the tag's mx_data_type.</summary>
|
||||
[Key(0)] public byte[]? ValueBytes { get; set; }
|
||||
|
||||
/// <summary>Raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality).</summary>
|
||||
[Key(1)] public byte Quality { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the timestamp in UTC ticks.</summary>
|
||||
[Key(2)] public long TimestampUtcTicks { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Aggregate bucket; <c>Value</c> is null when the aggregate is unavailable for the bucket.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianAggregateSampleDto
|
||||
{
|
||||
/// <summary>Gets or sets the aggregate value.</summary>
|
||||
[Key(0)] public double? Value { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the timestamp in UTC ticks.</summary>
|
||||
[Key(1)] public long TimestampUtcTicks { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Historian event row.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianEventDto
|
||||
{
|
||||
/// <summary>Gets or sets the event identifier.</summary>
|
||||
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the event source name.</summary>
|
||||
[Key(1)] public string? Source { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the event time in UTC ticks.</summary>
|
||||
[Key(2)] public long EventTimeUtcTicks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the received time in UTC ticks.</summary>
|
||||
[Key(3)] public long ReceivedTimeUtcTicks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the display text.</summary>
|
||||
[Key(4)] public string? DisplayText { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the severity.</summary>
|
||||
[Key(5)] public ushort Severity { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Alarm event to persist back into the historian event store.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class AlarmHistorianEventDto
|
||||
{
|
||||
/// <summary>Gets or sets the event identifier.</summary>
|
||||
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the source name.</summary>
|
||||
[Key(1)] public string SourceName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the condition identifier.</summary>
|
||||
[Key(2)] public string? ConditionId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the alarm type.</summary>
|
||||
[Key(3)] public string AlarmType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the alarm message.</summary>
|
||||
[Key(4)] public string? Message { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the severity.</summary>
|
||||
[Key(5)] public ushort Severity { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the event time in UTC ticks.</summary>
|
||||
[Key(6)] public long EventTimeUtcTicks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the acknowledgment comment.</summary>
|
||||
[Key(7)] public string? AckComment { get; set; }
|
||||
}
|
||||
|
||||
// ===== Read Raw =====
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadRawRequest
|
||||
{
|
||||
/// <summary>Gets or sets the tag name.</summary>
|
||||
[Key(0)] public string TagName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the start time in UTC ticks.</summary>
|
||||
[Key(1)] public long StartUtcTicks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the end time in UTC ticks.</summary>
|
||||
[Key(2)] public long EndUtcTicks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the maximum number of values to return.</summary>
|
||||
[Key(3)] public int MaxValues { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadRawReply
|
||||
{
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the request succeeded.</summary>
|
||||
[Key(1)] public bool Success { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the error message if the request failed.</summary>
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the historical samples.</summary>
|
||||
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
|
||||
}
|
||||
|
||||
// ===== Read Processed =====
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadProcessedRequest
|
||||
{
|
||||
/// <summary>Gets or sets the tag name.</summary>
|
||||
[Key(0)] public string TagName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the start time in UTC ticks.</summary>
|
||||
[Key(1)] public long StartUtcTicks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the end time in UTC ticks.</summary>
|
||||
[Key(2)] public long EndUtcTicks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the interval in milliseconds.</summary>
|
||||
[Key(3)] public double IntervalMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Wonderware AnalogSummary column name: "Average", "Minimum", "Maximum", "ValueCount".
|
||||
/// The .NET 10 client maps OPC UA aggregate enum → column.
|
||||
/// </summary>
|
||||
[Key(4)] public string AggregateColumn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(5)] public string CorrelationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadProcessedReply
|
||||
{
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the request succeeded.</summary>
|
||||
[Key(1)] public bool Success { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the error message if the request failed.</summary>
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the aggregate sample buckets.</summary>
|
||||
[Key(3)] public HistorianAggregateSampleDto[] Buckets { get; set; } = Array.Empty<HistorianAggregateSampleDto>();
|
||||
}
|
||||
|
||||
// ===== Read At-Time =====
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadAtTimeRequest
|
||||
{
|
||||
/// <summary>Gets or sets the tag name.</summary>
|
||||
[Key(0)] public string TagName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the timestamps in UTC ticks.</summary>
|
||||
[Key(1)] public long[] TimestampsUtcTicks { get; set; } = Array.Empty<long>();
|
||||
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(2)] public string CorrelationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadAtTimeReply
|
||||
{
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the request succeeded.</summary>
|
||||
[Key(1)] public bool Success { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the error message if the request failed.</summary>
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the historical samples.</summary>
|
||||
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
|
||||
}
|
||||
|
||||
// ===== Read Events =====
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadEventsRequest
|
||||
{
|
||||
/// <summary>Gets or sets the source name.</summary>
|
||||
[Key(0)] public string? SourceName { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the start time in UTC ticks.</summary>
|
||||
[Key(1)] public long StartUtcTicks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the end time in UTC ticks.</summary>
|
||||
[Key(2)] public long EndUtcTicks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the maximum number of events to return.</summary>
|
||||
[Key(3)] public int MaxEvents { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadEventsReply
|
||||
{
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the request succeeded.</summary>
|
||||
[Key(1)] public bool Success { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the error message if the request failed.</summary>
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the historian events.</summary>
|
||||
[Key(3)] public HistorianEventDto[] Events { get; set; } = Array.Empty<HistorianEventDto>();
|
||||
}
|
||||
|
||||
// ===== Write Alarm Events =====
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteAlarmEventsRequest
|
||||
{
|
||||
/// <summary>Gets or sets the alarm events to write.</summary>
|
||||
[Key(0)] public AlarmHistorianEventDto[] Events { get; set; } = Array.Empty<AlarmHistorianEventDto>();
|
||||
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(1)] public string CorrelationId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteAlarmEventsReply
|
||||
{
|
||||
/// <summary>Gets or sets the correlation identifier.</summary>
|
||||
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the request succeeded.</summary>
|
||||
[Key(1)] public bool Success { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the error message if the request failed.</summary>
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
|
||||
/// <summary>Per-event success flag, parallel to <see cref="WriteAlarmEventsRequest.Events"/>.</summary>
|
||||
[Key(3)] public bool[] PerEventOk { get; set; } = Array.Empty<bool>();
|
||||
|
||||
/// <summary>Per-event status parallel to the request's Events: 0=Ack, 1=Retry, 2=Permanent.
|
||||
/// Empty ⇒ an older sidecar that only sent <see cref="PerEventOk"/>; the client falls back to it.</summary>
|
||||
[Key(4)] public byte[] PerEventStatus { get; set; } = Array.Empty<byte>();
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
|
||||
/// <see cref="ReadFrameAsync"/> from multiple threads against the same instance. Mirror of
|
||||
/// Driver.Galaxy.Shared.FrameReader; sidecar carries its own copy so the deletion of
|
||||
/// Galaxy.Shared in PR 7.2 doesn't reach the sidecar.
|
||||
/// </summary>
|
||||
public sealed class FrameReader : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly bool _leaveOpen;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="FrameReader"/> class.</summary>
|
||||
/// <param name="stream">The stream to read frames from.</param>
|
||||
/// <param name="leaveOpen">Whether to leave the stream open when disposing.</param>
|
||||
public FrameReader(Stream stream, bool leaveOpen = false)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
/// <summary>Reads the next frame asynchronously from the stream.</summary>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
/// <returns>A tuple of message kind and body, or null if EOF is encountered cleanly.</returns>
|
||||
public async Task<(MessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||
if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
|
||||
return null; // clean EOF on frame boundary
|
||||
|
||||
var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
|
||||
if (length < 0 || length > Framing.MaxFrameBodyBytes)
|
||||
throw new InvalidDataException($"Sidecar IPC frame length {length} out of range.");
|
||||
|
||||
var kindByte = _stream.ReadByte();
|
||||
if (kindByte < 0) throw new EndOfStreamException("EOF after length prefix, before kind byte.");
|
||||
|
||||
var body = new byte[length];
|
||||
if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
|
||||
throw new EndOfStreamException("EOF mid-frame.");
|
||||
|
||||
return ((MessageKind)(byte)kindByte, body);
|
||||
}
|
||||
|
||||
/// <summary>Deserializes the message body to the specified type.</summary>
|
||||
/// <typeparam name="T">The type to deserialize to.</typeparam>
|
||||
/// <param name="body">The serialized message body.</param>
|
||||
public static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
|
||||
|
||||
private async Task<bool> ReadExactAsync(byte[] buffer, CancellationToken ct)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await _stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
if (offset == 0) return false;
|
||||
throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the frame reader and optionally closes the underlying stream.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_leaveOpen) _stream.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
|
||||
/// <see cref="SemaphoreSlim"/> so concurrent producers (heartbeat + reply paths) get
|
||||
/// serialized writes. Mirror of Driver.Galaxy.Shared.FrameWriter; sidecar carries its
|
||||
/// own copy.
|
||||
/// </summary>
|
||||
public sealed class FrameWriter : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly bool _leaveOpen;
|
||||
|
||||
/// <summary>Initializes a new instance of the FrameWriter.</summary>
|
||||
/// <param name="stream">The stream to write frames to.</param>
|
||||
/// <param name="leaveOpen">Whether to leave the stream open when disposed.</param>
|
||||
public FrameWriter(Stream stream, bool leaveOpen = false)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
/// <summary>Writes a frame with the specified message kind and serialized message.</summary>
|
||||
/// <typeparam name="T">The type of message being written.</typeparam>
|
||||
/// <param name="kind">The message kind identifier.</param>
|
||||
/// <param name="message">The message to serialize and write.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public async Task WriteAsync<T>(MessageKind kind, T message, CancellationToken ct)
|
||||
{
|
||||
var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
|
||||
if (body.Length > Framing.MaxFrameBodyBytes)
|
||||
throw new InvalidOperationException(
|
||||
$"Sidecar IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
|
||||
|
||||
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||
// Big-endian — easy to read in hex dumps.
|
||||
lengthPrefix[0] = (byte)((body.Length >> 24) & 0xFF);
|
||||
lengthPrefix[1] = (byte)((body.Length >> 16) & 0xFF);
|
||||
lengthPrefix[2] = (byte)((body.Length >> 8) & 0xFF);
|
||||
lengthPrefix[3] = (byte)( body.Length & 0xFF);
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, ct).ConfigureAwait(false);
|
||||
_stream.WriteByte((byte)kind);
|
||||
await _stream.WriteAsync(body, 0, body.Length, ct).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>Disposes the frame writer and releases resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_gate.Dispose();
|
||||
if (!_leaveOpen) _stream.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Length-prefixed framing constants for the Wonderware historian sidecar TCP protocol.
|
||||
/// Each frame on the wire is:
|
||||
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
|
||||
/// Length is the body size only; the kind byte is not part of the prefixed length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors the Galaxy.Shared framing exactly so the same FrameReader/FrameWriter pattern
|
||||
/// works on both sides. The sidecar's protocol is independent — both the .NET 4.8 server
|
||||
/// side and the .NET 10 client (PR 3.4) carry their own copies of these constants and
|
||||
/// stay in sync via the round-trip test matrix.
|
||||
/// </remarks>
|
||||
public static class Framing
|
||||
{
|
||||
public const int LengthPrefixSize = 4;
|
||||
public const int KindByteSize = 1;
|
||||
|
||||
/// <summary>16 MiB cap protects the receiver from a hostile or buggy peer.</summary>
|
||||
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire identifier for each historian sidecar message. Values are stable — never reorder;
|
||||
/// append new contracts at the end. The .NET 10 client and the .NET 4.8 sidecar must
|
||||
/// agree on every value here.
|
||||
/// </summary>
|
||||
public enum MessageKind : byte
|
||||
{
|
||||
Hello = 0x01,
|
||||
HelloAck = 0x02,
|
||||
|
||||
ReadRawRequest = 0x10,
|
||||
ReadRawReply = 0x11,
|
||||
|
||||
ReadProcessedRequest = 0x12,
|
||||
ReadProcessedReply = 0x13,
|
||||
|
||||
ReadAtTimeRequest = 0x14,
|
||||
ReadAtTimeReply = 0x15,
|
||||
|
||||
ReadEventsRequest = 0x16,
|
||||
ReadEventsReply = 0x17,
|
||||
|
||||
WriteAlarmEventsRequest = 0x20,
|
||||
WriteAlarmEventsReply = 0x21,
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// First frame of every connection. Advertises the sidecar protocol version and the
|
||||
/// per-process shared secret the supervisor passed at spawn time.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class Hello
|
||||
{
|
||||
public const int CurrentMajor = 1;
|
||||
public const int CurrentMinor = 0;
|
||||
|
||||
/// <summary>Gets or sets the protocol major version.</summary>
|
||||
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
|
||||
/// <summary>Gets or sets the protocol minor version.</summary>
|
||||
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
|
||||
/// <summary>Gets or sets the peer name.</summary>
|
||||
[Key(2)] public string PeerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Per-process shared secret — verified against the value the supervisor passed at spawn time.</summary>
|
||||
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Response to a Hello handshake message.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HelloAck
|
||||
{
|
||||
/// <summary>Gets or sets the protocol major version.</summary>
|
||||
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
|
||||
/// <summary>Gets or sets the protocol minor version.</summary>
|
||||
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the handshake was accepted.</summary>
|
||||
[Key(2)] public bool Accepted { get; set; }
|
||||
/// <summary>Gets or sets the rejection reason if Accepted is false.</summary>
|
||||
[Key(3)] public string? RejectReason { get; set; }
|
||||
/// <summary>Gets or sets the host name of the server.</summary>
|
||||
[Key(4)] public string HostName { get; set; } = string.Empty;
|
||||
}
|
||||
-334
@@ -1,334 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Sidecar-side dispatcher. Each post-Hello frame routes by <see cref="MessageKind"/> to
|
||||
/// the right historian operation and the result frame is written back through the same
|
||||
/// pipe. Per-call exceptions are caught and surfaced as <c>Success=false, Error=...</c>
|
||||
/// replies so a single bad request doesn't kill the connection.
|
||||
/// </summary>
|
||||
public sealed class HistorianFrameHandler : IFrameHandler
|
||||
{
|
||||
// WriteAlarmEventsReply.PerEventStatus byte semantics: 0=Ack, 1=Retry, 2=Permanent.
|
||||
private const byte StatusAck = 0;
|
||||
private const byte StatusRetry = 1;
|
||||
private const byte StatusPermanent = 2;
|
||||
|
||||
private readonly IHistorianDataSource _historian;
|
||||
private readonly IAlarmEventWriter? _alarmWriter;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the HistorianFrameHandler class.</summary>
|
||||
/// <param name="historian">The historian data source to query.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="alarmWriter">Optional alarm event writer for writebacks.</param>
|
||||
public HistorianFrameHandler(
|
||||
IHistorianDataSource historian,
|
||||
ILogger logger,
|
||||
IAlarmEventWriter? alarmWriter = null)
|
||||
{
|
||||
_historian = historian ?? throw new ArgumentNullException(nameof(historian));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_alarmWriter = alarmWriter;
|
||||
}
|
||||
|
||||
/// <summary>Handles an incoming frame by dispatching to the appropriate historian operation.</summary>
|
||||
/// <param name="kind">The frame message kind.</param>
|
||||
/// <param name="body">The frame body bytes.</param>
|
||||
/// <param name="writer">The frame writer for sending responses.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
=> kind switch
|
||||
{
|
||||
MessageKind.ReadRawRequest => HandleReadRawAsync(body, writer, ct),
|
||||
MessageKind.ReadProcessedRequest => HandleReadProcessedAsync(body, writer, ct),
|
||||
MessageKind.ReadAtTimeRequest => HandleReadAtTimeAsync(body, writer, ct),
|
||||
MessageKind.ReadEventsRequest => HandleReadEventsAsync(body, writer, ct),
|
||||
MessageKind.WriteAlarmEventsRequest => HandleWriteAlarmEventsAsync(body, writer, ct),
|
||||
_ => UnknownAsync(kind),
|
||||
};
|
||||
|
||||
private Task UnknownAsync(MessageKind kind)
|
||||
{
|
||||
_logger.Warning("Sidecar received unsupported frame kind {Kind}; dropping", kind);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleReadRawAsync(byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadRawRequest>(body);
|
||||
var reply = new ReadRawReply { CorrelationId = req.CorrelationId };
|
||||
try
|
||||
{
|
||||
var samples = await _historian.ReadRawAsync(
|
||||
req.TagName,
|
||||
new DateTime(req.StartUtcTicks, DateTimeKind.Utc),
|
||||
new DateTime(req.EndUtcTicks, DateTimeKind.Utc),
|
||||
req.MaxValues,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
reply.Success = true;
|
||||
reply.Samples = ToWire(samples);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Sidecar ReadRaw failed for {Tag}", req.TagName);
|
||||
reply.Success = false;
|
||||
reply.Error = ex.Message;
|
||||
}
|
||||
|
||||
await writer.WriteAsync(MessageKind.ReadRawReply, reply, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleReadProcessedAsync(byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadProcessedRequest>(body);
|
||||
var reply = new ReadProcessedReply { CorrelationId = req.CorrelationId };
|
||||
try
|
||||
{
|
||||
var buckets = await _historian.ReadAggregateAsync(
|
||||
req.TagName,
|
||||
new DateTime(req.StartUtcTicks, DateTimeKind.Utc),
|
||||
new DateTime(req.EndUtcTicks, DateTimeKind.Utc),
|
||||
req.IntervalMs,
|
||||
req.AggregateColumn,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
reply.Success = true;
|
||||
reply.Buckets = ToWire(buckets);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Sidecar ReadProcessed failed for {Tag}", req.TagName);
|
||||
reply.Success = false;
|
||||
reply.Error = ex.Message;
|
||||
}
|
||||
|
||||
await writer.WriteAsync(MessageKind.ReadProcessedReply, reply, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleReadAtTimeAsync(byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadAtTimeRequest>(body);
|
||||
var reply = new ReadAtTimeReply { CorrelationId = req.CorrelationId };
|
||||
try
|
||||
{
|
||||
var timestamps = new DateTime[req.TimestampsUtcTicks.Length];
|
||||
for (var i = 0; i < timestamps.Length; i++)
|
||||
timestamps[i] = new DateTime(req.TimestampsUtcTicks[i], DateTimeKind.Utc);
|
||||
|
||||
var samples = await _historian.ReadAtTimeAsync(req.TagName, timestamps, ct).ConfigureAwait(false);
|
||||
reply.Success = true;
|
||||
reply.Samples = ToWire(samples);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Sidecar ReadAtTime failed for {Tag}", req.TagName);
|
||||
reply.Success = false;
|
||||
reply.Error = ex.Message;
|
||||
}
|
||||
|
||||
await writer.WriteAsync(MessageKind.ReadAtTimeReply, reply, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleReadEventsAsync(byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadEventsRequest>(body);
|
||||
var reply = new ReadEventsReply { CorrelationId = req.CorrelationId };
|
||||
try
|
||||
{
|
||||
var events = await _historian.ReadEventsAsync(
|
||||
req.SourceName,
|
||||
new DateTime(req.StartUtcTicks, DateTimeKind.Utc),
|
||||
new DateTime(req.EndUtcTicks, DateTimeKind.Utc),
|
||||
req.MaxEvents,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
reply.Success = true;
|
||||
reply.Events = ToWire(events);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Sidecar ReadEvents failed for source {Source}", req.SourceName);
|
||||
reply.Success = false;
|
||||
reply.Error = ex.Message;
|
||||
}
|
||||
|
||||
await writer.WriteAsync(MessageKind.ReadEventsReply, reply, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleWriteAlarmEventsAsync(byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<WriteAlarmEventsRequest>(body);
|
||||
|
||||
// MessagePack deserializes an absent or explicit-nil array as null, not Array.Empty.
|
||||
// Normalise here so every path below can safely dereference .Length without an NRE.
|
||||
req.Events ??= Array.Empty<AlarmHistorianEventDto>();
|
||||
|
||||
var reply = new WriteAlarmEventsReply { CorrelationId = req.CorrelationId };
|
||||
|
||||
if (_alarmWriter is null)
|
||||
{
|
||||
reply.Success = false;
|
||||
reply.Error = "Sidecar not configured with an alarm-event writer.";
|
||||
reply.PerEventOk = new bool[req.Events.Length];
|
||||
reply.PerEventStatus = AllStatus(req.Events.Length, StatusRetry);
|
||||
await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Classify each event before touching the writer: structurally-malformed
|
||||
// (poison) events can never be persisted, so mark them Permanent and exclude
|
||||
// them from the writer batch. Only the well-formed remainder is handed to the
|
||||
// writer, whose bool[] result is mapped back onto the original indices.
|
||||
var status = new byte[req.Events.Length];
|
||||
var writable = new List<AlarmHistorianEventDto>(req.Events.Length);
|
||||
var originalIndex = new List<int>(req.Events.Length);
|
||||
for (var i = 0; i < req.Events.Length; i++)
|
||||
{
|
||||
if (IsStructurallyMalformed(req.Events[i]))
|
||||
{
|
||||
status[i] = StatusPermanent;
|
||||
}
|
||||
else
|
||||
{
|
||||
originalIndex.Add(i);
|
||||
writable.Add(req.Events[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Aligned 1:1 to `writable`; empty when every event was poison (writer skipped).
|
||||
var perEvent = writable.Count == 0
|
||||
? Array.Empty<bool>()
|
||||
: await _alarmWriter.WriteAsync(writable.ToArray(), ct).ConfigureAwait(false);
|
||||
|
||||
for (var i = 0; i < originalIndex.Count; i++)
|
||||
{
|
||||
var ok = i < perEvent.Length && perEvent[i];
|
||||
status[originalIndex[i]] = ok ? StatusAck : StatusRetry;
|
||||
}
|
||||
|
||||
reply.PerEventStatus = status;
|
||||
reply.PerEventOk = StatusToOk(status);
|
||||
reply.Success = true;
|
||||
// Whole-batch Success stays true even when some events failed — per-event
|
||||
// PerEventStatus slots carry the granular result (Ack / Retry / Permanent);
|
||||
// the SQLite drain worker acks 0, retries 1, and dead-letters 2. PerEventOk
|
||||
// is kept populated for rolling-deploy back-compat with an older client.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Sidecar WriteAlarmEvents failed");
|
||||
reply.Success = false;
|
||||
reply.Error = ex.Message;
|
||||
reply.PerEventOk = new bool[req.Events.Length];
|
||||
reply.PerEventStatus = AllStatus(req.Events.Length, StatusRetry);
|
||||
}
|
||||
|
||||
await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies an alarm event as structurally malformed (poison): an event the historian
|
||||
/// event store can never persist regardless of retries. Such events are marked Permanent
|
||||
/// so the store-and-forward sink dead-letters them immediately instead of looping to the
|
||||
/// retry cap. A blank source name or alarm type, or a non-positive event timestamp, are
|
||||
/// the structural invariants the historian write requires.
|
||||
/// </summary>
|
||||
/// <param name="e">The candidate alarm event.</param>
|
||||
/// <returns><c>true</c> when the event is structurally malformed; otherwise <c>false</c>.</returns>
|
||||
internal static bool IsStructurallyMalformed(AlarmHistorianEventDto e) =>
|
||||
e is null
|
||||
|| string.IsNullOrWhiteSpace(e.SourceName)
|
||||
|| string.IsNullOrWhiteSpace(e.AlarmType)
|
||||
|| e.EventTimeUtcTicks <= 0;
|
||||
|
||||
private static byte[] AllStatus(int length, byte value)
|
||||
{
|
||||
var status = new byte[length];
|
||||
for (var i = 0; i < length; i++) status[i] = value;
|
||||
return status;
|
||||
}
|
||||
|
||||
private static bool[] StatusToOk(byte[] status)
|
||||
{
|
||||
var ok = new bool[status.Length];
|
||||
for (var i = 0; i < status.Length; i++) ok[i] = status[i] == StatusAck;
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static HistorianSampleDto[] ToWire(List<HistorianSample> samples)
|
||||
{
|
||||
var dtos = new HistorianSampleDto[samples.Count];
|
||||
for (var i = 0; i < samples.Count; i++)
|
||||
{
|
||||
var s = samples[i];
|
||||
dtos[i] = new HistorianSampleDto
|
||||
{
|
||||
ValueBytes = s.Value is null ? null : MessagePackSerializer.Serialize(s.Value),
|
||||
Quality = s.Quality,
|
||||
TimestampUtcTicks = s.TimestampUtc.Ticks,
|
||||
};
|
||||
}
|
||||
return dtos;
|
||||
}
|
||||
|
||||
private static HistorianAggregateSampleDto[] ToWire(List<HistorianAggregateSample> samples)
|
||||
{
|
||||
var dtos = new HistorianAggregateSampleDto[samples.Count];
|
||||
for (var i = 0; i < samples.Count; i++)
|
||||
{
|
||||
dtos[i] = new HistorianAggregateSampleDto
|
||||
{
|
||||
Value = samples[i].Value,
|
||||
TimestampUtcTicks = samples[i].TimestampUtc.Ticks,
|
||||
};
|
||||
}
|
||||
return dtos;
|
||||
}
|
||||
|
||||
private static HistorianEventDto[] ToWire(List<Backend.HistorianEventDto> events)
|
||||
{
|
||||
var dtos = new HistorianEventDto[events.Count];
|
||||
for (var i = 0; i < events.Count; i++)
|
||||
{
|
||||
var e = events[i];
|
||||
dtos[i] = new HistorianEventDto
|
||||
{
|
||||
EventId = e.Id.ToString(),
|
||||
Source = e.Source,
|
||||
EventTimeUtcTicks = e.EventTime.Ticks,
|
||||
ReceivedTimeUtcTicks = e.ReceivedTime.Ticks,
|
||||
DisplayText = e.DisplayText,
|
||||
Severity = e.Severity,
|
||||
};
|
||||
}
|
||||
return dtos;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for persisting alarm events into the Wonderware Alarm & Events log. PR 3.W
|
||||
/// supplies a real implementation that drives the aahClient SDK; PR 3.3 ships the
|
||||
/// contract + a default null implementation so the sidecar can boot without one.
|
||||
/// </summary>
|
||||
public interface IAlarmEventWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes a batch of alarm events. Returns one boolean per input event indicating
|
||||
/// persisted vs. retry-please. The SQLite store-and-forward sink retries failed
|
||||
/// slots on the next drain tick.
|
||||
/// </summary>
|
||||
/// <param name="events">Alarm events to write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<bool[]> WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for handling each post-Hello frame the sidecar's <see cref="TcpFrameServer"/>
|
||||
/// reads. Implementations deserialize the body per the <see cref="MessageKind"/>, dispatch
|
||||
/// to the historian, and write the corresponding reply through the supplied
|
||||
/// <see cref="FrameWriter"/>.
|
||||
/// </summary>
|
||||
public interface IFrameHandler
|
||||
{
|
||||
/// <summary>Handles a frame from the sidecar frame server.</summary>
|
||||
/// <param name="kind">The type of message being handled.</param>
|
||||
/// <param name="body">The serialized message body.</param>
|
||||
/// <param name="writer">The frame writer to send responses.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Accepts one TCP client at a time, optionally over TLS, verifies the shared-secret
|
||||
/// Hello, then dispatches frames to <see cref="IFrameHandler"/>. Authentication is the
|
||||
/// shared secret carried in the Hello frame, optionally over a TLS-protected channel.
|
||||
/// </summary>
|
||||
public sealed class TcpFrameServer : IDisposable
|
||||
{
|
||||
private readonly IPAddress _bind;
|
||||
private readonly int _port;
|
||||
private readonly string _sharedSecret;
|
||||
private readonly X509Certificate2? _tlsCert; // null = plaintext
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private TcpListener? _listener;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="TcpFrameServer"/> class.</summary>
|
||||
/// <param name="bind">The IP address to bind the listener to.</param>
|
||||
/// <param name="port">The TCP port to bind (0 lets the OS pick a free port).</param>
|
||||
/// <param name="sharedSecret">The shared secret the client's Hello must match.</param>
|
||||
/// <param name="tlsCert">The server certificate for TLS; <c>null</c> for plaintext.</param>
|
||||
/// <param name="logger">The logger for diagnostic messages.</param>
|
||||
public TcpFrameServer(IPAddress bind, int port, string sharedSecret, X509Certificate2? tlsCert, ILogger logger)
|
||||
{
|
||||
_bind = bind ?? throw new ArgumentNullException(nameof(bind));
|
||||
_port = port;
|
||||
_sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
|
||||
_tlsCert = tlsCert;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>The port the listener actually bound (useful when constructed with port 0 in tests).</summary>
|
||||
public int BoundPort => ((IPEndPoint)_listener!.LocalEndpoint).Port;
|
||||
|
||||
private void EnsureListening()
|
||||
{
|
||||
if (_listener is not null) return;
|
||||
|
||||
// Assign _listener ONLY after Start() succeeds. If Start() throws (e.g. the port is in
|
||||
// a Windows excluded/reserved range → WSAEACCES "access forbidden", or already in use),
|
||||
// _listener must stay null so the next RunAsync iteration retries the full create+Start.
|
||||
// Assigning before Start() leaves a non-null-but-unstarted listener that the
|
||||
// `if (_listener is not null) return` guard would never re-Start, turning a one-time
|
||||
// bind error into a permanent misleading "Not listening" crash loop.
|
||||
var listener = new TcpListener(_bind, _port);
|
||||
listener.Start();
|
||||
_listener = listener;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts one connection, performs the Hello handshake, then dispatches frames to
|
||||
/// <paramref name="handler"/> until EOF or cancel. Returns when the client disconnects.
|
||||
/// </summary>
|
||||
/// <param name="handler">The frame handler to process frames.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
|
||||
EnsureListening();
|
||||
|
||||
// net48 has no AcceptTcpClientAsync(CancellationToken); Stop() unblocks a pending accept.
|
||||
using var reg = linked.Token.Register(() => { try { _listener!.Stop(); } catch { /* ignore */ } });
|
||||
TcpClient client;
|
||||
try { client = await _listener!.AcceptTcpClientAsync().ConfigureAwait(false); }
|
||||
catch (ObjectDisposedException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); }
|
||||
catch (InvalidOperationException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); }
|
||||
catch (SocketException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); }
|
||||
|
||||
using (client)
|
||||
{
|
||||
// net48's NetworkStream.ReadAsync ignores the CancellationToken, so cancelling the
|
||||
// token alone cannot unblock the frame loop when it's parked reading an idle client —
|
||||
// only closing the socket does. Register the cancel to Close() the active client so
|
||||
// RunAsync actually unwinds on shutdown (mirrors the listener.Stop() above that
|
||||
// unblocks a parked AcceptTcpClientAsync). Without this, RunAsync().GetAwaiter() in
|
||||
// Program.Main never returns on Ctrl-C/service-stop while a connection is open.
|
||||
using var clientReg = linked.Token.Register(() => { try { client.Close(); } catch { /* ignore */ } });
|
||||
client.NoDelay = true;
|
||||
Stream stream = client.GetStream();
|
||||
SslStream? ssl = null;
|
||||
try
|
||||
{
|
||||
if (_tlsCert is not null)
|
||||
{
|
||||
ssl = new SslStream(stream, leaveInnerStreamOpen: false);
|
||||
await ssl.AuthenticateAsServerAsync(_tlsCert, clientCertificateRequired: false,
|
||||
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false).ConfigureAwait(false);
|
||||
stream = ssl;
|
||||
}
|
||||
|
||||
using var reader = new FrameReader(stream, leaveOpen: true);
|
||||
using var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
|
||||
var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
if (first is null || first.Value.Kind != MessageKind.Hello)
|
||||
{
|
||||
_logger.Warning("Sidecar TCP first frame was not Hello; dropping");
|
||||
return;
|
||||
}
|
||||
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||
if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, linked.Token).ConfigureAwait(false);
|
||||
_logger.Warning("Sidecar TCP Hello rejected: shared-secret-mismatch");
|
||||
return;
|
||||
}
|
||||
if (hello.ProtocolMajor != Hello.CurrentMajor)
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = false, RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}" },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
_logger.Warning("Sidecar TCP Hello rejected: major mismatch peer={Peer} server={Server}", hello.ProtocolMajor, Hello.CurrentMajor);
|
||||
return;
|
||||
}
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = true, HostName = Environment.MachineName }, linked.Token).ConfigureAwait(false);
|
||||
|
||||
while (!linked.Token.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
if (frame is null) break;
|
||||
await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception) when (linked.Token.IsCancellationRequested)
|
||||
{
|
||||
// The clientReg cancel callback closed the socket mid-read/handshake (net48 read
|
||||
// doesn't observe the token); surface it as cancellation so RunAsync's
|
||||
// OperationCanceledException path unwinds cleanly instead of logging a connection
|
||||
// failure and counting it toward MaxConsecutiveFailures.
|
||||
throw new OperationCanceledException(linked.Token);
|
||||
}
|
||||
finally { ssl?.Dispose(); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- exponential backoff / give-up policy between accepted connections ----
|
||||
private static readonly TimeSpan[] BackoffSteps =
|
||||
{
|
||||
TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maximum consecutive failures before the server gives up and lets the process exit
|
||||
/// so the supervisor (NSSM / SCM) can restart the sidecar cleanly.
|
||||
/// </summary>
|
||||
private const int MaxConsecutiveFailures = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the server continuously, handling one connection at a time. When a connection
|
||||
/// ends (clean or error), waits with exponential backoff before accepting the next.
|
||||
/// If <see cref="MaxConsecutiveFailures"/> consecutive failures occur the method
|
||||
/// throws so the supervisor can restart the sidecar.
|
||||
/// </summary>
|
||||
/// <param name="handler">The frame handler to process frames.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
|
||||
{
|
||||
var consecutiveFailures = 0;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); consecutiveFailures = 0; }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
consecutiveFailures++;
|
||||
if (consecutiveFailures >= MaxConsecutiveFailures)
|
||||
{
|
||||
_logger.Fatal(ex, "Sidecar TCP connection loop failed {Count} consecutive times — giving up so supervisor can restart", consecutiveFailures);
|
||||
throw;
|
||||
}
|
||||
var delay = BackoffSteps[Math.Min(consecutiveFailures - 1, BackoffSteps.Length - 1)];
|
||||
_logger.Error(ex, "Sidecar TCP connection loop error (consecutive failure {Count}/{Max}) — retrying in {Delay}", consecutiveFailures, MaxConsecutiveFailures, delay);
|
||||
try { await Task.Delay(delay, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes the server, stops the listener, and cancels any pending operations.</summary>
|
||||
public void Dispose() { _cts.Cancel(); try { _listener?.Stop(); } catch { /* ignore */ } _cts.Dispose(); }
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the Wonderware Historian sidecar. Reads the shared secret, TCP
|
||||
/// bind/port, optional TLS settings, and historian connection config from environment
|
||||
/// (the supervisor passes them at spawn time per <c>driver-stability.md</c>). Hosts a
|
||||
/// TCP server (optionally over TLS) dispatching the five sidecar contracts (PR 3.3) to
|
||||
/// the Wonderware Historian SDK.
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>Entry point for the Wonderware Historian sidecar process.</summary>
|
||||
/// <param name="args">Command-line arguments (unused).</param>
|
||||
/// <returns>0 on success, 2 on fatal error.</returns>
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.File(
|
||||
@"%ProgramData%\OtOpcUa\historian-wonderware-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)),
|
||||
rollingInterval: RollingInterval.Day)
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SECRET")
|
||||
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_SECRET not set — supervisor must pass the per-process secret at spawn time");
|
||||
|
||||
var tcpPort = TryParseInt("OTOPCUA_HISTORIAN_TCP_PORT", 32569);
|
||||
var bindRaw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_BIND");
|
||||
var bind = string.IsNullOrWhiteSpace(bindRaw) ? IPAddress.Any : IPAddress.Parse(bindRaw);
|
||||
var tlsEnabled = string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_ENABLED"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
X509Certificate2? tlsCert = tlsEnabled ? LoadTlsCert() : null;
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
||||
|
||||
// Sidecar can boot in "tcp idle" mode (no real Wonderware Historian SDK
|
||||
// initialization) for smoke + IPC tests. Production sets ENABLED=true so the
|
||||
// SDK opens its connection up front.
|
||||
var historianEnabled = string.Equals(
|
||||
Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ENABLED"),
|
||||
"true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!historianEnabled)
|
||||
{
|
||||
Log.Information("Wonderware historian sidecar starting in tcp idle mode (SDK disabled) (OTOPCUA_HISTORIAN_ENABLED!=true) — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null);
|
||||
cts.Token.WaitHandle.WaitOne();
|
||||
Log.Information("Wonderware historian sidecar stopping cleanly");
|
||||
return 0;
|
||||
}
|
||||
|
||||
using var historian = BuildHistorian();
|
||||
var alarmWriter = BuildAlarmWriter();
|
||||
var handler = new HistorianFrameHandler(historian, Log.Logger, alarmWriter);
|
||||
using var server = new TcpFrameServer(bind, tcpPort, sharedSecret, tlsCert, Log.Logger);
|
||||
|
||||
Log.Information("Wonderware historian sidecar serving — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null);
|
||||
try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); }
|
||||
catch (OperationCanceledException) { /* clean shutdown via Ctrl-C */ }
|
||||
|
||||
Log.Information("Wonderware historian sidecar stopped cleanly");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Wonderware historian sidecar fatal");
|
||||
return 2;
|
||||
}
|
||||
finally { Log.CloseAndFlush(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the Wonderware Historian data source from environment variables. Mirrors
|
||||
/// the env-var contract that <c>Driver.Galaxy.Host</c> used in v1; PR 3.W reaffirms
|
||||
/// this contract in install scripts.
|
||||
/// </summary>
|
||||
private static HistorianDataSource BuildHistorian()
|
||||
{
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
|
||||
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
|
||||
IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase),
|
||||
UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"),
|
||||
Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"),
|
||||
CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30),
|
||||
MaxValuesPerRead = TryParseInt("OTOPCUA_HISTORIAN_MAX_VALUES", 10000),
|
||||
FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60),
|
||||
};
|
||||
|
||||
var servers = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVERS");
|
||||
if (!string.IsNullOrWhiteSpace(servers))
|
||||
cfg.ServerNames = new System.Collections.Generic.List<string>(
|
||||
servers.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
Log.Information("Sidecar Historian config — {NodeCount} node(s), port={Port}",
|
||||
cfg.ServerNames.Count > 0 ? cfg.ServerNames.Count : 1, cfg.Port);
|
||||
return new HistorianDataSource(cfg);
|
||||
}
|
||||
|
||||
private static int TryParseInt(string envName, int defaultValue)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(envName);
|
||||
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the TLS server certificate when TLS is enabled. The reference is either a
|
||||
/// <c>.pfx</c> file path (decrypted with the optional password env var) or, if not a
|
||||
/// file, a thumbprint resolved from the <c>LocalMachine\My</c> store.
|
||||
/// </summary>
|
||||
private static X509Certificate2 LoadTlsCert()
|
||||
{
|
||||
var certRef = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_CERT")
|
||||
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_TLS_CERT not set but TLS enabled — supply a .pfx path or a LocalMachine\\My store thumbprint");
|
||||
var pwd = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_CERT_PASSWORD");
|
||||
if (System.IO.File.Exists(certRef))
|
||||
return new X509Certificate2(certRef, pwd, X509KeyStorageFlags.MachineKeySet);
|
||||
// else treat as a thumbprint in LocalMachine\My
|
||||
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
var found = store.Certificates.Find(X509FindType.FindByThumbprint, certRef.Replace(" ", ""), validOnly: false);
|
||||
if (found.Count == 0) throw new InvalidOperationException($"OTOPCUA_HISTORIAN_TLS_CERT thumbprint '{certRef}' not found in LocalMachine\\My and is not a file path");
|
||||
return found[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the alarm-event writer when the alarm-write toggle is on, otherwise
|
||||
/// returns <c>null</c> so <see cref="HistorianFrameHandler"/> falls back to the
|
||||
/// "not configured" reply for any incoming <c>WriteAlarmEvents</c> frame.
|
||||
/// Default is <c>true</c> when <c>OTOPCUA_HISTORIAN_ENABLED=true</c>; explicitly
|
||||
/// set <c>OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false</c> to keep a read-only
|
||||
/// deployment that still loads the SDK for reads.
|
||||
/// </summary>
|
||||
internal static IAlarmEventWriter? BuildAlarmWriter()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED");
|
||||
var enabled = string.IsNullOrWhiteSpace(raw)
|
||||
? true
|
||||
: !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
Log.Information("Alarm-event writer disabled (OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false); historian sidecar will reject WriteAlarmEvents frames.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var cfg = BuildAlarmWriterConfig();
|
||||
var backend = new SdkAlarmHistorianWriteBackend(cfg);
|
||||
Log.Information("Alarm-event writer enabled — backend=SdkAlarmHistorianWriteBackend server={Server}", cfg.ServerName);
|
||||
return new AahClientManagedAlarmEventWriter(backend);
|
||||
}
|
||||
|
||||
private static HistorianConfiguration BuildAlarmWriterConfig()
|
||||
{
|
||||
return new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
|
||||
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
|
||||
IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase),
|
||||
UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"),
|
||||
Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"),
|
||||
CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30),
|
||||
FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60),
|
||||
};
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<!-- x64 — AVEVA Historian 2020 ships an x64 build of aahClientManaged + the native
|
||||
aahClient.dll under the Historian install's x64\ subfolder. The other three
|
||||
SDK assemblies (Historian.CBE / DPAPI / ArchestrA.CloudHistorian.Contract) are
|
||||
pure-managed AnyCPU and load fine in either bitness. The earlier x86 default
|
||||
was inherited from v1's Galaxy.Host bitness (MXAccess COM, retired in PR 7.2)
|
||||
and didn't reflect any constraint of the Historian SDK itself. -->
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware</RootNamespace>
|
||||
<AssemblyName>OtOpcUa.Driver.Historian.Wonderware</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack"/>
|
||||
<PackageReference Include="System.Memory"/>
|
||||
<PackageReference Include="System.Threading.Tasks.Extensions"/>
|
||||
<PackageReference Include="System.Data.SqlClient"/>
|
||||
<PackageReference Include="Serilog"/>
|
||||
<PackageReference Include="Serilog.Sinks.File"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Wonderware Historian SDK — consumed by Backend/ for HistoryReadAsync.
|
||||
Lifted from Driver.Galaxy.Host in PR 3.2 so the sidecar owns the 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 and satellite DLLs — staged beside the host exe so the
|
||||
aahClientManaged wrapper can P/Invoke into them without an AssemblyResolve hook. -->
|
||||
<None Include="..\..\..\lib\aahClient.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>
|
||||
|
||||
</Project>
|
||||
-1
@@ -60,7 +60,6 @@ else
|
||||
["Focas"] = typeof(FocasDriverPage),
|
||||
["OpcUaClient"] = typeof(OpcUaClientDriverPage),
|
||||
["GalaxyMxGateway"] = typeof(GalaxyDriverPage),
|
||||
["Historian.Wonderware"] = typeof(HistorianWonderwareDriverPage),
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
|
||||
-1
@@ -45,6 +45,5 @@
|
||||
new DriverTypeEntry("Focas", "focas", "[FOC]", "Fanuc CNC via FOCAS library."),
|
||||
new DriverTypeEntry("OpcUaClient", "opcuaclient", "[OPC]", "Upstream OPC UA server pull."),
|
||||
new DriverTypeEntry("Galaxy", "galaxy", "[Gx]", "AVEVA System Platform (Wonderware) via mxaccessgw."),
|
||||
new DriverTypeEntry("Historian.Wonderware", "historianwonderware","[Hx]", "Wonderware Historian replay/cyclic reads."),
|
||||
};
|
||||
}
|
||||
|
||||
-367
@@ -1,367 +0,0 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/historianwonderware"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New Wonderware Historian driver" : "Edit Wonderware Historian driver") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="historianwonderwareDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
||||
|
||||
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
|
||||
|
||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
||||
{
|
||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
||||
}
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
||||
GetConfigJson="@SerializeCurrentConfig"
|
||||
TimeoutSeconds="@_form.Historian.ProbeTimeoutSeconds" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="Historian Wonderware address"
|
||||
CurrentAddress="@_pickedAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<HistorianWonderwareAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Sidecar host</label>
|
||||
<InputText @bind-Value="_form.Historian.Host" class="form-control form-control-sm mono"
|
||||
placeholder="localhost" />
|
||||
<div class="form-text">DNS name or IP the historian sidecar's TCP listener is reachable at.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Sidecar port</label>
|
||||
<InputNumber @bind-Value="_form.Historian.Port" class="form-control form-control-sm mono" />
|
||||
<div class="form-text">Must match the sidecar's <code>OTOPCUA_HISTORIAN_TCP_PORT</code>.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Shared secret</label>
|
||||
<InputText @bind-Value="_form.Historian.SharedSecret" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
<div class="form-text">Per-process secret verified in the Hello frame — must match the sidecar's configured secret.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Peer name (diagnostic)</label>
|
||||
<InputText @bind-Value="_form.Historian.PeerName" class="form-control form-control-sm"
|
||||
placeholder="OtOpcUa" />
|
||||
<div class="form-text">Sent in Hello for sidecar logging. Default: OtOpcUa.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">TLS</label>
|
||||
<div class="form-check mt-1">
|
||||
<InputCheckbox @bind-Value="_form.Historian.UseTls" class="form-check-input" id="historianUseTls" />
|
||||
<label class="form-check-label" for="historianUseTls">Use TLS</label>
|
||||
</div>
|
||||
<div class="form-text">Wrap the sidecar TCP stream in TLS before the Hello handshake.</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Server cert thumbprint (TLS pin)</label>
|
||||
<InputText @bind-Value="_form.Historian.ServerCertThumbprint" class="form-control form-control-sm mono" />
|
||||
<div class="form-text">SHA-1 thumbprint to pin; blank = validate CA chain.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Timing *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Timing</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Connect timeout (s, blank = default 10 s)</label>
|
||||
<InputNumber @bind-Value="_form.Historian.ConnectTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Cap on TCP connect + Hello round-trip. Null = 10 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Call timeout (s, blank = default 30 s)</label>
|
||||
<InputNumber @bind-Value="_form.Historian.CallTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Cap on a single read/write once connected. Null = 30 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Effective connect timeout (s)</label>
|
||||
<input class="form-control form-control-sm" readonly
|
||||
value="@(_form.Historian.ConnectTimeoutSeconds?.ToString() ?? "10 (default)")" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Effective call timeout (s)</label>
|
||||
<input class="form-control form-control-sm" readonly
|
||||
value="@(_form.Historian.CallTimeoutSeconds?.ToString() ?? "30 (default)")" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Diagnostics *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Diagnostics</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.Historian.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private const string DriverTypeKey = "Historian.Wonderware";
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
||||
private DriverInstance? _existing;
|
||||
private List<Namespace> _namespaces = new();
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
// Address picker state
|
||||
private bool _showPicker;
|
||||
private string _pickedAddress = "";
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_namespaces = await db.Namespaces.AsNoTracking()
|
||||
.Where(n => n.ClusterId == ClusterId)
|
||||
.OrderBy(n => n.NamespaceId)
|
||||
.ToListAsync();
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = "",
|
||||
Name = "",
|
||||
DriverType = DriverTypeKey,
|
||||
NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "",
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
}
|
||||
else
|
||||
{
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
Name = _existing.Name,
|
||||
DriverType = _existing.DriverType,
|
||||
NamespaceId = _existing.NamespaceId,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? CreateDefaultOptions();
|
||||
_form = new FormModel();
|
||||
_form.Historian = WonderwareHistorianClientFormModel.FromRecord(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private static WonderwareHistorianClientOptions CreateDefaultOptions() =>
|
||||
new(Host: "localhost", Port: 32569, SharedSecret: "") { UseTls = false, ServerCertThumbprint = null };
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var opts = _form.Historian.ToRecord();
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = _identityModel.NamespaceId,
|
||||
Name = _identityModel.Name,
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = _identityModel.Enabled,
|
||||
DriverConfig = configJson,
|
||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.NamespaceId = _identityModel.NamespaceId;
|
||||
entity.Name = _identityModel.Name;
|
||||
entity.Enabled = _identityModel.Enabled;
|
||||
entity.DriverConfig = configJson;
|
||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.DriverInstances.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(_form.Historian.ToRecord(), _jsonOpts);
|
||||
|
||||
private static WonderwareHistorianClientOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public sealed class FormModel
|
||||
{
|
||||
public WonderwareHistorianClientFormModel Historian { get; set; } = new();
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable mirror of <see cref="WonderwareHistorianClientOptions"/> (positional record).
|
||||
/// <c>ConnectTimeoutSeconds</c> and <c>CallTimeoutSeconds</c> are nullable int — null
|
||||
/// round-trips to a null TimeSpan?, which the record resolves to its compiled default.
|
||||
/// </summary>
|
||||
public sealed class WonderwareHistorianClientFormModel
|
||||
{
|
||||
public string Host { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 32569;
|
||||
public string SharedSecret { get; set; } = "";
|
||||
public string PeerName { get; set; } = "OtOpcUa";
|
||||
public int? ConnectTimeoutSeconds { get; set; }
|
||||
public int? CallTimeoutSeconds { get; set; }
|
||||
public int ProbeTimeoutSeconds { get; set; } = 15;
|
||||
public bool UseTls { get; set; }
|
||||
public string? ServerCertThumbprint { get; set; }
|
||||
|
||||
public static WonderwareHistorianClientFormModel FromRecord(WonderwareHistorianClientOptions r) => new()
|
||||
{
|
||||
Host = r.Host,
|
||||
Port = r.Port,
|
||||
SharedSecret = r.SharedSecret,
|
||||
PeerName = r.PeerName,
|
||||
ConnectTimeoutSeconds = r.ConnectTimeout.HasValue ? (int)r.ConnectTimeout.Value.TotalSeconds : null,
|
||||
CallTimeoutSeconds = r.CallTimeout.HasValue ? (int)r.CallTimeout.Value.TotalSeconds : null,
|
||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||
UseTls = r.UseTls,
|
||||
ServerCertThumbprint = r.ServerCertThumbprint,
|
||||
};
|
||||
|
||||
public WonderwareHistorianClientOptions ToRecord() => new(
|
||||
Host: Host,
|
||||
Port: Port,
|
||||
SharedSecret: SharedSecret,
|
||||
PeerName: PeerName,
|
||||
ConnectTimeout: ConnectTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(ConnectTimeoutSeconds.Value) : null,
|
||||
CallTimeout: CallTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(CallTimeoutSeconds.Value) : null)
|
||||
{
|
||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
||||
UseTls = UseTls,
|
||||
ServerCertThumbprint = ServerCertThumbprint,
|
||||
};
|
||||
}
|
||||
}
|
||||
-1
@@ -36,7 +36,6 @@
|
||||
<option value="Focas">Focas</option>
|
||||
<option value="OpcUaClient">OpcUaClient</option>
|
||||
<option value="GalaxyMxGateway">Galaxy</option>
|
||||
<option value="Historian.Wonderware">Historian.Wonderware</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
|
||||
</div>
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
|
||||
|
||||
/// <summary>
|
||||
/// Pure static helper that converts a Wonderware Historian tag name + retrieval mode
|
||||
/// + interval into the canonical address query string (e.g. MyTag?mode=Cyclic&interval=60).
|
||||
/// Extracted so unit tests can call it without bUnit.
|
||||
/// </summary>
|
||||
public static class HistorianWonderwareAddressBuilder
|
||||
{
|
||||
public static string Build(string tagName, string mode, int interval)
|
||||
// Percent-encode the tag name so a name carrying query-reserved characters (? & # =) can't
|
||||
// corrupt the produced query string (AdminUI-005). Mode is a fixed enum-style token, so it
|
||||
// needs no encoding.
|
||||
=> $"{Uri.EscapeDataString(tagName)}?mode={mode}&interval={interval}";
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
@* Static Wonderware Historian address builder: tag name + retrieval mode + interval
|
||||
→ MyTag?mode=Cyclic&interval=60 *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Tag name</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="SysTimeHour"
|
||||
@bind="_tagName" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Retrieval mode</label>
|
||||
<select class="form-select form-select-sm" @bind="_mode" @bind:after="OnChangedAsync">
|
||||
<option value="Last">Last</option>
|
||||
<option value="Cyclic">Cyclic</option>
|
||||
<option value="Delta">Delta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Interval (seconds)</label>
|
||||
<input type="number" class="form-control form-control-sm" min="1"
|
||||
@bind="_interval" @bind:after="OnChangedAsync" />
|
||||
<div class="form-text">Polling/retrieval interval.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="text-muted small">Result:</span>
|
||||
<code class="mono ms-2">@_built</code>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string CurrentAddress { get; set; } = "";
|
||||
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
|
||||
|
||||
private string _tagName = "";
|
||||
private string _mode = "Cyclic";
|
||||
private int _interval = 60;
|
||||
private string _built = "";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_built = string.IsNullOrWhiteSpace(_tagName) ? "" : HistorianWonderwareAddressBuilder.Build(_tagName, _mode, _interval);
|
||||
_ = CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
private async Task OnChangedAsync()
|
||||
{
|
||||
_built = string.IsNullOrWhiteSpace(_tagName) ? "" : HistorianWonderwareAddressBuilder.Build(_tagName, _mode, _interval);
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-12"><label class="form-label">Historian tagname (FullName)</label>
|
||||
<input type="text" class="form-control form-control-sm mono" value="@_m.FullName"
|
||||
placeholder="Reactor1.Temperature"
|
||||
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
|
||||
<div class="form-text">The AVEVA Historian tagname the driver reads against.</div></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
|
||||
private HistorianWonderwareTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = HistorianWonderwareTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for a Wonderware (AVEVA) Historian equipment tag's TagConfig JSON. The
|
||||
/// tag binds to a historian tag by its full reference (<c>FullName</c> — the historian tagname/source
|
||||
/// the driver reads against). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
/// <remarks>
|
||||
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
|
||||
/// (<c>AddressSpaceComposer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
|
||||
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
|
||||
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
|
||||
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
|
||||
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
|
||||
/// </remarks>
|
||||
public sealed class HistorianWonderwareTagConfigModel
|
||||
{
|
||||
/// <summary>Historian tagname/source the tag binds to (the driver-side full reference). Required.</summary>
|
||||
public string FullName { get; set; } = "";
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
/// <param name="json">The tag's TagConfig JSON (null/blank/malformed ⇒ defaults).</param>
|
||||
public static HistorianWonderwareTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new HistorianWonderwareTagConfigModel
|
||||
{
|
||||
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
|
||||
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); any history keys merged
|
||||
/// by the TagModal (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as
|
||||
/// preserved unknown keys.</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(FullName) ? "A historian tagname (FullName) is required." : null;
|
||||
}
|
||||
@@ -17,7 +17,6 @@ public static class TagConfigEditorMap
|
||||
["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
|
||||
["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
||||
["OpcUaClient"] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
||||
["Historian.Wonderware"] = typeof(Components.Shared.Uns.TagEditors.HistorianWonderwareTagConfigEditor),
|
||||
};
|
||||
|
||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||
|
||||
@@ -19,7 +19,6 @@ public static class TagConfigValidator
|
||||
["TwinCat"] = j => TwinCATTagConfigModel.FromJson(j).Validate(),
|
||||
["Focas"] = j => FocasTagConfigModel.FromJson(j).Validate(),
|
||||
["OpcUaClient"] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
|
||||
["Historian.Wonderware"] = j => HistorianWonderwareTagConfigModel.FromJson(j).Validate(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -15,7 +15,6 @@ using TwinCATProbe = Driver.TwinCAT.TwinCATDriverProbe;
|
||||
using FocasProbe = Driver.FOCAS.FocasDriverProbe;
|
||||
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
|
||||
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
|
||||
using HistorianProbe = Driver.Historian.Wonderware.Client.WonderwareHistorianDriverProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
||||
@@ -84,7 +83,6 @@ public static class DriverFactoryBootstrap
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, HistorianProbe>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -99,9 +99,8 @@ if (hasDriver)
|
||||
// overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins)
|
||||
// with a SqliteStoreAndForwardSink draining to the gateway SendEvent writer. The alarm-write path
|
||||
// targets the SAME single gateway as the read path, so its connection (endpoint/key/TLS) is sourced
|
||||
// from the ServerHistorian section — NOT the legacy Wonderware-shaped AlarmHistorian host/port.
|
||||
// AlarmHistorianOptions still supplies the Enabled gate + the SQLite store-and-forward knobs
|
||||
// (consumed inside AddAlarmHistorian), so its Wonderware connection fields are intentionally unused.
|
||||
// from the ServerHistorian section. AlarmHistorianOptions supplies only the Enabled gate + the
|
||||
// SQLite store-and-forward knobs (consumed inside AddAlarmHistorian) — it carries no connection fields.
|
||||
// Runtime owns the gating + Sqlite construction; the Host supplies the concrete gateway downstream
|
||||
// via the driver factory (which owns the package-client adapter). The writer builds its OWN gateway
|
||||
// channel — a second channel to the same sidecar: sharing one channel with the read path would force
|
||||
|
||||
@@ -54,15 +54,14 @@
|
||||
called from DriverFactoryBootstrap on driver-role nodes; the F7 seam (IDriverFactory)
|
||||
then exposes the registry to DriverHostActor. Galaxy is net10 because it talks gRPC to
|
||||
the out-of-process mxaccessgw worker — the COM-bound net48 piece is over there.
|
||||
Historian.Wonderware (the net48 COM-bridge driver) is intentionally excluded; the
|
||||
net10 .Client gRPC wrapper is what production binds when the historian role is needed. -->
|
||||
The historian read/write backend is the Historian.Gateway driver (gRPC to HistorianGateway);
|
||||
the retired Wonderware historian sidecar projects are no longer referenced. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
|
||||
@@ -233,13 +233,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
||||
/// configured roles. Only the v1 in-process types stay Windows-only:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>"Galaxy"</c> — legacy MXAccess COM proxy (retired in PR 7.2; gated for any
|
||||
/// leftover DriverInstance rows that still reference the old type name).</item>
|
||||
/// <item><c>"Historian.Wonderware"</c> — Wonderware Historian sidecar over Windows-only
|
||||
/// named pipes.</item>
|
||||
/// </list>
|
||||
/// configured roles. Only the legacy v1 in-process <c>"Galaxy"</c> type stays Windows-only:
|
||||
/// the legacy MXAccess COM proxy (retired in PR 7.2; gated for any leftover DriverInstance
|
||||
/// rows that still reference the old type name).
|
||||
/// The v2 <c>"GalaxyMxGateway"</c> driver talks gRPC to an external mxaccessgw process,
|
||||
/// so it runs on any platform .NET 10 supports — Linux containers included. Not stubbed.
|
||||
/// </summary>
|
||||
@@ -247,7 +243,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <param name="roles">Operational roles configured for this instance.</param>
|
||||
public static bool ShouldStub(string driverType, IEnumerable<string> roles)
|
||||
{
|
||||
var isWindowsOnly = driverType is "Galaxy" or "Historian.Wonderware";
|
||||
var isWindowsOnly = driverType is "Galaxy";
|
||||
if (!OperatingSystem.IsWindows() && isWindowsOnly) return true;
|
||||
if (roles.Contains("dev") && isWindowsOnly) return true;
|
||||
return false;
|
||||
|
||||
@@ -8,8 +8,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
/// Binds the <c>AlarmHistorian</c> configuration section that gates the durable
|
||||
/// store-and-forward alarm sink. When <see cref="Enabled"/> is <c>true</c>,
|
||||
/// <c>AddAlarmHistorian</c> registers a <c>SqliteStoreAndForwardSink</c> (draining to the
|
||||
/// Wonderware TCP writer supplied by the Host) in place of the
|
||||
/// <c>NullAlarmHistorianSink</c> default; otherwise the Null default survives.
|
||||
/// gateway alarm writer supplied by the Host) in place of the
|
||||
/// <c>NullAlarmHistorianSink</c> default; otherwise the Null default survives. This section
|
||||
/// supplies only the <see cref="Enabled"/> gate and the SQLite store-and-forward knobs — the
|
||||
/// downstream connection (endpoint/key/TLS) is sourced from the <c>ServerHistorian</c> section.
|
||||
/// </summary>
|
||||
public sealed class AlarmHistorianOptions
|
||||
{
|
||||
@@ -25,21 +27,6 @@ public sealed class AlarmHistorianOptions
|
||||
/// <summary>Filesystem path to the local SQLite store-and-forward queue database.</summary>
|
||||
public string DatabasePath { get; init; } = "alarm-historian.db";
|
||||
|
||||
/// <summary>TCP hostname or IP address the Wonderware historian sidecar listens on.</summary>
|
||||
public string Host { get; init; } = "localhost";
|
||||
|
||||
/// <summary>TCP port the Wonderware historian sidecar listens on.</summary>
|
||||
public int Port { get; init; } = 32569;
|
||||
|
||||
/// <summary>When <c>true</c>, the client connects over TLS.</summary>
|
||||
public bool UseTls { get; init; }
|
||||
|
||||
/// <summary>Expected TLS server certificate thumbprint (hex, no spaces). Null or empty disables pinning.</summary>
|
||||
public string? ServerCertThumbprint { get; init; }
|
||||
|
||||
/// <summary>Per-process shared secret the sidecar verifies in the Hello frame.</summary>
|
||||
public string SharedSecret { get; init; } = "";
|
||||
|
||||
/// <summary>Maximum number of queued rows the drain worker forwards in a single batch.</summary>
|
||||
public int BatchSize { get; init; } = 100;
|
||||
|
||||
@@ -64,8 +51,6 @@ public sealed class AlarmHistorianOptions
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (!Enabled) return warnings;
|
||||
if (string.IsNullOrWhiteSpace(SharedSecret))
|
||||
warnings.Add("AlarmHistorian:SharedSecret is empty while the historian is enabled — the Wonderware sidecar Hello frame will carry an empty secret.");
|
||||
if (!Path.IsPathRooted(DatabasePath))
|
||||
warnings.Add($"AlarmHistorian:DatabasePath '{DatabasePath}' is relative — it resolves against the process working directory (e.g. System32 for a Windows service). Set an absolute path.");
|
||||
if (DrainIntervalSeconds <= 0)
|
||||
|
||||
@@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
|
||||
/// <summary>
|
||||
/// Registers shared runtime services. Currently binds <see cref="IAlarmHistorianSink"/>
|
||||
/// to <see cref="NullAlarmHistorianSink"/> as the default; production deployments
|
||||
/// override this with <c>SqliteStoreAndForwardSink</c> wrapping <c>WonderwareHistorianClient</c>.
|
||||
/// override this with <c>SqliteStoreAndForwardSink</c> wrapping the HistorianGateway alarm writer.
|
||||
/// Call this BEFORE <c>AddAkka</c>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register with.</param>
|
||||
@@ -63,14 +63,14 @@ public static class ServiceCollectionExtensions
|
||||
/// <c>Enabled=true</c>, registers a <see cref="SqliteStoreAndForwardSink"/> (draining via the
|
||||
/// <paramref name="writerFactory"/>-supplied writer) as the <see cref="IAlarmHistorianSink"/>,
|
||||
/// overriding the <see cref="NullAlarmHistorianSink"/> default. Otherwise a no-op (Null stays).
|
||||
/// The writer is injected so the durable downstream (Wonderware named-pipe client) can be supplied
|
||||
/// by the Host, which is the only project that references it.
|
||||
/// The writer is injected so the durable downstream (the HistorianGateway alarm writer) can be
|
||||
/// supplied by the Host, which is the only project that references it.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register with.</param>
|
||||
/// <param name="configuration">The configuration carrying the <c>AlarmHistorian</c> section.</param>
|
||||
/// <param name="writerFactory">
|
||||
/// Factory the Host supplies to build the concrete <see cref="IAlarmHistorianWriter"/>
|
||||
/// (the Wonderware named-pipe client) from the bound options + the resolving provider.
|
||||
/// (the HistorianGateway alarm writer) from the bound options + the resolving provider.
|
||||
/// </param>
|
||||
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
||||
public static IServiceCollection AddAlarmHistorian(
|
||||
|
||||
-266
@@ -1,266 +0,0 @@
|
||||
// xUnit1051: MessagePackSerializer.Serialize/Deserialize have optional CancellationToken
|
||||
// overloads; these are synchronous parity tests — suppressing the false-positive advisory.
|
||||
#pragma warning disable xUnit1051
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-parity tests for the client-side IPC contracts (Contracts.cs + Framing.cs).
|
||||
/// These tests pin the MessagePack byte representation of each DTO using known inputs
|
||||
/// and assert byte-equality against expected values. Because the sidecar (.NET 4.8)
|
||||
/// carries a byte-identical mirror of these DTOs, a silent <c>[Key]</c> index drift or
|
||||
/// field-type change in either copy would cause a mismatch here and be caught at build
|
||||
/// time — without needing to reference the net48 sidecar assembly from a net10 test
|
||||
/// project (which the TFM mismatch prevents). (Finding 009.)
|
||||
/// </summary>
|
||||
public sealed class ContractsWireParityTests
|
||||
{
|
||||
// ---- HistorianSampleDto ----
|
||||
// Fields at Key(0)=ValueBytes(null), Key(1)=Quality(0), Key(2)=TimestampUtcTicks(0)
|
||||
// MessagePack fixarray(3) + nil + fixint(0) + fixint(0) = 93 c0 00 00
|
||||
|
||||
/// <summary>Verifies that HistorianSampleDto serialized bytes are stable.</summary>
|
||||
[Fact]
|
||||
public void HistorianSampleDto_SerializedBytes_AreStable()
|
||||
{
|
||||
var dto = new HistorianSampleDto { ValueBytes = null, Quality = 0, TimestampUtcTicks = 0 };
|
||||
var bytes = MessagePackSerializer.Serialize(dto);
|
||||
|
||||
// fixarray(3) = 0x93, nil = 0xC0, fixint(0) = 0x00, fixint(0) = 0x00
|
||||
bytes.ShouldBe(new byte[] { 0x93, 0xC0, 0x00, 0x00 });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HistorianSampleDto with value round-trips correctly.</summary>
|
||||
[Fact]
|
||||
public void HistorianSampleDto_WithValue_RoundTrips()
|
||||
{
|
||||
var original = new HistorianSampleDto
|
||||
{
|
||||
ValueBytes = MessagePackSerializer.Serialize<object>(42.5),
|
||||
Quality = 192,
|
||||
TimestampUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks,
|
||||
};
|
||||
var bytes = MessagePackSerializer.Serialize(original);
|
||||
var roundTripped = MessagePackSerializer.Deserialize<HistorianSampleDto>(bytes);
|
||||
|
||||
roundTripped.Quality.ShouldBe((byte)192);
|
||||
roundTripped.TimestampUtcTicks.ShouldBe(original.TimestampUtcTicks);
|
||||
roundTripped.ValueBytes.ShouldBe(original.ValueBytes);
|
||||
}
|
||||
|
||||
// ---- HistorianAggregateSampleDto ----
|
||||
// Key(0)=Value(null), Key(1)=TimestampUtcTicks(0)
|
||||
// fixarray(2) + nil + fixint(0) = 92 c0 00
|
||||
|
||||
/// <summary>Verifies that HistorianAggregateSampleDto serialized bytes are stable.</summary>
|
||||
[Fact]
|
||||
public void HistorianAggregateSampleDto_SerializedBytes_AreStable()
|
||||
{
|
||||
var dto = new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = 0 };
|
||||
var bytes = MessagePackSerializer.Serialize(dto);
|
||||
|
||||
// fixarray(2) = 0x92, nil = 0xC0, fixint(0) = 0x00
|
||||
bytes.ShouldBe(new byte[] { 0x92, 0xC0, 0x00 });
|
||||
}
|
||||
|
||||
// ---- ReadRawRequest ----
|
||||
// 5 fields at Key(0..4). TagName="", StartUtcTicks=0, EndUtcTicks=0, MaxValues=0, CorrelationId=""
|
||||
// fixarray(5) + fixstr(0)="" + fixint(0) + fixint(0) + fixint(0) + fixstr(0)=""
|
||||
|
||||
/// <summary>Verifies that an empty ReadRawRequest serializes as a fixed array of 5 elements.</summary>
|
||||
[Fact]
|
||||
public void ReadRawRequest_EmptyInstance_SerializesAsFixArray5()
|
||||
{
|
||||
var req = new ReadRawRequest();
|
||||
var bytes = MessagePackSerializer.Serialize(req);
|
||||
|
||||
// Should start with fixarray(5) = 0x95
|
||||
bytes[0].ShouldBe((byte)0x95);
|
||||
// Round-trip verification
|
||||
var rt = MessagePackSerializer.Deserialize<ReadRawRequest>(bytes);
|
||||
rt.TagName.ShouldBe(string.Empty);
|
||||
rt.MaxValues.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadRawRequest with values round-trips correctly.</summary>
|
||||
[Fact]
|
||||
public void ReadRawRequest_WithValues_RoundTrips()
|
||||
{
|
||||
var original = new ReadRawRequest
|
||||
{
|
||||
TagName = "Tank.Level",
|
||||
StartUtcTicks = 100L,
|
||||
EndUtcTicks = 200L,
|
||||
MaxValues = 500,
|
||||
CorrelationId = "abc",
|
||||
};
|
||||
var bytes = MessagePackSerializer.Serialize(original);
|
||||
var rt = MessagePackSerializer.Deserialize<ReadRawRequest>(bytes);
|
||||
|
||||
rt.TagName.ShouldBe("Tank.Level");
|
||||
rt.StartUtcTicks.ShouldBe(100L);
|
||||
rt.EndUtcTicks.ShouldBe(200L);
|
||||
rt.MaxValues.ShouldBe(500);
|
||||
rt.CorrelationId.ShouldBe("abc");
|
||||
}
|
||||
|
||||
// ---- ReadRawReply ----
|
||||
|
||||
/// <summary>Verifies that ReadRawReply round-trips correctly.</summary>
|
||||
[Fact]
|
||||
public void ReadRawReply_RoundTrips()
|
||||
{
|
||||
var original = new ReadRawReply
|
||||
{
|
||||
CorrelationId = "x",
|
||||
Success = true,
|
||||
Error = null,
|
||||
Samples = [new HistorianSampleDto { Quality = 192, TimestampUtcTicks = 99L }],
|
||||
};
|
||||
var bytes = MessagePackSerializer.Serialize(original);
|
||||
var rt = MessagePackSerializer.Deserialize<ReadRawReply>(bytes);
|
||||
|
||||
rt.CorrelationId.ShouldBe("x");
|
||||
rt.Success.ShouldBeTrue();
|
||||
rt.Error.ShouldBeNull();
|
||||
rt.Samples.Length.ShouldBe(1);
|
||||
rt.Samples[0].Quality.ShouldBe((byte)192);
|
||||
rt.Samples[0].TimestampUtcTicks.ShouldBe(99L);
|
||||
}
|
||||
|
||||
// ---- ReadAtTimeRequest / ReadAtTimeReply ----
|
||||
|
||||
/// <summary>Verifies that ReadAtTimeRequest round-trips correctly.</summary>
|
||||
[Fact]
|
||||
public void ReadAtTimeRequest_RoundTrips()
|
||||
{
|
||||
var ticks = new long[] { 100L, 200L, 300L };
|
||||
var original = new ReadAtTimeRequest { TagName = "T", TimestampsUtcTicks = ticks, CorrelationId = "c" };
|
||||
var bytes = MessagePackSerializer.Serialize(original);
|
||||
var rt = MessagePackSerializer.Deserialize<ReadAtTimeRequest>(bytes);
|
||||
|
||||
rt.TagName.ShouldBe("T");
|
||||
rt.TimestampsUtcTicks.ShouldBe(ticks);
|
||||
rt.CorrelationId.ShouldBe("c");
|
||||
}
|
||||
|
||||
// ---- WriteAlarmEventsRequest / WriteAlarmEventsReply ----
|
||||
|
||||
/// <summary>Verifies that WriteAlarmEventsRequest round-trips correctly.</summary>
|
||||
[Fact]
|
||||
public void WriteAlarmEventsRequest_RoundTrips()
|
||||
{
|
||||
var original = new WriteAlarmEventsRequest
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new AlarmHistorianEventDto
|
||||
{
|
||||
EventId = "ev1",
|
||||
SourceName = "Tank/HiHi",
|
||||
ConditionId = "HiHi",
|
||||
AlarmType = "LimitAlarm:Activated",
|
||||
Message = "msg",
|
||||
Severity = 700,
|
||||
EventTimeUtcTicks = 999L,
|
||||
AckComment = null,
|
||||
},
|
||||
],
|
||||
CorrelationId = "r",
|
||||
};
|
||||
var bytes = MessagePackSerializer.Serialize(original);
|
||||
var rt = MessagePackSerializer.Deserialize<WriteAlarmEventsRequest>(bytes);
|
||||
|
||||
rt.CorrelationId.ShouldBe("r");
|
||||
rt.Events.Length.ShouldBe(1);
|
||||
rt.Events[0].EventId.ShouldBe("ev1");
|
||||
rt.Events[0].SourceName.ShouldBe("Tank/HiHi");
|
||||
rt.Events[0].Severity.ShouldBe((ushort)700);
|
||||
rt.Events[0].EventTimeUtcTicks.ShouldBe(999L);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAlarmEventsReply round-trips correctly (legacy PerEventOk path).</summary>
|
||||
[Fact]
|
||||
public void WriteAlarmEventsReply_RoundTrips()
|
||||
{
|
||||
var original = new WriteAlarmEventsReply
|
||||
{
|
||||
CorrelationId = "r",
|
||||
Success = true,
|
||||
Error = null,
|
||||
PerEventOk = [true, false, true],
|
||||
};
|
||||
var bytes = MessagePackSerializer.Serialize(original);
|
||||
var rt = MessagePackSerializer.Deserialize<WriteAlarmEventsReply>(bytes);
|
||||
|
||||
rt.CorrelationId.ShouldBe("r");
|
||||
rt.Success.ShouldBeTrue();
|
||||
rt.PerEventOk.ShouldBe(new[] { true, false, true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the <c>[Key(4)]</c> index for <see cref="WriteAlarmEventsReply.PerEventStatus"/>,
|
||||
/// the additive granular status field added in the <c>feddc2b8</c> commit. A silent
|
||||
/// Key-index drift in either the client or the sidecar mirror copy would swap the legacy
|
||||
/// <c>PerEventOk</c> bool array and the new status byte array, misclassifying outcomes
|
||||
/// at runtime. (Finding 013.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WriteAlarmEventsReply_PerEventStatus_IsAtKey4_AndRoundTrips()
|
||||
{
|
||||
var original = new WriteAlarmEventsReply
|
||||
{
|
||||
CorrelationId = "s",
|
||||
Success = true,
|
||||
PerEventOk = [true],
|
||||
PerEventStatus = [0, 1, 2], // Ack, Retry, Permanent
|
||||
};
|
||||
var bytes = MessagePackSerializer.Serialize(original);
|
||||
|
||||
// The array must start with fixarray(5) — five keys at indices 0-4.
|
||||
bytes[0].ShouldBe((byte)0x95, "WriteAlarmEventsReply must be a 5-field MessagePack array");
|
||||
|
||||
var rt = MessagePackSerializer.Deserialize<WriteAlarmEventsReply>(bytes);
|
||||
rt.CorrelationId.ShouldBe("s");
|
||||
rt.Success.ShouldBeTrue();
|
||||
rt.PerEventOk.ShouldBe(new[] { true });
|
||||
// Key(4): PerEventStatus must round-trip independently of Key(3): PerEventOk.
|
||||
rt.PerEventStatus.ShouldBe(new byte[] { 0, 1, 2 });
|
||||
}
|
||||
|
||||
// ---- MessageKind enum values are pinned ----
|
||||
// Changing a MessageKind value is a wire break; pin them explicitly.
|
||||
|
||||
/// <summary>Verifies that MessageKind enum values are stable.</summary>
|
||||
[Fact]
|
||||
public void MessageKind_Values_AreStable()
|
||||
{
|
||||
((byte)MessageKind.Hello).ShouldBe((byte)0x01);
|
||||
((byte)MessageKind.HelloAck).ShouldBe((byte)0x02);
|
||||
((byte)MessageKind.ReadRawRequest).ShouldBe((byte)0x10);
|
||||
((byte)MessageKind.ReadRawReply).ShouldBe((byte)0x11);
|
||||
((byte)MessageKind.ReadProcessedRequest).ShouldBe((byte)0x12);
|
||||
((byte)MessageKind.ReadProcessedReply).ShouldBe((byte)0x13);
|
||||
((byte)MessageKind.ReadAtTimeRequest).ShouldBe((byte)0x14);
|
||||
((byte)MessageKind.ReadAtTimeReply).ShouldBe((byte)0x15);
|
||||
((byte)MessageKind.ReadEventsRequest).ShouldBe((byte)0x16);
|
||||
((byte)MessageKind.ReadEventsReply).ShouldBe((byte)0x17);
|
||||
((byte)MessageKind.WriteAlarmEventsRequest).ShouldBe((byte)0x20);
|
||||
((byte)MessageKind.WriteAlarmEventsReply).ShouldBe((byte)0x21);
|
||||
}
|
||||
|
||||
// ---- Framing constants are pinned ----
|
||||
|
||||
/// <summary>Verifies that framing constants are stable.</summary>
|
||||
[Fact]
|
||||
public void Framing_Constants_AreStable()
|
||||
{
|
||||
Framing.LengthPrefixSize.ShouldBe(4);
|
||||
Framing.KindByteSize.ShouldBe(1);
|
||||
Framing.MaxFrameBodyBytes.ShouldBe(16 * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-process fake of the Wonderware historian sidecar. Reuses the client-side framing
|
||||
/// code (which is byte-identical to the real sidecar) so the wire bytes round-trip
|
||||
/// correctly without requiring the .NET 4.8 sidecar binary at test time. Listens on a
|
||||
/// loopback <see cref="TcpListener"/> and serves one connection at a time, mirroring the
|
||||
/// real sidecar's <c>TcpFrameServer</c> single-active-connection model.
|
||||
/// </summary>
|
||||
internal sealed class FakeSidecarServer : IAsyncDisposable
|
||||
{
|
||||
private readonly string _expectedSecret;
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
/// <summary>Gets or sets the handler for ReadRaw requests.</summary>
|
||||
public Func<ReadRawRequest, ReadRawReply> OnReadRaw { get; set; } = _ => new ReadRawReply { Success = true };
|
||||
|
||||
/// <summary>Gets or sets the handler for ReadProcessed requests.</summary>
|
||||
public Func<ReadProcessedRequest, ReadProcessedReply> OnReadProcessed { get; set; } = _ => new ReadProcessedReply { Success = true };
|
||||
|
||||
/// <summary>Gets or sets the handler for ReadAtTime requests.</summary>
|
||||
public Func<ReadAtTimeRequest, ReadAtTimeReply> OnReadAtTime { get; set; } = _ => new ReadAtTimeReply { Success = true };
|
||||
|
||||
/// <summary>Gets or sets the handler for ReadEvents requests.</summary>
|
||||
public Func<ReadEventsRequest, ReadEventsReply> OnReadEvents { get; set; } = _ => new ReadEventsReply { Success = true };
|
||||
|
||||
/// <summary>Gets or sets the handler for WriteAlarmEvents requests.</summary>
|
||||
public Func<WriteAlarmEventsRequest, WriteAlarmEventsReply> OnWriteAlarmEvents { get; set; } = req
|
||||
=> new WriteAlarmEventsReply { Success = true, PerEventOk = Enumerable.Repeat(true, req.Events.Length).ToArray() };
|
||||
|
||||
/// <summary>Force-disconnect the next accepted client mid-call to exercise reconnect.</summary>
|
||||
public bool DisconnectAfterHandshake { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Drop the connection after the handshake but before replying to any non-Hello request.
|
||||
/// Armed for every connection until reset. Used to exercise the WriteBatchAsync catch
|
||||
/// path and the second-attempt-also-fails propagation path.
|
||||
/// </summary>
|
||||
public bool DisconnectBeforeReply { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reply to the first non-Hello request with this kind instead of the expected kind,
|
||||
/// to exercise <see cref="System.IO.InvalidDataException"/> detection in ExchangeAsync.
|
||||
/// Reset to null after the first mis-routed reply.
|
||||
/// </summary>
|
||||
public MessageKind? ReplyWithWrongKind { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stall indefinitely after receiving a request before sending any reply, so the client's
|
||||
/// call-timeout token fires. Used to test the CallTimeout path.
|
||||
/// </summary>
|
||||
public bool StallAfterRequest { get; set; }
|
||||
|
||||
/// <summary>Initializes a new instance of FakeSidecarServer with the specified expected secret.</summary>
|
||||
/// <param name="expectedSecret">The expected shared secret for handshake validation.</param>
|
||||
public FakeSidecarServer(string expectedSecret)
|
||||
{
|
||||
_expectedSecret = expectedSecret;
|
||||
// Bind synchronously in the ctor so BoundPort is readable before StartAsync returns.
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
/// <summary>Gets the loopback host the listener is bound to.</summary>
|
||||
public string Host => "127.0.0.1";
|
||||
|
||||
/// <summary>Gets the TCP port the listener actually bound (OS-assigned).</summary>
|
||||
public int BoundPort => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
|
||||
/// <summary>Starts the fake sidecar server asynchronously. The listener is already bound (ctor).</summary>
|
||||
public Task StartAsync()
|
||||
{
|
||||
_loop = Task.Run(() => RunAsync(_cts.Token));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
TcpClient tcpClient;
|
||||
try { tcpClient = await _listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
|
||||
using (tcpClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
tcpClient.NoDelay = true;
|
||||
var stream = tcpClient.GetStream();
|
||||
using var reader = new FrameReader(stream, leaveOpen: true);
|
||||
using var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
|
||||
// Hello handshake.
|
||||
var first = await reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
if (first is null || first.Value.Kind != MessageKind.Hello) continue;
|
||||
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||
|
||||
if (!string.Equals(hello.SharedSecret, _expectedSecret, StringComparison.Ordinal))
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = true, HostName = "fake-sidecar" }, ct);
|
||||
|
||||
if (DisconnectAfterHandshake)
|
||||
{
|
||||
DisconnectAfterHandshake = false; // arm once
|
||||
tcpClient.Close();
|
||||
continue;
|
||||
}
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
if (frame is null) break;
|
||||
|
||||
// Drop before sending any reply — lets the client fall into its catch /
|
||||
// retry path or propagate on second failure.
|
||||
if (DisconnectBeforeReply)
|
||||
{
|
||||
tcpClient.Close();
|
||||
break;
|
||||
}
|
||||
|
||||
// Stall indefinitely to let the client's call-timeout token fire.
|
||||
if (StallAfterRequest)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
// Optionally send a deliberately wrong kind back to exercise
|
||||
// InvalidDataException detection in the client's ExchangeAsync.
|
||||
if (ReplyWithWrongKind.HasValue)
|
||||
{
|
||||
var wrongKind = ReplyWithWrongKind.Value;
|
||||
ReplyWithWrongKind = null; // arm once
|
||||
// Send an empty body with the wrong kind so the client can parse it.
|
||||
await writer.WriteAsync(wrongKind, new ReadRawReply { Success = false }, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (frame.Value.Kind)
|
||||
{
|
||||
case MessageKind.ReadRawRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadRawRequest>(frame.Value.Body);
|
||||
var reply = OnReadRaw(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadRawReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
case MessageKind.ReadProcessedRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadProcessedRequest>(frame.Value.Body);
|
||||
var reply = OnReadProcessed(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadProcessedReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
case MessageKind.ReadAtTimeRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadAtTimeRequest>(frame.Value.Body);
|
||||
var reply = OnReadAtTime(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadAtTimeReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
case MessageKind.ReadEventsRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadEventsRequest>(frame.Value.Body);
|
||||
var reply = OnReadEvents(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadEventsReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
case MessageKind.WriteAlarmEventsRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<WriteAlarmEventsRequest>(frame.Value.Body);
|
||||
var reply = OnWriteAlarmEvents(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (IOException) { /* peer dropped — accept next */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases all resources used by the fake sidecar server.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try { _listener.Stop(); } catch { /* ignore */ }
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop.ConfigureAwait(false); } catch { /* ignore shutdown errors */ }
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="FrameChannel.DefaultTcpConnectFactory"/>. Each scenario binds a
|
||||
/// loopback <see cref="TcpListener"/> on <c>127.0.0.1:0</c>, accepts on a background task,
|
||||
/// and drives the client factory against it — proving a plaintext stream round-trips a byte,
|
||||
/// a TLS connection succeeds when the pinned thumbprint matches, and fails when it does not.
|
||||
/// </summary>
|
||||
public sealed class TcpConnectFactoryTests
|
||||
{
|
||||
// Generous timeout so the deterministic tests never hang CI if a side stalls.
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Generates an in-memory self-signed RSA cert with a serverAuth EKU and a private key.</summary>
|
||||
private static X509Certificate2 MakeSelfSignedCert()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=otopcua-historian-sidecar-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
req.CertificateExtensions.Add(
|
||||
new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") /* serverAuth */ }, critical: false));
|
||||
using var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
|
||||
// Round-trip through a PFX so the returned cert carries an exportable private key.
|
||||
var pfx = ephemeral.Export(X509ContentType.Pfx, "pw");
|
||||
return X509CertificateLoader.LoadPkcs12(pfx, "pw", X509KeyStorageFlags.Exportable);
|
||||
}
|
||||
|
||||
/// <summary>Plaintext: the factory returns a connected stream; a byte written server-side reads back client-side.</summary>
|
||||
[Fact]
|
||||
public async Task Plaintext_ReturnsConnectedStream_ByteRoundTrips()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
// Accept one client and push a single byte from the server side.
|
||||
var serverTask = Task.Run(async () =>
|
||||
{
|
||||
using var server = await listener.AcceptTcpClientAsync(cts.Token);
|
||||
var serverStream = server.GetStream();
|
||||
await serverStream.WriteAsync(new byte[] { 0x7A }, cts.Token);
|
||||
await serverStream.FlushAsync(cts.Token);
|
||||
// Hold the connection open until the client has read.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token);
|
||||
}, cts.Token);
|
||||
|
||||
var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret")
|
||||
{
|
||||
UseTls = false,
|
||||
};
|
||||
|
||||
await using var clientStream = await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token);
|
||||
var buffer = new byte[1];
|
||||
var read = await clientStream.ReadAsync(buffer, cts.Token);
|
||||
|
||||
read.ShouldBe(1);
|
||||
buffer[0].ShouldBe((byte)0x7A);
|
||||
|
||||
await serverTask;
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
/// <summary>TLS pin match: a self-signed cert pinned by thumbprint authenticates successfully.</summary>
|
||||
[Fact]
|
||||
public async Task Tls_PinnedThumbprintMatches_ConnectsSuccessfully()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
using var cert = MakeSelfSignedCert();
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
var serverTask = Task.Run(async () =>
|
||||
{
|
||||
using var server = await listener.AcceptTcpClientAsync(cts.Token);
|
||||
var ssl = new SslStream(server.GetStream(), leaveInnerStreamOpen: false);
|
||||
await ssl.AuthenticateAsServerAsync(cert, clientCertificateRequired: false,
|
||||
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false);
|
||||
// Hold open until the client finished its handshake.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token);
|
||||
ssl.Dispose();
|
||||
}, cts.Token);
|
||||
|
||||
var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret")
|
||||
{
|
||||
UseTls = true,
|
||||
ServerCertThumbprint = cert.GetCertHashString(),
|
||||
};
|
||||
|
||||
await using var stream = await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token);
|
||||
stream.ShouldBeOfType<SslStream>();
|
||||
((SslStream)stream).IsAuthenticated.ShouldBeTrue();
|
||||
|
||||
await serverTask;
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
/// <summary>TLS wrong thumbprint: the pin check fails the validation callback → AuthenticationException.</summary>
|
||||
[Fact]
|
||||
public async Task Tls_WrongThumbprint_ThrowsAuthenticationException()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
using var cert = MakeSelfSignedCert();
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
// The server still attempts its handshake; it will fault when the client aborts. Swallow.
|
||||
var serverTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var server = await listener.AcceptTcpClientAsync(cts.Token);
|
||||
var ssl = new SslStream(server.GetStream(), leaveInnerStreamOpen: false);
|
||||
await ssl.AuthenticateAsServerAsync(cert, clientCertificateRequired: false,
|
||||
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false);
|
||||
ssl.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Expected — the client rejects the cert and tears the connection down.
|
||||
}
|
||||
}, cts.Token);
|
||||
|
||||
var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret")
|
||||
{
|
||||
UseTls = true,
|
||||
ServerCertThumbprint = "00112233445566778899AABBCCDDEEFF00112233", // bogus
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<AuthenticationException>(
|
||||
async () => await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token));
|
||||
|
||||
try { await serverTask; } catch { /* ignore */ }
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="WonderwareHistorianClientOptions"/> TCP/TLS fields.
|
||||
/// </summary>
|
||||
public sealed class WonderwareHistorianClientOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void TcpTlsFields_AreStoredCorrectly_WhenExplicitlySet()
|
||||
{
|
||||
var opts = new WonderwareHistorianClientOptions("h", 32569, "secret")
|
||||
{
|
||||
UseTls = true,
|
||||
ServerCertThumbprint = "AB"
|
||||
};
|
||||
|
||||
opts.Host.ShouldBe("h");
|
||||
opts.Port.ShouldBe(32569);
|
||||
opts.UseTls.ShouldBeTrue();
|
||||
opts.ServerCertThumbprint.ShouldBe("AB");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TcpTlsFields_HaveCorrectDefaults_WhenNotSet()
|
||||
{
|
||||
var opts = new WonderwareHistorianClientOptions("host", 32569, "secret");
|
||||
|
||||
opts.Host.ShouldBe("host");
|
||||
opts.Port.ShouldBe(32569);
|
||||
opts.UseTls.ShouldBeFalse();
|
||||
opts.ServerCertThumbprint.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
-890
@@ -1,890 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests for <see cref="WonderwareHistorianClient"/>: every interface method
|
||||
/// round-trips over a real loopback TCP connection against the in-process
|
||||
/// <see cref="FakeSidecarServer"/>, which reuses the client's own byte-identical framing
|
||||
/// code. Covers byte→uint quality mapping, BadNoData propagation for null aggregate
|
||||
/// buckets, alarm-write per-event status flow, Hello handshake rejection on bad secret,
|
||||
/// and reconnect after a transport drop.
|
||||
/// </summary>
|
||||
public sealed class WonderwareHistorianClientTests
|
||||
{
|
||||
private const string Secret = "test-secret-123";
|
||||
|
||||
private static WonderwareHistorianClientOptions OptsFor(FakeSidecarServer server) => new(
|
||||
Host: "127.0.0.1",
|
||||
Port: server.BoundPort,
|
||||
SharedSecret: Secret,
|
||||
PeerName: "test",
|
||||
ConnectTimeout: TimeSpan.FromSeconds(2),
|
||||
CallTimeout: TimeSpan.FromSeconds(2))
|
||||
{
|
||||
UseTls = false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a client over loopback TCP against the fake's bound port using the public ctor
|
||||
/// (which dials TCP).
|
||||
/// </summary>
|
||||
private static WonderwareHistorianClient TcpClientFor(FakeSidecarServer server)
|
||||
=> new(OptsFor(server));
|
||||
|
||||
/// <summary>Verifies that ReadRawAsync round-trips samples and maps quality bytes to OPC UA status codes.</summary>
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_RoundTripsSamples_AndMapsQualityByteToOpcUaStatusCode()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnReadRaw = req => new ReadRawReply
|
||||
{
|
||||
Success = true,
|
||||
Samples =
|
||||
[
|
||||
new HistorianSampleDto
|
||||
{
|
||||
ValueBytes = MessagePackSerializer.Serialize<object>(42.0),
|
||||
Quality = 192, // Good
|
||||
TimestampUtcTicks = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc).Ticks,
|
||||
},
|
||||
new HistorianSampleDto
|
||||
{
|
||||
ValueBytes = MessagePackSerializer.Serialize<object>(43.5),
|
||||
Quality = 8, // Bad_NotConnected
|
||||
TimestampUtcTicks = new DateTime(2026, 4, 29, 12, 0, 1, DateTimeKind.Utc).Ticks,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var result = await client.ReadRawAsync("Tank.Level",
|
||||
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc),
|
||||
100, CancellationToken.None);
|
||||
|
||||
result.ContinuationPoint.ShouldBeNull();
|
||||
result.Samples.Count.ShouldBe(2);
|
||||
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
|
||||
result.Samples[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc));
|
||||
result.Samples[1].StatusCode.ShouldBe(0x808A0000u); // Bad_NotConnected
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadProcessedAsync maps null buckets to BadNoData status.</summary>
|
||||
[Fact]
|
||||
public async Task ReadProcessedAsync_NullBuckets_MapToBadNoData()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnReadProcessed = _ => new ReadProcessedReply
|
||||
{
|
||||
Success = true,
|
||||
Buckets =
|
||||
[
|
||||
new HistorianAggregateSampleDto { Value = 50.0, TimestampUtcTicks = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc).Ticks },
|
||||
new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = new DateTime(2026, 4, 29, 0, 1, 0, DateTimeKind.Utc).Ticks },
|
||||
],
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var result = await client.ReadProcessedAsync("Tank.Level",
|
||||
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 4, 29, 0, 2, 0, DateTimeKind.Utc),
|
||||
TimeSpan.FromMinutes(1), HistoryAggregateType.Average, CancellationToken.None);
|
||||
|
||||
result.Samples.Count.ShouldBe(2);
|
||||
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
|
||||
result.Samples[0].Value.ShouldBe(50.0);
|
||||
result.Samples[1].StatusCode.ShouldBe(0x800E0000u); // BadNoData
|
||||
result.Samples[1].Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadAtTimeAsync preserves timestamp order.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAtTimeAsync_PreservesTimestampOrder()
|
||||
{
|
||||
var t1 = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2026, 4, 29, 2, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnReadAtTime = req => new ReadAtTimeReply
|
||||
{
|
||||
Success = true,
|
||||
Samples = req.TimestampsUtcTicks
|
||||
.Select(ticks => new HistorianSampleDto { Quality = 192, TimestampUtcTicks = ticks })
|
||||
.ToArray(),
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var result = await client.ReadAtTimeAsync("Tank.Level", new[] { t1, t2 }, CancellationToken.None);
|
||||
|
||||
result.Samples.Count.ShouldBe(2);
|
||||
result.Samples[0].SourceTimestampUtc.ShouldBe(t1);
|
||||
result.Samples[1].SourceTimestampUtc.ShouldBe(t2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadAtTimeAsync aligns by timestamp and fills gaps with bad status.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAtTimeAsync_PartialAndReorderedReply_AlignsByTimestamp_AndFillsGapsAsBad()
|
||||
{
|
||||
var t1 = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2026, 4, 29, 2, 0, 0, DateTimeKind.Utc);
|
||||
var t3 = new DateTime(2026, 4, 29, 3, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
// Sidecar returns only t3 and t1 (out of order), drops t2 entirely. A
|
||||
// contract-compliant client must realign by timestamp and synthesize a
|
||||
// Bad-quality snapshot for the missing t2.
|
||||
OnReadAtTime = _ => new ReadAtTimeReply
|
||||
{
|
||||
Success = true,
|
||||
Samples =
|
||||
[
|
||||
new HistorianSampleDto
|
||||
{
|
||||
ValueBytes = MessagePackSerializer.Serialize<object>(3.0),
|
||||
Quality = 192, TimestampUtcTicks = t3.Ticks,
|
||||
},
|
||||
new HistorianSampleDto
|
||||
{
|
||||
ValueBytes = MessagePackSerializer.Serialize<object>(1.0),
|
||||
Quality = 192, TimestampUtcTicks = t1.Ticks,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var result = await client.ReadAtTimeAsync("Tank.Level", new[] { t1, t2, t3 }, CancellationToken.None);
|
||||
|
||||
// Result MUST be the same length and order as the request.
|
||||
result.Samples.Count.ShouldBe(3);
|
||||
|
||||
result.Samples[0].SourceTimestampUtc.ShouldBe(t1);
|
||||
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
|
||||
result.Samples[0].Value.ShouldBe(1.0);
|
||||
|
||||
// t2 was not returned by the sidecar → Bad-quality gap snapshot at the requested time.
|
||||
result.Samples[1].SourceTimestampUtc.ShouldBe(t2);
|
||||
result.Samples[1].StatusCode.ShouldBe(0x80000000u); // Bad
|
||||
result.Samples[1].Value.ShouldBeNull();
|
||||
|
||||
result.Samples[2].SourceTimestampUtc.ShouldBe(t3);
|
||||
result.Samples[2].StatusCode.ShouldBe(0x00000000u); // Good
|
||||
result.Samples[2].Value.ShouldBe(3.0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadEventsAsync preserves event field values.</summary>
|
||||
[Fact]
|
||||
public async Task ReadEventsAsync_PreservesEventFields()
|
||||
{
|
||||
var eid = Guid.NewGuid().ToString("N");
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnReadEvents = _ => new ReadEventsReply
|
||||
{
|
||||
Success = true,
|
||||
Events =
|
||||
[
|
||||
new HistorianEventDto
|
||||
{
|
||||
EventId = eid, Source = "Tank.HiHi",
|
||||
EventTimeUtcTicks = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc).Ticks,
|
||||
ReceivedTimeUtcTicks = new DateTime(2026, 4, 29, 1, 0, 1, DateTimeKind.Utc).Ticks,
|
||||
DisplayText = "Level high-high", Severity = 800,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var result = await client.ReadEventsAsync("Tank.HiHi",
|
||||
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc),
|
||||
100, CancellationToken.None);
|
||||
|
||||
result.Events.Count.ShouldBe(1);
|
||||
result.Events[0].EventId.ShouldBe(eid);
|
||||
result.Events[0].SourceName.ShouldBe("Tank.HiHi");
|
||||
result.Events[0].Message.ShouldBe("Level high-high");
|
||||
result.Events[0].Severity.ShouldBe((ushort)800);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadRawAsync throws InvalidOperationException on server errors.</summary>
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_ServerError_ThrowsInvalidOperation()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnReadRaw = _ => new ReadRawReply { Success = false, Error = "historian unreachable" },
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||
ex.Message.ShouldContain("historian unreachable");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteBatchAsync maps per-event results to acknowledge or retry outcomes.</summary>
|
||||
[Fact]
|
||||
public async Task WriteBatchAsync_PerEventOk_MapsToAckOrRetryPlease()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnWriteAlarmEvents = req => new WriteAlarmEventsReply
|
||||
{
|
||||
Success = true,
|
||||
PerEventOk = req.Events.Select(e => e.EventId != "ev-fail").ToArray(),
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "operator", null, DateTime.UtcNow),
|
||||
new AlarmHistorianEvent("ev-fail", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Acknowledged", "msg", "operator", null, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
|
||||
|
||||
outcomes.Count.ShouldBe(2);
|
||||
outcomes[0].ShouldBe(HistorianWriteOutcome.Ack);
|
||||
outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteBatchAsync returns retry outcomes for whole call failures.</summary>
|
||||
[Fact]
|
||||
public async Task WriteBatchAsync_WholeCallFailure_ReturnsRetryPleaseForEveryEvent()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
|
||||
{
|
||||
Success = false,
|
||||
Error = "historian event-store down",
|
||||
PerEventOk = new bool[2],
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
|
||||
new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Cleared", "msg", "u", null, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
|
||||
|
||||
outcomes.Count.ShouldBe(2);
|
||||
outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The granular PerEventStatus wire field maps directly: 0→Ack, 1→Retry, 2→PermanentFail.
|
||||
/// A poison event the sidecar marks Permanent (status 2) must dead-letter via
|
||||
/// <see cref="HistorianWriteOutcome.PermanentFail"/> rather than retrying.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteBatchAsync_PerEventStatusPermanent_MapsToPermanentFail()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
|
||||
{
|
||||
Success = true,
|
||||
PerEventStatus = [2], // Permanent
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-poison", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
|
||||
|
||||
outcomes.Count.ShouldBe(1);
|
||||
outcomes[0].ShouldBe(HistorianWriteOutcome.PermanentFail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PerEventStatus = 0 maps to <see cref="HistorianWriteOutcome.Ack"/>; the granular path
|
||||
/// takes precedence over the legacy PerEventOk bool when both are present.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteBatchAsync_PerEventStatusAck_MapsToAck()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
|
||||
{
|
||||
Success = true,
|
||||
PerEventStatus = [0], // Ack
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-ok", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
|
||||
|
||||
outcomes.Count.ShouldBe(1);
|
||||
outcomes[0].ShouldBe(HistorianWriteOutcome.Ack);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When <c>PerEventStatus</c> is present but its length does not equal the batch size,
|
||||
/// the client must ignore it and fall back to the legacy <c>PerEventOk</c> path to
|
||||
/// avoid mis-indexing into the status array. Here a 2-event batch receives
|
||||
/// <c>PerEventStatus=[1]</c> (length 1) but <c>PerEventOk=[true, false]</c>; the
|
||||
/// outcomes must reflect the PerEventOk values ([Ack, RetryPlease]), not the status
|
||||
/// byte (which would have produced [RetryPlease] had it been used).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteBatchAsync_PerEventStatusLengthMismatch_FallsBackToPerEventOk()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
|
||||
{
|
||||
Success = true,
|
||||
PerEventStatus = [1], // length 1 ≠ batch count 2 → must be ignored
|
||||
PerEventOk = [true, false], // legacy fallback: true→Ack, false→RetryPlease
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
|
||||
new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Acknowledged", "msg", "u", null, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
|
||||
|
||||
outcomes.Count.ShouldBe(2);
|
||||
outcomes[0].ShouldBe(HistorianWriteOutcome.Ack); // PerEventOk[0] = true
|
||||
outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease); // PerEventOk[1] = false
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status byte 1 (the only value that is neither 0 nor 2) must map to
|
||||
/// <see cref="HistorianWriteOutcome.RetryPlease"/> via the default arm of the
|
||||
/// <c>PerEventStatus</c> switch. A single-event batch with <c>PerEventStatus=[1]</c>
|
||||
/// (length matches batch) must yield <c>[RetryPlease]</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteBatchAsync_PerEventStatusRetry_MapsToRetryPlease()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
|
||||
{
|
||||
Success = true,
|
||||
PerEventStatus = [1], // status 1 → RetryPlease
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-retry", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
|
||||
|
||||
outcomes.Count.ShouldBe(1);
|
||||
outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rolling-deploy back-compat: an older sidecar that sends an empty PerEventStatus but a
|
||||
/// populated PerEventOk must still classify via the legacy bool path (false→RetryPlease).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteBatchAsync_EmptyPerEventStatus_FallsBackToLegacyPerEventOk()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
|
||||
{
|
||||
Success = true,
|
||||
PerEventStatus = [], // older sidecar — no granular status
|
||||
PerEventOk = [false],
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-legacy", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
|
||||
|
||||
outcomes.Count.ShouldBe(1);
|
||||
outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Hello handshake throws UnauthorizedAccessException on secret mismatch.</summary>
|
||||
[Fact]
|
||||
public async Task Hello_BadSecret_ThrowsUnauthorizedAccess()
|
||||
{
|
||||
await using var server = new FakeSidecarServer("different-secret");
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
|
||||
var ex = await Should.ThrowAsync<UnauthorizedAccessException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||
ex.Message.ShouldContain("shared-secret-mismatch");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the client retries after a transport drop.</summary>
|
||||
[Fact]
|
||||
public async Task Reconnect_AfterTransportDrop_RetriesOnce()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
// First connection drops after handshake → client retries on next call.
|
||||
DisconnectAfterHandshake = true,
|
||||
OnReadRaw = req => new ReadRawReply
|
||||
{
|
||||
Success = true,
|
||||
Samples = [new HistorianSampleDto { Quality = 192, TimestampUtcTicks = req.StartUtcTicks }],
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
|
||||
// First call: handshake + dropped. Reconnect kicks in inside the channel; second
|
||||
// attempt within the same InvokeAsync succeeds. From the caller's perspective it's
|
||||
// one ReadRawAsync that returns a sample.
|
||||
var result = await client.ReadRawAsync("Tag",
|
||||
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc),
|
||||
100, CancellationToken.None);
|
||||
|
||||
result.Samples.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetHealthSnapshot tracks success and failure counts.</summary>
|
||||
[Fact]
|
||||
public async Task GetHealthSnapshot_TracksSuccessAndFailureCounts()
|
||||
{
|
||||
var failNext = false;
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnReadRaw = _ => failNext
|
||||
? new ReadRawReply { Success = false, Error = "boom" }
|
||||
: new ReadRawReply { Success = true },
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
|
||||
await client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None);
|
||||
|
||||
failNext = true;
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None));
|
||||
|
||||
var snap = client.GetHealthSnapshot();
|
||||
snap.TotalQueries.ShouldBe(2);
|
||||
snap.TotalSuccesses.ShouldBe(1);
|
||||
snap.TotalFailures.ShouldBe(1);
|
||||
snap.ConsecutiveFailures.ShouldBe(1);
|
||||
snap.LastError.ShouldNotBeNull();
|
||||
snap.ProcessConnectionOpen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ===== Finding-009: missing edge-case tests =====
|
||||
|
||||
/// <summary>
|
||||
/// (2) A transport drop during a write (the catch path in WriteBatchAsync) must return
|
||||
/// RetryPlease for every event in the batch — never throw, never PermanentFail.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteBatchAsync_TransportDropDuringWrite_ReturnsRetryPleaseForEveryEvent()
|
||||
{
|
||||
// Server disconnects before replying to the write request. The client's single retry
|
||||
// reconnects; on the second attempt the server is still armed to disconnect, so both
|
||||
// attempts fail and the catch block fires.
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
DisconnectBeforeReply = true,
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var batch = new[]
|
||||
{
|
||||
new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
|
||||
new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Cleared", "msg", "u", null, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
// WriteBatchAsync must not throw — it absorbs transport failures as RetryPlease.
|
||||
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
|
||||
|
||||
outcomes.Count.ShouldBe(2);
|
||||
outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (3) When both the first attempt and the single retry fail (the "second attempt also
|
||||
/// fails" path in InvokeAsync), the exception propagates to the caller.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_BothAttemptsFailTransport_PropagatesException()
|
||||
{
|
||||
// DisconnectBeforeReply stays true so both the first attempt and the single retry
|
||||
// inside InvokeAsync are dropped, causing the second ExchangeAsync to throw.
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
DisconnectBeforeReply = true,
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
|
||||
// ReadRawAsync uses Invoke, which propagates the exception when both attempts fail.
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (4) A stalled sidecar that never sends a reply must cause an
|
||||
/// <see cref="OperationCanceledException"/> within the configured CallTimeout.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_StalledSidecar_TimesOutWithOperationCanceledException()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
StallAfterRequest = true,
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
var opts = new WonderwareHistorianClientOptions(
|
||||
Host: "127.0.0.1",
|
||||
Port: server.BoundPort,
|
||||
SharedSecret: Secret,
|
||||
PeerName: "test",
|
||||
ConnectTimeout: TimeSpan.FromSeconds(2),
|
||||
CallTimeout: TimeSpan.FromMilliseconds(500)) // short timeout for test speed
|
||||
{
|
||||
UseTls = false,
|
||||
};
|
||||
|
||||
await using var client = new WonderwareHistorianClient(opts);
|
||||
|
||||
// The stall means neither the first nor the retry can complete, so the timeout
|
||||
// linked-token should cancel the operation.
|
||||
await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (5) <see cref="HistoryAggregateType.Total"/> is derived client-side as the
|
||||
/// time-weighted Average multiplied by the interval duration in seconds, because the
|
||||
/// Wonderware AnalogSummary query exposes no Total column. The client must issue the
|
||||
/// wire request with the Average column and scale every returned bucket value by
|
||||
/// <c>interval.TotalSeconds</c>, carrying the bucket's quality and timestamp through.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadProcessedAsync_TotalAggregate_ReturnsAverageTimesIntervalSeconds()
|
||||
{
|
||||
var bucketTs = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc);
|
||||
string? requestedColumn = null;
|
||||
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnReadProcessed = req =>
|
||||
{
|
||||
// Capture the column the client asked for: Total must be requested as Average.
|
||||
requestedColumn = req.AggregateColumn;
|
||||
return new ReadProcessedReply
|
||||
{
|
||||
Success = true,
|
||||
Buckets =
|
||||
[
|
||||
// One Good Average bucket of 2.0; with a 60s interval the derived
|
||||
// Total is 2.0 * 60 = 120.0.
|
||||
new HistorianAggregateSampleDto { Value = 2.0, TimestampUtcTicks = bucketTs.Ticks },
|
||||
// A null (unavailable) Average bucket must stay BadNoData / null.
|
||||
new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = bucketTs.AddMinutes(1).Ticks },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
var result = await client.ReadProcessedAsync("Tank.Level",
|
||||
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 4, 29, 0, 2, 0, DateTimeKind.Utc),
|
||||
TimeSpan.FromMinutes(1), HistoryAggregateType.Total, CancellationToken.None);
|
||||
|
||||
// The wire request asks for the Average column — Total has no AnalogSummary column.
|
||||
requestedColumn.ShouldBe("Average");
|
||||
|
||||
result.Samples.Count.ShouldBe(2);
|
||||
|
||||
// Total = Average (2.0) x interval-seconds (60) = 120.0, quality + timestamp carried.
|
||||
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
|
||||
result.Samples[0].Value.ShouldBe(120.0);
|
||||
result.Samples[0].SourceTimestampUtc.ShouldBe(bucketTs);
|
||||
|
||||
// Null Average bucket → still BadNoData / null after scaling.
|
||||
result.Samples[1].StatusCode.ShouldBe(0x800E0000u); // BadNoData
|
||||
result.Samples[1].Value.ShouldBeNull();
|
||||
result.Samples[1].SourceTimestampUtc.ShouldBe(bucketTs.AddMinutes(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (6) When the sidecar replies with a <see cref="MessageKind"/> the client does not
|
||||
/// expect (e.g. ReadRawReply where ReadAtTimeReply was expected), the client must throw
|
||||
/// <see cref="InvalidDataException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_SidecarRepliesWithWrongKind_ThrowsInvalidDataException()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
// Force the server to reply with ReadAtTimeReply instead of ReadRawReply.
|
||||
ReplyWithWrongKind = MessageKind.ReadAtTimeReply,
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
|
||||
await Should.ThrowAsync<InvalidDataException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||
}
|
||||
|
||||
// ===== Finding-003 / Finding-004: health counter consistency =====
|
||||
|
||||
/// <summary>
|
||||
/// (Finding 003 + 004) A sidecar-level failure must be classified once: TotalSuccesses
|
||||
/// must stay at 0, TotalFailures must become 1, and TotalQueries / TotalSuccesses /
|
||||
/// TotalFailures must all be updated under the same lock so a concurrent snapshot can
|
||||
/// never observe inflated successes or out-of-band TotalQueries. This pins behaviour so
|
||||
/// a future regression to the "RecordSuccess then undo via ReclassifySuccessAsFailure"
|
||||
/// dance is caught.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetHealthSnapshot_SidecarFailure_NeverInflatesSuccessCounter()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnReadRaw = _ => new ReadRawReply { Success = false, Error = "boom" },
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None));
|
||||
|
||||
var snap = client.GetHealthSnapshot();
|
||||
snap.TotalQueries.ShouldBe(1);
|
||||
snap.TotalSuccesses.ShouldBe(0);
|
||||
snap.TotalFailures.ShouldBe(1);
|
||||
snap.ConsecutiveFailures.ShouldBe(1);
|
||||
snap.LastError.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (Finding 003) Concurrent calls + concurrent <see cref="WonderwareHistorianClient.GetHealthSnapshot"/>
|
||||
/// reads must observe consistent counters. Specifically, TotalSuccesses + TotalFailures
|
||||
/// must equal TotalQueries at every observed snapshot (no torn read between an
|
||||
/// Interlocked-incremented TotalQueries and a lock-protected outcome counter). The
|
||||
/// channel serializes calls, so the test is observable: each completed query strictly
|
||||
/// increments either successes or failures by one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetHealthSnapshot_ConcurrentCallsAndReads_CountersAreInternallyConsistent()
|
||||
{
|
||||
await using var server = new FakeSidecarServer(Secret)
|
||||
{
|
||||
OnReadRaw = _ => new ReadRawReply { Success = true },
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = TcpClientFor(server);
|
||||
|
||||
using var stop = new CancellationTokenSource();
|
||||
var readerSawInconsistent = false;
|
||||
|
||||
#pragma warning disable xUnit1051 // Internal Task.Run loop drives a polling stress test; cancellation flows via stop.IsCancellationRequested below.
|
||||
var reader = Task.Run(() =>
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
var snap = client.GetHealthSnapshot();
|
||||
// Every completed call increments TotalQueries AND exactly one of
|
||||
// TotalSuccesses or TotalFailures under the same lock; an in-flight call
|
||||
// has not yet incremented any of them. So TotalQueries should always equal
|
||||
// the sum of TotalSuccesses + TotalFailures (no in-between state visible).
|
||||
if (snap.TotalSuccesses + snap.TotalFailures != snap.TotalQueries)
|
||||
{
|
||||
readerSawInconsistent = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
#pragma warning restore xUnit1051
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
await client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
stop.Cancel();
|
||||
await reader;
|
||||
|
||||
readerSawInconsistent.ShouldBeFalse(
|
||||
"GetHealthSnapshot exposed TotalQueries that disagreed with the sum of TotalSuccesses + TotalFailures — counters are not updated under a single lock.");
|
||||
|
||||
var final = client.GetHealthSnapshot();
|
||||
final.TotalQueries.ShouldBe(50);
|
||||
final.TotalSuccesses.ShouldBe(50);
|
||||
final.TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ===== Task 3: default public ctor dials TCP =====
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the default public ctor connects over TCP rather than named-pipe by
|
||||
/// constructing the client against a loopback <see cref="TcpListener"/> and asserting
|
||||
/// that a ReadRaw round-trip returns the known sample. If the ctor still dialled a
|
||||
/// named pipe the connect would fail because no pipe is listening.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DefaultCtor_DialsTcp_ReadRawRoundTrips()
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
|
||||
// 1. Start a loopback TCP listener on an OS-assigned port.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
var expectedTicks = new DateTime(2026, 6, 12, 8, 0, 0, DateTimeKind.Utc).Ticks;
|
||||
var expectedValue = MessagePackSerializer.Serialize<object>(99.0, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// 2. Accept one client in the background and drive the server side of the protocol.
|
||||
// Intentional: the background server task uses cts.Token (a linked+timeout source)
|
||||
// rather than TestContext.Current.CancellationToken directly, because it adds a
|
||||
// wall-clock safety bound so the test never hangs CI.
|
||||
#pragma warning disable xUnit1051
|
||||
var serverTask = Task.Run(async () =>
|
||||
{
|
||||
using var server = await listener.AcceptTcpClientAsync(cts.Token);
|
||||
server.NoDelay = true;
|
||||
var stream = server.GetStream();
|
||||
using var reader = new FrameReader(stream, leaveOpen: true);
|
||||
using var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
|
||||
// Hello handshake.
|
||||
var helloFrame = await reader.ReadFrameAsync(cts.Token);
|
||||
helloFrame.ShouldNotBeNull();
|
||||
helloFrame!.Value.Kind.ShouldBe(MessageKind.Hello);
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = true, HostName = "test-tcp-sidecar" }, cts.Token);
|
||||
|
||||
// ReadRaw request.
|
||||
var reqFrame = await reader.ReadFrameAsync(cts.Token);
|
||||
reqFrame.ShouldNotBeNull();
|
||||
reqFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawRequest);
|
||||
var req = MessagePackSerializer.Deserialize<ReadRawRequest>(reqFrame.Value.Body);
|
||||
|
||||
var reply = new ReadRawReply
|
||||
{
|
||||
Success = true,
|
||||
CorrelationId = req.CorrelationId,
|
||||
Samples =
|
||||
[
|
||||
new HistorianSampleDto
|
||||
{
|
||||
ValueBytes = expectedValue,
|
||||
Quality = 192, // Good
|
||||
TimestampUtcTicks = expectedTicks,
|
||||
},
|
||||
],
|
||||
};
|
||||
await writer.WriteAsync(MessageKind.ReadRawReply, reply, cts.Token);
|
||||
}, cts.Token);
|
||||
#pragma warning restore xUnit1051
|
||||
|
||||
// 3. Construct the client via the PUBLIC ctor (no ForTests factory).
|
||||
var opts = new WonderwareHistorianClientOptions(
|
||||
Host: "127.0.0.1",
|
||||
Port: boundPort,
|
||||
SharedSecret: Secret,
|
||||
ConnectTimeout: TimeSpan.FromSeconds(5),
|
||||
CallTimeout: TimeSpan.FromSeconds(5))
|
||||
{
|
||||
UseTls = false,
|
||||
};
|
||||
|
||||
WonderwareHistorianClient? client = null;
|
||||
try
|
||||
{
|
||||
client = new WonderwareHistorianClient(opts);
|
||||
|
||||
var result = await client.ReadRawAsync(
|
||||
"Tank.Level",
|
||||
new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 6, 13, 0, 0, 0, DateTimeKind.Utc),
|
||||
100, cts.Token);
|
||||
|
||||
// 4. Assert the known sample came back.
|
||||
result.Samples.Count.ShouldBe(1);
|
||||
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
|
||||
result.Samples[0].SourceTimestampUtc.ShouldBe(new DateTime(expectedTicks, DateTimeKind.Utc));
|
||||
result.Samples[0].Value.ShouldBe(99.0);
|
||||
|
||||
await serverTask;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (client is not null) await client.DisposeAsync();
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack"/>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-270
@@ -1,270 +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.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// PR C.1 — pins the trinary outcome → IPC bool[] mapping that the sidecar uses
|
||||
/// on the WriteAlarmEvents reply. Per-event outcomes:
|
||||
/// Ack → true, RetryPlease → false, PermanentFail → false.
|
||||
/// The sender's B.4 widens the IPC bool back into the trinary outcome at the
|
||||
/// IPC boundary using structured diagnostics; the wire intentionally collapses
|
||||
/// to "ok / not-ok".
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AahClientManagedAlarmEventWriterTests
|
||||
{
|
||||
/// <summary>Verifies that an empty batch returns an empty array without invoking the backend.</summary>
|
||||
[Fact]
|
||||
public async Task Empty_batch_returns_empty_array_without_invoking_backend()
|
||||
{
|
||||
var backend = new RecordingBackend(_ => throw new InvalidOperationException("must not invoke for empty input"));
|
||||
var writer = new AahClientManagedAlarmEventWriter(backend);
|
||||
|
||||
var result = await writer.WriteAsync(Array.Empty<AlarmHistorianEventDto>(), CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
backend.Calls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a single acknowledgment outcome maps to true.</summary>
|
||||
[Fact]
|
||||
public async Task Single_ack_outcome_maps_to_true()
|
||||
{
|
||||
var backend = new RecordingBackend(events => events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray());
|
||||
var writer = new AahClientManagedAlarmEventWriter(backend);
|
||||
|
||||
var result = await writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(new[] { true });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a mixed batch preserves per-slot outcome ordering.</summary>
|
||||
[Fact]
|
||||
public async Task Mixed_batch_preserves_per_slot_ordering()
|
||||
{
|
||||
// Ack / Retry / Permanent / Ack — the sender uses positional matching against
|
||||
// its queue, so every slot must hit the exact bool corresponding to its input.
|
||||
var backend = new RecordingBackend(_ => new[]
|
||||
{
|
||||
AlarmHistorianWriteOutcome.Ack,
|
||||
AlarmHistorianWriteOutcome.RetryPlease,
|
||||
AlarmHistorianWriteOutcome.PermanentFail,
|
||||
AlarmHistorianWriteOutcome.Ack,
|
||||
});
|
||||
var writer = new AahClientManagedAlarmEventWriter(backend);
|
||||
|
||||
var result = await writer.WriteAsync(
|
||||
new[] { Event("E1"), Event("E2"), Event("E3"), Event("E4") },
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(new[] { true, false, false, true });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that backend exceptions mark the whole batch as RetryPlease.</summary>
|
||||
[Fact]
|
||||
public async Task Backend_exception_marks_whole_batch_RetryPlease()
|
||||
{
|
||||
var backend = new RecordingBackend(_ => throw new InvalidOperationException("cluster unreachable"));
|
||||
var writer = new AahClientManagedAlarmEventWriter(backend);
|
||||
|
||||
var result = await writer.WriteAsync(
|
||||
new[] { Event("E1"), Event("E2"), Event("E3") },
|
||||
CancellationToken.None);
|
||||
|
||||
// Whole batch must end up as "not ok" (RetryPlease at the trinary layer) —
|
||||
// dropping a transiently-failed batch corrupts the sender's queue.
|
||||
result.ShouldBe(new[] { false, false, false });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cancellation propagates from the backend.</summary>
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_from_backend()
|
||||
{
|
||||
var backend = new RecordingBackend(_ => throw new OperationCanceledException());
|
||||
var writer = new AahClientManagedAlarmEventWriter(backend);
|
||||
|
||||
var ex = await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None));
|
||||
ex.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a backend returning the wrong outcome count degrades to RetryPlease.</summary>
|
||||
[Fact]
|
||||
public async Task Backend_returning_wrong_count_degrades_to_RetryPlease()
|
||||
{
|
||||
// Backend returns more outcomes than inputs — defensive degrade rather than
|
||||
// letting a backend bug desync the sender's queue accounting.
|
||||
var backend = new RecordingBackend(_ => new[]
|
||||
{
|
||||
AlarmHistorianWriteOutcome.Ack,
|
||||
AlarmHistorianWriteOutcome.Ack,
|
||||
});
|
||||
var writer = new AahClientManagedAlarmEventWriter(backend);
|
||||
|
||||
var result = await writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(new[] { false });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a large batch with all acknowledgments returns all true outcomes.</summary>
|
||||
/// <param name="batchSize">The batch size to test.</param>
|
||||
[Theory]
|
||||
[InlineData(100)]
|
||||
[InlineData(1000)]
|
||||
public async Task Large_batch_all_ack_returns_all_true(int batchSize)
|
||||
{
|
||||
// Spec: "1 / 100 / 1000 events through a fake aahClientManaged writer;
|
||||
// assert per-row outcome list parallel to input order."
|
||||
var backend = new RecordingBackend(events => events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray());
|
||||
var writer = new AahClientManagedAlarmEventWriter(backend);
|
||||
|
||||
var batch = Enumerable.Range(0, batchSize)
|
||||
.Select(i => Event($"E{i}"))
|
||||
.ToArray();
|
||||
|
||||
var result = await writer.WriteAsync(batch, CancellationToken.None);
|
||||
|
||||
result.Length.ShouldBe(batchSize);
|
||||
result.ShouldAllBe(ok => ok);
|
||||
backend.Calls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a large batch with alternating outcomes preserves positional ordering.</summary>
|
||||
/// <param name="batchSize">The batch size to test.</param>
|
||||
[Theory]
|
||||
[InlineData(100)]
|
||||
[InlineData(1000)]
|
||||
public async Task Large_batch_alternating_outcomes_are_positionally_correct(int batchSize)
|
||||
{
|
||||
// Verifies that per-row outcome ordering is preserved for large batches;
|
||||
// a backend that returns the outcomes in a different allocation order would
|
||||
// fail this test if the writer incorrectly indexing outcomes.
|
||||
var backend = new RecordingBackend(events =>
|
||||
events.Select((_, i) => i % 2 == 0
|
||||
? AlarmHistorianWriteOutcome.Ack
|
||||
: AlarmHistorianWriteOutcome.RetryPlease).ToArray());
|
||||
var writer = new AahClientManagedAlarmEventWriter(backend);
|
||||
|
||||
var batch = Enumerable.Range(0, batchSize).Select(i => Event($"E{i}")).ToArray();
|
||||
var result = await writer.WriteAsync(batch, CancellationToken.None);
|
||||
|
||||
result.Length.ShouldBe(batchSize);
|
||||
for (var i = 0; i < result.Length; i++)
|
||||
{
|
||||
var expected = i % 2 == 0;
|
||||
result[i].ShouldBe(expected, $"slot {i}: expected {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that retry then succeed correctly simulates cluster failover.</summary>
|
||||
[Fact]
|
||||
public async Task Backend_retry_then_succeed_simulates_cluster_failover()
|
||||
{
|
||||
// Spec: "Cluster failover: primary node returns BadCommunicationError;
|
||||
// picker rotates to secondary; assert eventual success."
|
||||
//
|
||||
// The real cluster-failover path is internal to SdkAlarmHistorianWriteBackend
|
||||
// (which is rig-gated) and is exercised at the HistorianClusterEndpointPicker
|
||||
// level in HistorianClusterEndpointPickerTests. Here we test the
|
||||
// AahClientManagedAlarmEventWriter's handling of a backend that returns
|
||||
// RetryPlease on the first call (primary-node failure) and Ack on the
|
||||
// second call (secondary-node success), confirming the IPC layer correctly
|
||||
// propagates the trinary outcome across two separate drain ticks.
|
||||
var callCount = 0;
|
||||
var backend = new RecordingBackend(events =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
// First call: simulate communication error (isCommunicationError=true)
|
||||
// which produces RetryPlease — equivalent to primary node failing.
|
||||
return events.Select(_ => AlarmHistorianWriteOutcome.RetryPlease).ToArray();
|
||||
}
|
||||
// Second call (after cluster picker has rotated to secondary): Ack.
|
||||
return events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray();
|
||||
});
|
||||
var writer = new AahClientManagedAlarmEventWriter(backend);
|
||||
var batch = new[] { Event("E1"), Event("E2") };
|
||||
|
||||
// First drain tick: primary "fails" → all RetryPlease (false at IPC layer).
|
||||
var firstResult = await writer.WriteAsync(batch, CancellationToken.None);
|
||||
firstResult.ShouldBe(new[] { false, false });
|
||||
|
||||
// Second drain tick: secondary succeeds → all Ack (true at IPC layer).
|
||||
var secondResult = await writer.WriteAsync(batch, CancellationToken.None);
|
||||
secondResult.ShouldBe(new[] { true, true });
|
||||
|
||||
backend.Calls.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies outcome mapping across various HRESULT and error condition combinations.</summary>
|
||||
/// <param name="hresult">The HRESULT code to test.</param>
|
||||
/// <param name="isCommunicationError">Whether the error is a communication error.</param>
|
||||
/// <param name="isMalformedInput">Whether the input is malformed.</param>
|
||||
/// <param name="expected">The expected outcome.</param>
|
||||
[Theory]
|
||||
// hresult 0 + clean → Ack
|
||||
[InlineData(0, false, false, AlarmHistorianWriteOutcome.Ack)]
|
||||
// hresult 0 but malformed → PermanentFail (malformed wins)
|
||||
[InlineData(0, false, true, AlarmHistorianWriteOutcome.PermanentFail)]
|
||||
// non-zero hresult + comm error → RetryPlease
|
||||
[InlineData(unchecked((int)0x80131500), true, false, AlarmHistorianWriteOutcome.RetryPlease)]
|
||||
// non-zero hresult, no comm flag, no malformed → conservative RetryPlease
|
||||
[InlineData(unchecked((int)0x80131500), false, false, AlarmHistorianWriteOutcome.RetryPlease)]
|
||||
// any malformed input → PermanentFail regardless of hresult
|
||||
[InlineData(unchecked((int)0x80131500), true, true, AlarmHistorianWriteOutcome.PermanentFail)]
|
||||
public void MapOutcome_table(int hresult, bool isCommunicationError, bool isMalformedInput, AlarmHistorianWriteOutcome expected)
|
||||
{
|
||||
AahClientManagedAlarmEventWriter
|
||||
.MapOutcome(hresult, isCommunicationError, isMalformedInput)
|
||||
.ShouldBe(expected);
|
||||
}
|
||||
|
||||
private static AlarmHistorianEventDto Event(string id) => new AlarmHistorianEventDto
|
||||
{
|
||||
EventId = id,
|
||||
SourceName = "Tank01",
|
||||
ConditionId = "Tank01.Level.HiHi",
|
||||
AlarmType = "AnalogLimitAlarm.HiHi",
|
||||
Message = "Tank 01 high-high level",
|
||||
Severity = 750,
|
||||
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
|
||||
AckComment = null,
|
||||
};
|
||||
|
||||
/// <summary>Test double that records calls and returns outcomes via a delegate.</summary>
|
||||
private sealed class RecordingBackend : IAlarmHistorianWriteBackend
|
||||
{
|
||||
private readonly Func<AlarmHistorianEventDto[], AlarmHistorianWriteOutcome[]> _produce;
|
||||
|
||||
/// <summary>Gets the number of calls recorded.</summary>
|
||||
public int Calls { get; private set; }
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="RecordingBackend"/> class.</summary>
|
||||
/// <param name="produce">A delegate that produces outcomes for the given events.</param>
|
||||
public RecordingBackend(Func<AlarmHistorianEventDto[], AlarmHistorianWriteOutcome[]> produce)
|
||||
{
|
||||
_produce = produce;
|
||||
}
|
||||
|
||||
/// <summary>Records a call and returns outcomes from the delegate.</summary>
|
||||
/// <param name="events">The events to write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The outcomes produced by the delegate.</returns>
|
||||
public Task<AlarmHistorianWriteOutcome[]> WriteBatchAsync(
|
||||
AlarmHistorianEventDto[] events, CancellationToken cancellationToken)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(_produce(events));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianClusterEndpointPickerTests
|
||||
{
|
||||
private static HistorianConfiguration Config(params string[] nodes) => new()
|
||||
{
|
||||
ServerName = "ignored",
|
||||
ServerNames = nodes.ToList(),
|
||||
FailureCooldownSeconds = 60,
|
||||
};
|
||||
|
||||
/// <summary>Verifies that a single-node configuration falls back to ServerName when ServerNames is empty.</summary>
|
||||
[Fact]
|
||||
public void Single_node_config_falls_back_to_ServerName_when_ServerNames_empty()
|
||||
{
|
||||
var cfg = new HistorianConfiguration { ServerName = "only-node", ServerNames = new() };
|
||||
var p = new HistorianClusterEndpointPicker(cfg);
|
||||
p.NodeCount.ShouldBe(1);
|
||||
p.GetHealthyNodes().ShouldBe(new[] { "only-node" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed node enters cooldown and is skipped from the healthy nodes list.</summary>
|
||||
[Fact]
|
||||
public void Failed_node_enters_cooldown_and_is_skipped()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => now);
|
||||
|
||||
p.MarkFailed("a", "boom");
|
||||
p.GetHealthyNodes().ShouldBe(new[] { "b" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the cooldown period expires after the configured time window.</summary>
|
||||
[Fact]
|
||||
public void Cooldown_expires_after_configured_window()
|
||||
{
|
||||
var clock = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => clock);
|
||||
p.MarkFailed("a", "boom");
|
||||
p.GetHealthyNodes().ShouldBe(new[] { "b" });
|
||||
|
||||
clock = clock.AddSeconds(61);
|
||||
p.GetHealthyNodes().ShouldBe(new[] { "a", "b" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that marking a node healthy immediately clears its cooldown.</summary>
|
||||
[Fact]
|
||||
public void MarkHealthy_immediately_clears_cooldown()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var p = new HistorianClusterEndpointPicker(Config("a"), () => now);
|
||||
p.MarkFailed("a", "boom");
|
||||
p.GetHealthyNodes().ShouldBeEmpty();
|
||||
p.MarkHealthy("a");
|
||||
p.GetHealthyNodes().ShouldBe(new[] { "a" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when all nodes are in cooldown, an empty healthy list is returned.</summary>
|
||||
[Fact]
|
||||
public void All_nodes_in_cooldown_returns_empty_healthy_list()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => now);
|
||||
p.MarkFailed("a", "x");
|
||||
p.MarkFailed("b", "y");
|
||||
p.GetHealthyNodes().ShouldBeEmpty();
|
||||
p.NodeCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a snapshot reports failure count and the last error message.</summary>
|
||||
[Fact]
|
||||
public void Snapshot_reports_failure_count_and_last_error()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var p = new HistorianClusterEndpointPicker(Config("a"), () => now);
|
||||
p.MarkFailed("a", "first");
|
||||
p.MarkFailed("a", "second");
|
||||
|
||||
var snap = p.SnapshotNodeStates().Single();
|
||||
snap.FailureCount.ShouldBe(2);
|
||||
snap.LastError.ShouldBe("second");
|
||||
snap.IsHealthy.ShouldBeFalse();
|
||||
snap.CooldownUntil.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that duplicate hostnames are deduplicated case-insensitively.</summary>
|
||||
[Fact]
|
||||
public void Duplicate_hostnames_are_deduplicated_case_insensitively()
|
||||
{
|
||||
var p = new HistorianClusterEndpointPicker(Config("NodeA", "nodea", "NodeB"));
|
||||
p.NodeCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-012 coverage — pins <see cref="HistorianDataSource"/>'s
|
||||
/// connect-failover / cooldown loop via a fake <see cref="IHistorianConnectionFactory"/>.
|
||||
/// A live <see cref="HistorianAccess"/> is never instantiated; the fake throws on every
|
||||
/// attempt so the read path surfaces the connect failure without touching the SDK.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianDataSourceConnectFailoverTests
|
||||
{
|
||||
/// <summary>Verifies that ReadRaw throws when no nodes are healthy.</summary>
|
||||
[Fact]
|
||||
public async Task ReadRaw_when_no_nodes_are_healthy_throws_so_IPC_surfaces_Success_false()
|
||||
{
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerNames = new List<string> { "node-a" },
|
||||
FailureCooldownSeconds = 60,
|
||||
// Disable the outer request timeout so the test doesn't race the connect failure
|
||||
// against the timeout (we want the connect failure path, not a TimeoutException).
|
||||
RequestTimeoutSeconds = 0,
|
||||
};
|
||||
var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory());
|
||||
|
||||
// Read methods used to swallow the connect exception and return an empty list with
|
||||
// Success=true; the fix re-throws so the IPC layer surfaces Success=false. The
|
||||
// exception must therefore propagate.
|
||||
await Should.ThrowAsync<Exception>(() => ds.ReadRawAsync(
|
||||
"Tank.Level",
|
||||
new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 5, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
maxValues: 100,
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadRaw tries each cluster node in order.</summary>
|
||||
[Fact]
|
||||
public async Task ReadRaw_tries_each_cluster_node_in_order_until_one_succeeds_or_all_fail()
|
||||
{
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerNames = new List<string> { "node-a", "node-b", "node-c" },
|
||||
FailureCooldownSeconds = 60,
|
||||
RequestTimeoutSeconds = 0,
|
||||
};
|
||||
var factory = new TrackingThrowingConnectionFactory();
|
||||
var ds = new HistorianDataSource(cfg, factory);
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => ds.ReadRawAsync(
|
||||
"Tank.Level",
|
||||
new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 5, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
maxValues: 100,
|
||||
CancellationToken.None));
|
||||
|
||||
// All three candidates must be attempted in the configured order before the
|
||||
// connect-loop gives up.
|
||||
factory.AttemptedNodes.ShouldBe(new[] { "node-a", "node-b", "node-c" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that failed nodes are marked in cooldown and not retried immediately.</summary>
|
||||
[Fact]
|
||||
public async Task ReadRaw_marks_failed_nodes_in_cooldown_so_a_subsequent_call_sees_no_healthy_nodes()
|
||||
{
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerNames = new List<string> { "node-a", "node-b" },
|
||||
FailureCooldownSeconds = 60,
|
||||
RequestTimeoutSeconds = 0,
|
||||
};
|
||||
var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory());
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => ds.ReadRawAsync(
|
||||
"Tank.Level",
|
||||
DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow,
|
||||
maxValues: 100, CancellationToken.None));
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.NodeCount.ShouldBe(2);
|
||||
snap.HealthyNodeCount.ShouldBe(0, "both nodes failed and entered cooldown after the connect attempts");
|
||||
snap.ProcessConnectionOpen.ShouldBeFalse();
|
||||
snap.ActiveProcessNode.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadEvents uses a separate event connection path.</summary>
|
||||
[Fact]
|
||||
public async Task ReadEvents_uses_a_separate_event_connection_path()
|
||||
{
|
||||
// ReadEventsAsync uses _eventConnection / EnsureEventConnected — a different
|
||||
// codepath than ReadRawAsync. Symmetric test to pin the dual-connection design.
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerNames = new List<string> { "node-a" },
|
||||
FailureCooldownSeconds = 60,
|
||||
RequestTimeoutSeconds = 0,
|
||||
};
|
||||
var factory = new TrackingThrowingConnectionFactory();
|
||||
var ds = new HistorianDataSource(cfg, factory);
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => ds.ReadEventsAsync(
|
||||
sourceName: "Tank.HiHi",
|
||||
DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow,
|
||||
maxEvents: 100, CancellationToken.None));
|
||||
|
||||
factory.AttemptedTypes.ShouldContain(HistorianConnectionType.Event,
|
||||
"event reads must open an Event-typed connection");
|
||||
factory.AttemptedNodes.ShouldBe(new[] { "node-a" });
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Simulates a connection failure by throwing an exception.
|
||||
/// </summary>
|
||||
/// <param name="config">The historian configuration.</param>
|
||||
/// <param name="type">The connection type.</param>
|
||||
/// <param name="readOnly">Whether to open a read-only connection.</param>
|
||||
public HistorianAccess CreateAndConnect(
|
||||
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
|
||||
=> throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
|
||||
}
|
||||
|
||||
private sealed class TrackingThrowingConnectionFactory : IHistorianConnectionFactory
|
||||
{
|
||||
/// <summary>Gets the list of node names that were attempted.</summary>
|
||||
public List<string> AttemptedNodes { get; } = new();
|
||||
/// <summary>Gets the list of connection types that were attempted.</summary>
|
||||
public List<HistorianConnectionType> AttemptedTypes { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tracks connection attempts and simulates a connection failure.
|
||||
/// </summary>
|
||||
/// <param name="config">The historian configuration.</param>
|
||||
/// <param name="type">The connection type.</param>
|
||||
/// <param name="readOnly">Whether to open a read-only connection.</param>
|
||||
public HistorianAccess CreateAndConnect(
|
||||
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
|
||||
{
|
||||
AttemptedNodes.Add(config.ServerName);
|
||||
AttemptedTypes.Add(type);
|
||||
throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using ArchestrA;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-005 regression tests for <see cref="HistorianDataSource.GetHealthSnapshot"/>.
|
||||
/// The active-node strings and the connection-open booleans were published under different
|
||||
/// locks, so a snapshot could observe an internally inconsistent pairing (open with no node,
|
||||
/// or closed with a non-null node). The fix derives the open booleans from the same field
|
||||
/// that is published under the same lock so the snapshot is self-consistent by construction.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianDataSourceHealthSnapshotTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the "half-published" state directly via reflection: set <c>_connection</c>
|
||||
/// to a non-null sentinel but leave <c>_activeProcessNode</c> null. The snapshot must
|
||||
/// report <c>ProcessConnectionOpen = false</c> and <c>ActiveProcessNode = null</c>
|
||||
/// consistently — never a mismatch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Snapshot_with_connection_set_but_active_node_null_is_consistent()
|
||||
{
|
||||
var ds = new HistorianDataSource(
|
||||
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
|
||||
|
||||
SetField(ds, "_connection", new HistorianAccess());
|
||||
SetField(ds, "_activeProcessNode", (string?)null);
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
(snap.ProcessConnectionOpen == (snap.ActiveProcessNode != null)).ShouldBeTrue(
|
||||
"snapshot must not advertise open with no node — picks one source of truth");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symmetric case for the event connection.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Snapshot_with_event_connection_set_but_active_node_null_is_consistent()
|
||||
{
|
||||
var ds = new HistorianDataSource(
|
||||
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
|
||||
|
||||
SetField(ds, "_eventConnection", new HistorianAccess());
|
||||
SetField(ds, "_activeEventNode", (string?)null);
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
(snap.EventConnectionOpen == (snap.ActiveEventNode != null)).ShouldBeTrue(
|
||||
"snapshot must not advertise event open with no node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The other direction: connection cleared but node still populated (the failure path
|
||||
/// between the two field clears). The snapshot must still pair them consistently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Snapshot_with_connection_cleared_but_active_node_populated_is_consistent()
|
||||
{
|
||||
var ds = new HistorianDataSource(
|
||||
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
|
||||
|
||||
SetField(ds, "_connection", (HistorianAccess?)null);
|
||||
SetField(ds, "_activeProcessNode", "node-stale");
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
(snap.ProcessConnectionOpen == (snap.ActiveProcessNode != null)).ShouldBeTrue(
|
||||
"snapshot must not advertise closed with a node still set");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steady-state happy path: both fields populated — snapshot reports both consistently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Snapshot_with_both_fields_populated_reports_open_and_active_node()
|
||||
{
|
||||
var ds = new HistorianDataSource(
|
||||
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
|
||||
|
||||
SetField(ds, "_connection", new HistorianAccess());
|
||||
SetField(ds, "_activeProcessNode", "h1");
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.ProcessConnectionOpen.ShouldBeTrue();
|
||||
snap.ActiveProcessNode.ShouldBe("h1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steady-state default (no connect attempted): both null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Snapshot_with_default_fields_reports_closed_with_no_active_node()
|
||||
{
|
||||
var ds = new HistorianDataSource(
|
||||
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.ProcessConnectionOpen.ShouldBeFalse();
|
||||
snap.ActiveProcessNode.ShouldBeNull();
|
||||
snap.EventConnectionOpen.ShouldBeFalse();
|
||||
snap.ActiveEventNode.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static void SetField(object target, string name, object? value)
|
||||
{
|
||||
var f = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
f.ShouldNotBeNull($"private field '{name}' must exist on {target.GetType().Name}");
|
||||
f!.SetValue(target, value);
|
||||
}
|
||||
}
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-010 regression. <see cref="HistorianConfiguration.RequestTimeoutSeconds"/>
|
||||
/// was documented as the "outer safety timeout applied to sync-over-async Historian
|
||||
/// operations" but was never read or enforced — a hung <c>StartQuery</c> or a slow
|
||||
/// <c>MoveNext</c> could block the single pipe-server connection thread indefinitely.
|
||||
/// The fix wires it into the read paths via a linked <see cref="CancellationTokenSource"/>
|
||||
/// so the documented safety net actually exists.
|
||||
///
|
||||
/// The SDK-touching read methods cannot be unit-driven without a live AVEVA Historian.
|
||||
/// This test pins the helper that derives the effective timeout from the config — the
|
||||
/// read methods invoke that helper, so a regression in either the helper or the wiring
|
||||
/// would break the test.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianDataSourceRequestTimeoutTests
|
||||
{
|
||||
/// <summary>Verifies default request timeout is 60 seconds.</summary>
|
||||
[Fact]
|
||||
public void Default_request_timeout_is_60_seconds()
|
||||
{
|
||||
new HistorianConfiguration().RequestTimeoutSeconds.ShouldBe(60);
|
||||
}
|
||||
|
||||
/// <summary>Verifies positive request timeout values are applied correctly.</summary>
|
||||
[Fact]
|
||||
public void Positive_request_timeout_is_used_verbatim()
|
||||
{
|
||||
InvokeBuildLinkedTokenSource(
|
||||
new HistorianConfiguration { RequestTimeoutSeconds = 30 },
|
||||
CancellationToken.None,
|
||||
out var cts);
|
||||
cts.ShouldNotBeNull();
|
||||
// The helper must wire CancelAfter — easiest cross-check is to observe that the
|
||||
// returned CTS is NOT already cancelled, and that disposing it is safe.
|
||||
cts!.IsCancellationRequested.ShouldBeFalse();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies zero or negative timeout values disable the outer safety timeout.</summary>
|
||||
[Fact]
|
||||
public void Zero_or_negative_request_timeout_is_treated_as_no_timeout()
|
||||
{
|
||||
// A zero/negative value means "no outer timeout" — the helper must still return a
|
||||
// linked CTS so callers can use one code path, but it must not auto-cancel.
|
||||
InvokeBuildLinkedTokenSource(
|
||||
new HistorianConfiguration { RequestTimeoutSeconds = 0 },
|
||||
CancellationToken.None,
|
||||
out var cts);
|
||||
cts.ShouldNotBeNull();
|
||||
cts!.IsCancellationRequested.ShouldBeFalse();
|
||||
// Give the runtime a moment — a misconfigured CancelAfter(0) would fire immediately.
|
||||
Thread.Sleep(50);
|
||||
cts.IsCancellationRequested.ShouldBeFalse("RequestTimeoutSeconds <= 0 must not auto-cancel");
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies short timeout values correctly fire cancellation on the linked token.</summary>
|
||||
[Fact]
|
||||
public async Task Small_timeout_cancels_the_linked_token()
|
||||
{
|
||||
// 50 ms timeout — sleep 250 ms then assert the linked CTS has fired.
|
||||
InvokeBuildLinkedTokenSource(
|
||||
new HistorianConfiguration { RequestTimeoutSeconds = 1 }, // smallest non-zero whole-second value
|
||||
CancellationToken.None,
|
||||
out var cts);
|
||||
cts.ShouldNotBeNull();
|
||||
|
||||
// The wall-clock cost of waiting a full second per test is acceptable — this
|
||||
// pins the actual CancelAfter wiring rather than just the conditional logic.
|
||||
await Task.Delay(1500);
|
||||
cts!.IsCancellationRequested.ShouldBeTrue("RequestTimeoutSeconds=1 must cancel within 1.5s");
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies caller's cancellation token propagates to the linked token.</summary>
|
||||
[Fact]
|
||||
public void Inbound_cancellation_propagates_into_the_linked_token()
|
||||
{
|
||||
using var outer = new CancellationTokenSource();
|
||||
InvokeBuildLinkedTokenSource(
|
||||
new HistorianConfiguration { RequestTimeoutSeconds = 60 },
|
||||
outer.Token,
|
||||
out var cts);
|
||||
cts.ShouldNotBeNull();
|
||||
cts!.IsCancellationRequested.ShouldBeFalse();
|
||||
|
||||
outer.Cancel();
|
||||
cts.IsCancellationRequested.ShouldBeTrue("cancelling the caller's CT must cancel the linked CTS");
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private static void InvokeBuildLinkedTokenSource(
|
||||
HistorianConfiguration cfg, CancellationToken ct, out CancellationTokenSource? cts)
|
||||
{
|
||||
// The helper is internal so the InternalsVisibleTo on the data-source project lets
|
||||
// us bind to it directly. Reflection keeps the test resilient if the method name is
|
||||
// ever shortened.
|
||||
var method = typeof(HistorianDataSource)
|
||||
.GetMethod("BuildRequestCts", BindingFlags.Static | BindingFlags.NonPublic);
|
||||
method.ShouldNotBeNull(
|
||||
"HistorianDataSource.BuildRequestCts must exist — wires RequestTimeoutSeconds into the read paths");
|
||||
cts = (CancellationTokenSource?)method!.Invoke(null, new object[] { cfg, ct });
|
||||
}
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
using ArchestrA;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-008 regression. The previous implementation unconditionally
|
||||
/// called <c>HandleConnectionError()</c> whenever <c>StartQuery</c> returned <c>false</c>,
|
||||
/// which tore down the (relatively expensive) shared SDK connection on a query-class error
|
||||
/// such as a bad tag name. A burst of bad-tag queries could therefore push an otherwise
|
||||
/// healthy cluster node into cooldown via the picker's <c>MarkFailed</c>. The fix
|
||||
/// classifies the SDK error code: connection-class codes drop the connection; query-class
|
||||
/// codes leave it intact.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianDataSourceStartQueryClassificationTests
|
||||
{
|
||||
// ── Connection-class codes — the connection should be reset ───────────
|
||||
|
||||
/// <summary>Verifies that connection-class error codes are classified as connection errors.</summary>
|
||||
/// <param name="code">The historian error code to test.</param>
|
||||
[Theory]
|
||||
[InlineData(HistorianAccessError.ErrorValue.FailedToConnect)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.FailedToCreateSession)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NoReply)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotReady)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotInitialized)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.Stopping)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.Win32Exception)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.InvalidResponse)]
|
||||
public void Connection_class_codes_are_classified_as_connection_errors(HistorianAccessError.ErrorValue code)
|
||||
{
|
||||
HistorianDataSource.IsConnectionClassError(code).ShouldBeTrue(
|
||||
$"{code} is a connection/server failure — the SDK connection should be reset");
|
||||
}
|
||||
|
||||
// ── Query-class codes — the connection should NOT be reset ────────────
|
||||
|
||||
/// <summary>Verifies that query-class error codes are NOT classified as connection errors.</summary>
|
||||
/// <param name="code">The historian error code to test.</param>
|
||||
[Theory]
|
||||
[InlineData(HistorianAccessError.ErrorValue.InvalidArgument)] // bad tag name, etc.
|
||||
[InlineData(HistorianAccessError.ErrorValue.ValidationFailed)] // bad query args
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotApplicable)] // wrong tag kind for query
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotImplemented)] // unsupported aggregate
|
||||
[InlineData(HistorianAccessError.ErrorValue.NoData)] // empty range
|
||||
public void Query_class_codes_are_NOT_classified_as_connection_errors(HistorianAccessError.ErrorValue code)
|
||||
{
|
||||
HistorianDataSource.IsConnectionClassError(code).ShouldBeFalse(
|
||||
$"{code} is a query payload problem — must NOT tear down the SDK connection");
|
||||
}
|
||||
|
||||
// ── Driver.Historian.Wonderware-014: the at-time loop must classify a per-timestamp
|
||||
// StartQuery failure the same way the raw / aggregate / event paths do. The SDK
|
||||
// HistoryQuery type is sealed-by-non-virtual + has no interface, so the loop itself
|
||||
// can't be driven offline; the per-failure decision is therefore extracted into a
|
||||
// pure helper that the at-time loop calls and these tests pin directly. ──────────
|
||||
|
||||
/// <summary>
|
||||
/// A connection-class StartQuery error in the at-time loop must signal "reset the
|
||||
/// connection and abort the read" (true) — not silently record a Bad sample and keep
|
||||
/// hammering the dead connection for every remaining timestamp.
|
||||
/// </summary>
|
||||
/// <param name="code">The connection-class error code.</param>
|
||||
[Theory]
|
||||
[InlineData(HistorianAccessError.ErrorValue.FailedToConnect)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NoReply)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotReady)]
|
||||
public void AtTime_StartQuery_failure_with_connection_class_code_requests_connection_reset(
|
||||
HistorianAccessError.ErrorValue code)
|
||||
{
|
||||
var error = new HistorianAccessError { ErrorCode = code };
|
||||
HistorianDataSource.ShouldResetConnectionForStartQueryFailure(error).ShouldBeTrue(
|
||||
$"{code} is a connection failure — the at-time loop must reset the connection, not record Bad");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A query-class StartQuery error (or a missing error) in the at-time loop must NOT
|
||||
/// reset the connection (false): a single bad/empty timestamp records a per-timestamp
|
||||
/// Bad sample and continues to the next without tearing down the shared connection.
|
||||
/// </summary>
|
||||
/// <param name="code">The query-class error code.</param>
|
||||
[Theory]
|
||||
[InlineData(HistorianAccessError.ErrorValue.InvalidArgument)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NoData)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotApplicable)]
|
||||
public void AtTime_StartQuery_failure_with_query_class_code_does_not_request_reset(
|
||||
HistorianAccessError.ErrorValue code)
|
||||
{
|
||||
var error = new HistorianAccessError { ErrorCode = code };
|
||||
HistorianDataSource.ShouldResetConnectionForStartQueryFailure(error).ShouldBeFalse(
|
||||
$"{code} is a query/no-data problem — the at-time loop keeps the connection and records Bad");
|
||||
}
|
||||
|
||||
/// <summary>A null error defaults to query-class (no reset) — the caller still records a Bad sample.</summary>
|
||||
[Fact]
|
||||
public void AtTime_StartQuery_failure_with_null_error_defaults_to_no_reset()
|
||||
{
|
||||
HistorianDataSource.ShouldResetConnectionForStartQueryFailure(null).ShouldBeFalse(
|
||||
"a null error must not be promoted to a connection reset");
|
||||
}
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
using System.Runtime.Serialization;
|
||||
using ArchestrA;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-012 coverage — pins the two static helpers on
|
||||
/// <see cref="HistorianDataSource"/> that previously had no direct tests:
|
||||
/// <see cref="HistorianDataSource.SelectValueFromPair"/> (the string-vs-numeric heuristic
|
||||
/// for the raw + at-time read paths) and <see cref="HistorianDataSource.ExtractAggregateValue"/>
|
||||
/// (the aggregate-column dispatch). The SDK <c>HistoryQueryResult</c> initialises internal
|
||||
/// state lazily on first property access, which makes it impractical to fake via
|
||||
/// <see cref="FormatterServices.GetUninitializedObject"/>; the heuristic was therefore
|
||||
/// refactored into an SDK-independent overload that the tests drive directly.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianDataSourceValueAndAggregateTests
|
||||
{
|
||||
// ── SelectValueFromPair ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that numeric value is returned when StringValue is empty.</summary>
|
||||
[Fact]
|
||||
public void SelectValueFromPair_returns_numeric_value_when_StringValue_is_empty()
|
||||
{
|
||||
HistorianDataSource.SelectValueFromPair(42.5, string.Empty).ShouldBe(42.5);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that numeric value is returned when Value is non-zero even if StringValue is populated.</summary>
|
||||
[Fact]
|
||||
public void SelectValueFromPair_returns_numeric_value_when_Value_is_non_zero_even_with_StringValue_populated()
|
||||
{
|
||||
// Tag is numeric and sampled non-zero; the SDK may still populate a formatted
|
||||
// StringValue but the value path wins.
|
||||
HistorianDataSource.SelectValueFromPair(3.14, "3.14").ShouldBe(3.14);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that StringValue is returned when Value is zero and StringValue is non-empty.</summary>
|
||||
[Fact]
|
||||
public void SelectValueFromPair_returns_StringValue_when_Value_is_zero_and_StringValue_non_empty()
|
||||
{
|
||||
// String tags in the SDK always project Value=0 — that's the documented heuristic.
|
||||
HistorianDataSource.SelectValueFromPair(0.0, "Ready").ShouldBe("Ready");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that numeric zero is returned when Value is zero and StringValue is empty.</summary>
|
||||
[Fact]
|
||||
public void SelectValueFromPair_returns_numeric_zero_when_Value_is_zero_and_StringValue_empty()
|
||||
{
|
||||
// Numeric tag legitimately samples zero, no formatted text — must remain numeric.
|
||||
HistorianDataSource.SelectValueFromPair(0.0, string.Empty).ShouldBe(0.0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null StringValue falls back to numeric value.</summary>
|
||||
[Fact]
|
||||
public void SelectValueFromPair_null_StringValue_falls_back_to_numeric()
|
||||
{
|
||||
HistorianDataSource.SelectValueFromPair(7.7, null).ShouldBe(7.7);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the documented edge case where numeric zero with a formatted string returns the string.</summary>
|
||||
[Fact]
|
||||
public void SelectValueFromPair_documented_edge_case_numeric_zero_with_formatted_string_returns_string()
|
||||
{
|
||||
// The doc comment on SelectValue calls this out as a known SDK-binding edge case:
|
||||
// "A numeric tag at exactly zero with a non-empty formatted StringValue (e.g. '0.00')
|
||||
// would be mis-reported as a string". This test pins that documented behaviour so
|
||||
// a future SDK upgrade that surfaces a real data-type field can replace the
|
||||
// heuristic deliberately rather than by accident.
|
||||
HistorianDataSource.SelectValueFromPair(0.0, "0.00").ShouldBe("0.00");
|
||||
}
|
||||
|
||||
// ── ExtractAggregateValue ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that aggregate value extraction dispatches correctly for known columns.</summary>
|
||||
/// <param name="column">The aggregate result column name to extract.</param>
|
||||
/// <param name="expected">The expected aggregate double value.</param>
|
||||
[Theory]
|
||||
[InlineData("Average", 10.0)]
|
||||
[InlineData("Minimum", 1.0)]
|
||||
[InlineData("Maximum", 20.0)]
|
||||
[InlineData("First", 2.0)]
|
||||
[InlineData("Last", 8.0)]
|
||||
[InlineData("StdDev", 1.5)]
|
||||
public void ExtractAggregateValue_dispatches_known_columns(string column, double expected)
|
||||
{
|
||||
var result = NewAggregateResult();
|
||||
result.Average = 10.0;
|
||||
result.Minimum = 1.0;
|
||||
result.Maximum = 20.0;
|
||||
result.ValueCount = 5;
|
||||
result.First = 2.0;
|
||||
result.Last = 8.0;
|
||||
result.StdDev = 1.5;
|
||||
|
||||
HistorianDataSource.ExtractAggregateValue(result, column).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ValueCount is dispatched to the uint field.</summary>
|
||||
[Fact]
|
||||
public void ExtractAggregateValue_ValueCount_dispatches_to_uint_field()
|
||||
{
|
||||
var result = NewAggregateResult();
|
||||
result.ValueCount = 42;
|
||||
HistorianDataSource.ExtractAggregateValue(result, "ValueCount").ShouldBe(42.0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown column returns null.</summary>
|
||||
[Fact]
|
||||
public void ExtractAggregateValue_unknown_column_returns_null()
|
||||
{
|
||||
// Unknown column → null → IPC sample carries no value → client maps to BadNoData.
|
||||
HistorianDataSource.ExtractAggregateValue(NewAggregateResult(), "NotAColumn").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that aggregate value dispatch is case-sensitive.</summary>
|
||||
[Fact]
|
||||
public void ExtractAggregateValue_case_sensitive_dispatch()
|
||||
{
|
||||
// The switch is case-sensitive — "average" (lowercase) does NOT dispatch. Pinned so
|
||||
// the canonical column-name casing is preserved across refactors.
|
||||
var result = NewAggregateResult();
|
||||
result.Average = 99.0;
|
||||
HistorianDataSource.ExtractAggregateValue(result, "average").ShouldBeNull();
|
||||
HistorianDataSource.ExtractAggregateValue(result, "Average").ShouldBe(99.0);
|
||||
}
|
||||
|
||||
private static AnalogSummaryQueryResult NewAggregateResult()
|
||||
{
|
||||
return (AnalogSummaryQueryResult)FormatterServices.GetUninitializedObject(typeof(AnalogSummaryQueryResult));
|
||||
}
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianQualityMapperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Rich mapping preserves specific OPC DA subcodes through the historian ToWire path.
|
||||
/// Before PR 12 the category-only fallback collapsed e.g. BadNotConnected(8) to
|
||||
/// Bad(0x80000000) so downstream OPC UA clients could not distinguish transport issues
|
||||
/// from sensor issues. After PR 12 every known subcode round-trips to its canonical
|
||||
/// uint32 StatusCode and Proxy translation stays byte-for-byte with v1 QualityMapper.
|
||||
/// </summary>
|
||||
/// <param name="quality">The OPC DA quality code to map.</param>
|
||||
/// <param name="expected">The expected canonical OPC UA StatusCode.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)192, 0x00000000u)] // Good
|
||||
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData((byte)64, 0x40000000u)] // Uncertain
|
||||
[InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
|
||||
[InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
|
||||
[InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
|
||||
[InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
|
||||
[InlineData((byte)0, 0x80000000u)] // Bad
|
||||
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
|
||||
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
|
||||
[InlineData((byte)12, 0x808B0000u)] // Bad_DeviceFailure
|
||||
[InlineData((byte)16, 0x808C0000u)] // Bad_SensorFailure
|
||||
[InlineData((byte)20, 0x80050000u)] // Bad_CommunicationError
|
||||
[InlineData((byte)24, 0x808D0000u)] // Bad_OutOfService
|
||||
[InlineData((byte)32, 0x80320000u)] // Bad_WaitingForInitialData
|
||||
public void Maps_specific_OPC_DA_codes_to_canonical_StatusCode(byte quality, uint expected)
|
||||
{
|
||||
HistorianQualityMapper.Map(quality).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unknown good-family quality codes fall back to plain Good.</summary>
|
||||
/// <param name="q">The OPC DA quality byte to test.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)200)] // Good — unknown subcode in Good family
|
||||
[InlineData((byte)255)] // Good — unknown
|
||||
public void Unknown_good_family_codes_fall_back_to_plain_Good(byte q)
|
||||
{
|
||||
HistorianQualityMapper.Map(q).ShouldBe(0x00000000u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unknown uncertain-family quality codes fall back to plain Uncertain.</summary>
|
||||
/// <param name="q">The OPC DA quality byte to test.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)100)] // Uncertain — unknown subcode
|
||||
[InlineData((byte)150)] // Uncertain — unknown
|
||||
public void Unknown_uncertain_family_codes_fall_back_to_plain_Uncertain(byte q)
|
||||
{
|
||||
HistorianQualityMapper.Map(q).ShouldBe(0x40000000u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unknown bad-family quality codes fall back to plain Bad.</summary>
|
||||
/// <param name="q">The OPC DA quality byte to test.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)1)] // Bad — unknown subcode
|
||||
[InlineData((byte)50)] // Bad — unknown
|
||||
public void Unknown_bad_family_codes_fall_back_to_plain_Bad(byte q)
|
||||
{
|
||||
HistorianQualityMapper.Map(q).ShouldBe(0x80000000u);
|
||||
}
|
||||
}
|
||||
-323
@@ -1,323 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// PR C.1 — covers <see cref="SdkAlarmHistorianWriteBackend"/>, the aahClientManaged-bound
|
||||
/// alarm-event writer. The SDK-touching batch loop itself is exercised by the rig-gated
|
||||
/// <c>Live_*</c> tests (D.1); the unit tests below pin the parts that are SDK-type-free:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>connection-unavailable → whole batch deferred as RetryPlease;</description></item>
|
||||
/// <item><description><see cref="SdkAlarmHistorianWriteBackend.ClassifyOutcome"/> error-code mapping;</description></item>
|
||||
/// <item><description><see cref="SdkHistorianConnectionFactory.BuildConnectionArgs"/> read-only-vs-write shaping.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SdkAlarmHistorianWriteBackendTests
|
||||
{
|
||||
// ── Connection-unavailable path (deterministic, no SDK load) ──────────
|
||||
|
||||
/// <summary>Verifies that an empty batch returns an empty outcome array.</summary>
|
||||
[Fact]
|
||||
public async Task Empty_batch_returns_empty_array()
|
||||
{
|
||||
var backend = new SdkAlarmHistorianWriteBackend(
|
||||
Config("any"), new ThrowingConnectionFactory());
|
||||
|
||||
var outcomes = await backend.WriteBatchAsync(
|
||||
Array.Empty<AlarmHistorianEventDto>(), CancellationToken.None);
|
||||
|
||||
outcomes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when all nodes are unreachable, the entire batch is deferred as RetryPlease.</summary>
|
||||
[Fact]
|
||||
public async Task Unreachable_node_defers_whole_batch_as_RetryPlease()
|
||||
{
|
||||
// No node can be connected — the backend must defer every event so the
|
||||
// lmxopcua-side SQLite store-and-forward sink retains the rows rather than
|
||||
// dropping them.
|
||||
var backend = new SdkAlarmHistorianWriteBackend(
|
||||
Config("unreachable"), new ThrowingConnectionFactory());
|
||||
|
||||
var events = new[] { AlarmEvent("E1"), AlarmEvent("E2"), AlarmEvent("E3") };
|
||||
var outcomes = await backend.WriteBatchAsync(events, CancellationToken.None);
|
||||
|
||||
outcomes.Length.ShouldBe(events.Length);
|
||||
outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a large batch with unreachable nodes returns one outcome per event.</summary>
|
||||
[Fact]
|
||||
public async Task Unreachable_node_large_batch_returns_one_outcome_per_event()
|
||||
{
|
||||
// Guards the outcome-array allocation: WriteBatchAsync must always return exactly
|
||||
// as many outcomes as input events, even on the whole-batch-deferred path.
|
||||
var backend = new SdkAlarmHistorianWriteBackend(
|
||||
Config("unreachable"), new ThrowingConnectionFactory());
|
||||
|
||||
var batch = Enumerable.Range(0, 1000).Select(i => AlarmEvent($"E{i}")).ToArray();
|
||||
var outcomes = await backend.WriteBatchAsync(batch, CancellationToken.None);
|
||||
|
||||
outcomes.Length.ShouldBe(1000);
|
||||
outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a connection failure marks the node as failed in the endpoint picker.</summary>
|
||||
[Fact]
|
||||
public async Task Connect_failure_marks_node_failed_in_picker()
|
||||
{
|
||||
// Every connect attempt throws → the picker should record the failure so the
|
||||
// node enters cooldown (cluster-failover plumbing).
|
||||
var cfg = Config("node-a");
|
||||
var picker = new HistorianClusterEndpointPicker(cfg);
|
||||
var backend = new SdkAlarmHistorianWriteBackend(cfg, new ThrowingConnectionFactory(), picker);
|
||||
|
||||
await backend.WriteBatchAsync(new[] { AlarmEvent("E1") }, CancellationToken.None);
|
||||
|
||||
picker.HealthyNodeCount.ShouldBe(0, "the only node failed to connect and is now in cooldown");
|
||||
}
|
||||
|
||||
// ── ClassifyOutcome — error-code → outcome mapping ────────────────────
|
||||
|
||||
/// <summary>Verifies that error codes map to the expected write outcomes.</summary>
|
||||
/// <param name="code">The historian access error code to classify.</param>
|
||||
/// <param name="expected">The expected write outcome.</param>
|
||||
[Theory]
|
||||
[InlineData(HistorianAccessError.ErrorValue.Success, AlarmHistorianWriteOutcome.Ack)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.FailedToConnect, AlarmHistorianWriteOutcome.RetryPlease)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.FailedToCreateSession, AlarmHistorianWriteOutcome.RetryPlease)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NoReply, AlarmHistorianWriteOutcome.RetryPlease)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotReady, AlarmHistorianWriteOutcome.RetryPlease)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.Failure, AlarmHistorianWriteOutcome.RetryPlease)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NoData, AlarmHistorianWriteOutcome.RetryPlease)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.InvalidArgument, AlarmHistorianWriteOutcome.PermanentFail)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.ValidationFailed, AlarmHistorianWriteOutcome.PermanentFail)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NullPointerArgument, AlarmHistorianWriteOutcome.PermanentFail)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotImplemented, AlarmHistorianWriteOutcome.PermanentFail)]
|
||||
public void ClassifyOutcome_maps_error_code_to_expected_outcome(
|
||||
HistorianAccessError.ErrorValue code, AlarmHistorianWriteOutcome expected)
|
||||
{
|
||||
SdkAlarmHistorianWriteBackend.ClassifyOutcome(code).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ── ToHistorianEvent — EventId handling ───────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a parseable event ID is used verbatim in the historian event.</summary>
|
||||
[Fact]
|
||||
public void ToHistorianEvent_parseable_event_id_is_used_verbatim()
|
||||
{
|
||||
// Sanity case: a real GUID round-trips into HistorianEvent.Id.
|
||||
var id = Guid.Parse("12345678-1234-1234-1234-123456789abc");
|
||||
var dto = new AlarmHistorianEventDto
|
||||
{
|
||||
EventId = id.ToString(),
|
||||
SourceName = "Tank01",
|
||||
AlarmType = "AnalogLimitAlarm.HiHi",
|
||||
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
|
||||
};
|
||||
|
||||
#pragma warning disable CS0618
|
||||
SdkAlarmHistorianWriteBackend.ToHistorianEvent(dto).Id.ShouldBe(id);
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unparseable event ID is synthesized as a unique non-empty GUID.</summary>
|
||||
[Fact]
|
||||
public void ToHistorianEvent_unparseable_event_id_synthesizes_unique_non_empty_Guid()
|
||||
{
|
||||
// Driver.Historian.Wonderware-004 regression: when EventId is not a parseable
|
||||
// GUID (or is empty) the previous implementation silently left HistorianEvent.Id
|
||||
// as Guid.Empty, so multiple alarms collided on the same id with no warning.
|
||||
// The fix synthesizes a fresh Guid so every event still gets a unique identifier.
|
||||
var dtoA = new AlarmHistorianEventDto
|
||||
{
|
||||
EventId = "not-a-guid",
|
||||
SourceName = "Tank01",
|
||||
AlarmType = "Active",
|
||||
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
|
||||
};
|
||||
var dtoB = new AlarmHistorianEventDto
|
||||
{
|
||||
EventId = string.Empty,
|
||||
SourceName = "Tank01",
|
||||
AlarmType = "Active",
|
||||
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
|
||||
};
|
||||
|
||||
#pragma warning disable CS0618
|
||||
var idA = SdkAlarmHistorianWriteBackend.ToHistorianEvent(dtoA).Id;
|
||||
var idB = SdkAlarmHistorianWriteBackend.ToHistorianEvent(dtoB).Id;
|
||||
#pragma warning restore CS0618
|
||||
|
||||
idA.ShouldNotBe(Guid.Empty, "unparseable EventId must not collapse to Guid.Empty");
|
||||
idB.ShouldNotBe(Guid.Empty, "empty EventId must not collapse to Guid.Empty");
|
||||
idA.ShouldNotBe(idB, "every event needs a unique synthesized id");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a write-to-read-only-file error is classified as RetryPlease, not PermanentFail.</summary>
|
||||
[Fact]
|
||||
public void ClassifyOutcome_WriteToReadOnlyFile_is_RetryPlease_not_PermanentFail()
|
||||
{
|
||||
// Driver.Historian.Wonderware-001 regression: WriteToReadOnlyFile is a
|
||||
// connection-configuration fault (the write session was opened without
|
||||
// ReadOnly = false), NOT a malformed-event fault. Routing it to PermanentFail
|
||||
// would dead-letter every alarm event in the batch on a misconfigured/regressed
|
||||
// connection — data loss. It must be treated as a transient connection-class
|
||||
// error so the events are deferred and retried once the connection is corrected.
|
||||
SdkAlarmHistorianWriteBackend.ClassifyOutcome(
|
||||
HistorianAccessError.ErrorValue.WriteToReadOnlyFile)
|
||||
.ShouldBe(AlarmHistorianWriteOutcome.RetryPlease);
|
||||
}
|
||||
|
||||
// ── BuildConnectionArgs — read-only vs write shaping ──────────────────
|
||||
|
||||
/// <summary>Verifies that a write connection is opened with ReadOnly set to false.</summary>
|
||||
[Fact]
|
||||
public void BuildConnectionArgs_write_connection_is_not_read_only()
|
||||
{
|
||||
// The alarm-event write path must open ReadOnly=false; AddStreamedValue on a
|
||||
// read-only session fails with WriteToReadOnlyFile.
|
||||
var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
|
||||
Config("h1"), HistorianConnectionType.Event, readOnly: false);
|
||||
|
||||
args.ReadOnly.ShouldBeFalse();
|
||||
args.ConnectionType.ShouldBe(HistorianConnectionType.Event);
|
||||
args.ServerName.ShouldBe("h1");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a query connection is opened with ReadOnly set to true.</summary>
|
||||
[Fact]
|
||||
public void BuildConnectionArgs_query_connection_is_read_only()
|
||||
{
|
||||
var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
|
||||
Config("h1"), HistorianConnectionType.Process, readOnly: true);
|
||||
|
||||
args.ReadOnly.ShouldBeTrue();
|
||||
args.ConnectionType.ShouldBe(HistorianConnectionType.Process);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-integrated security credentials are preserved in connection arguments.</summary>
|
||||
[Fact]
|
||||
public void BuildConnectionArgs_non_integrated_security_carries_credentials()
|
||||
{
|
||||
var cfg = Config("h1");
|
||||
cfg.IntegratedSecurity = false;
|
||||
cfg.UserName = "histuser";
|
||||
cfg.Password = "histpass";
|
||||
|
||||
var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
|
||||
cfg, HistorianConnectionType.Event, readOnly: false);
|
||||
|
||||
args.IntegratedSecurity.ShouldBeFalse();
|
||||
args.UserName.ShouldBe("histuser");
|
||||
args.Password.ShouldBe("histpass");
|
||||
}
|
||||
|
||||
// ── Rig-gated integration tests ───────────────────────────────────────
|
||||
//
|
||||
// The entry point (HistorianAccess.AddStreamedValue) is pinned and implemented;
|
||||
// these need a live AVEVA Historian and are un-skipped during the PR D.1 smoke.
|
||||
|
||||
/// <summary>Verifies that a single alarm event roundtrip returns an Ack outcome.</summary>
|
||||
[Fact(Skip = "rig-required: needs a live AVEVA Historian — un-skip during the PR D.1 rollout smoke")]
|
||||
public async Task Live_single_event_roundtrip_returns_Ack()
|
||||
{
|
||||
var backend = new SdkAlarmHistorianWriteBackend(BuildRigConfig());
|
||||
|
||||
var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-E1") }, CancellationToken.None);
|
||||
|
||||
outcomes.Length.ShouldBe(1);
|
||||
outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cluster failover rotates from a bad primary node to a secondary node.</summary>
|
||||
[Fact(Skip = "rig-required: needs a live AVEVA Historian cluster (two nodes) — un-skip during the PR D.1 rollout smoke")]
|
||||
public async Task Live_cluster_failover_primary_bad_rotates_to_secondary()
|
||||
{
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerNames = new List<string>
|
||||
{
|
||||
"invalid-primary-node-deliberately-unreachable",
|
||||
Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
|
||||
},
|
||||
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
|
||||
IntegratedSecurity = true,
|
||||
FailureCooldownSeconds = 5,
|
||||
CommandTimeoutSeconds = 10,
|
||||
};
|
||||
var backend = new SdkAlarmHistorianWriteBackend(cfg);
|
||||
|
||||
var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-failover-E1") }, CancellationToken.None);
|
||||
|
||||
outcomes.Length.ShouldBe(1);
|
||||
outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack);
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static HistorianConfiguration Config(string server) => new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = server,
|
||||
Port = 32568,
|
||||
IntegratedSecurity = true,
|
||||
CommandTimeoutSeconds = 30,
|
||||
FailureCooldownSeconds = 60,
|
||||
};
|
||||
|
||||
private static AlarmHistorianEventDto AlarmEvent(string id) => new AlarmHistorianEventDto
|
||||
{
|
||||
EventId = id,
|
||||
SourceName = "TestSource",
|
||||
ConditionId = "TestSource.Level.HiHi",
|
||||
AlarmType = "AnalogLimitAlarm.HiHi",
|
||||
Message = "C.1 test alarm",
|
||||
Severity = 500,
|
||||
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
|
||||
AckComment = null,
|
||||
};
|
||||
|
||||
private static HistorianConfiguration BuildRigConfig() => new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
|
||||
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
|
||||
IntegratedSecurity = true,
|
||||
CommandTimeoutSeconds = 30,
|
||||
FailureCooldownSeconds = 60,
|
||||
};
|
||||
|
||||
private static int TryParseInt(string envName, int defaultValue)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(envName);
|
||||
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake factory whose every connect attempt throws — drives the
|
||||
/// connection-unavailable path without loading the native SDK.
|
||||
/// </summary>
|
||||
private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory
|
||||
{
|
||||
/// <summary>Creates and attempts to connect, always throwing a simulated connect failure.</summary>
|
||||
/// <param name="config">The historian configuration specifying the target server.</param>
|
||||
/// <param name="type">The connection type (Process or Event).</param>
|
||||
/// <param name="readOnly">Whether to open a read-only connection.</param>
|
||||
public HistorianAccess CreateAndConnect(
|
||||
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
|
||||
=> throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog.Core;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the sidecar's poison-event classifier and the per-event status mapping in
|
||||
/// <see cref="HistorianFrameHandler"/>. A structurally-malformed alarm event is marked
|
||||
/// Permanent (status 2) and excluded from the writer batch so the store-and-forward sink
|
||||
/// dead-letters it immediately rather than looping to the retry cap; well-formed events
|
||||
/// map to Ack (0) / Retry (1) from the writer's per-event bool result.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianEventClassifierTests
|
||||
{
|
||||
/// <summary>Verifies a blank source name is classified structurally malformed.</summary>
|
||||
[Fact]
|
||||
public void IsStructurallyMalformed_BlankSourceName_IsTrue()
|
||||
{
|
||||
var e = WellFormed();
|
||||
e.SourceName = " ";
|
||||
|
||||
HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a blank alarm type is classified structurally malformed.</summary>
|
||||
[Fact]
|
||||
public void IsStructurallyMalformed_BlankAlarmType_IsTrue()
|
||||
{
|
||||
var e = WellFormed();
|
||||
e.AlarmType = "";
|
||||
|
||||
HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-positive event timestamp is classified structurally malformed.</summary>
|
||||
/// <param name="ticks">The event timestamp in ticks to test.</param>
|
||||
[Theory]
|
||||
[InlineData(0L)]
|
||||
[InlineData(-1L)]
|
||||
public void IsStructurallyMalformed_NonPositiveTimestamp_IsTrue(long ticks)
|
||||
{
|
||||
var e = WellFormed();
|
||||
e.EventTimeUtcTicks = ticks;
|
||||
|
||||
HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a well-formed event is not classified structurally malformed.</summary>
|
||||
[Fact]
|
||||
public void IsStructurallyMalformed_WellFormedEvent_IsFalse()
|
||||
{
|
||||
HistorianFrameHandler.IsStructurallyMalformed(WellFormed()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A mixed batch — one poison event then one well-formed event the writer acks — must
|
||||
/// yield PerEventStatus = [2, 0]: the poison event is Permanent and excluded from the
|
||||
/// writer batch, and only the well-formed event reaches the writer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handler_MixedBatch_MarksPoisonPermanent_AndOnlyWritesWellFormed()
|
||||
{
|
||||
var poison = WellFormed();
|
||||
poison.EventId = "poison";
|
||||
poison.SourceName = ""; // structurally malformed
|
||||
|
||||
var good = WellFormed();
|
||||
good.EventId = "good";
|
||||
|
||||
var fakeWriter = new RecordingAlarmEventWriter(_ => true);
|
||||
var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter);
|
||||
|
||||
var req = new WriteAlarmEventsRequest { Events = new[] { poison, good }, CorrelationId = "c1" };
|
||||
var reply = await RoundTripAsync(handler, req);
|
||||
|
||||
reply.Success.ShouldBeTrue();
|
||||
reply.PerEventStatus.ShouldBe(new byte[] { 2, 0 });
|
||||
reply.PerEventOk.ShouldBe(new[] { false, true });
|
||||
|
||||
// The writer only ever saw the well-formed event.
|
||||
fakeWriter.Received.Count.ShouldBe(1);
|
||||
fakeWriter.Received[0].EventId.ShouldBe("good");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A well-formed event the writer reports as not-persisted maps to Retry (status 1),
|
||||
/// not Permanent — only structurally-malformed events are Permanent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handler_WriterReportsNotPersisted_MapsToRetry()
|
||||
{
|
||||
var good = WellFormed();
|
||||
good.EventId = "good";
|
||||
|
||||
var fakeWriter = new RecordingAlarmEventWriter(_ => false);
|
||||
var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter);
|
||||
|
||||
var req = new WriteAlarmEventsRequest { Events = new[] { good }, CorrelationId = "c2" };
|
||||
var reply = await RoundTripAsync(handler, req);
|
||||
|
||||
reply.Success.ShouldBeTrue();
|
||||
reply.PerEventStatus.ShouldBe(new byte[] { 1 });
|
||||
reply.PerEventOk.ShouldBe(new[] { false });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An all-poison batch must short-circuit the writer entirely (no WriteAsync call)
|
||||
/// and mark every slot Permanent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handler_AllPoison_SkipsWriter_AllPermanent()
|
||||
{
|
||||
var p1 = WellFormed();
|
||||
p1.SourceName = "";
|
||||
var p2 = WellFormed();
|
||||
p2.AlarmType = "";
|
||||
|
||||
var fakeWriter = new RecordingAlarmEventWriter(_ => true);
|
||||
var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter);
|
||||
|
||||
var req = new WriteAlarmEventsRequest { Events = new[] { p1, p2 }, CorrelationId = "c3" };
|
||||
var reply = await RoundTripAsync(handler, req);
|
||||
|
||||
reply.Success.ShouldBeTrue();
|
||||
reply.PerEventStatus.ShouldBe(new byte[] { 2, 2 });
|
||||
fakeWriter.Received.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
private static AlarmHistorianEventDto WellFormed() => new()
|
||||
{
|
||||
EventId = "ev",
|
||||
SourceName = "Tank.HiHi",
|
||||
ConditionId = "HiHi",
|
||||
AlarmType = "LimitAlarm:Activated",
|
||||
Message = "msg",
|
||||
Severity = 700,
|
||||
EventTimeUtcTicks = new DateTime(2026, 6, 18, 12, 0, 0, DateTimeKind.Utc).Ticks,
|
||||
AckComment = null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Drives a WriteAlarmEvents request through the real frame handler over an in-memory
|
||||
/// duplex stream pair and deserializes the reply the handler writes back.
|
||||
/// </summary>
|
||||
private static async Task<WriteAlarmEventsReply> RoundTripAsync(
|
||||
HistorianFrameHandler handler, WriteAlarmEventsRequest req)
|
||||
{
|
||||
var capture = new MemoryStream();
|
||||
using var writer = new FrameWriter(capture, leaveOpen: true);
|
||||
|
||||
var body = MessagePackSerializer.Serialize(req);
|
||||
await handler.HandleAsync(MessageKind.WriteAlarmEventsRequest, body, writer, CancellationToken.None);
|
||||
|
||||
capture.Position = 0;
|
||||
using var reader = new FrameReader(capture, leaveOpen: true);
|
||||
var frame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||
frame.ShouldNotBeNull();
|
||||
frame!.Value.Kind.ShouldBe(MessageKind.WriteAlarmEventsReply);
|
||||
return MessagePackSerializer.Deserialize<WriteAlarmEventsReply>(frame.Value.Body);
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IAlarmEventWriter"/> that records the batch it received and returns a fixed verdict.</summary>
|
||||
private sealed class RecordingAlarmEventWriter : IAlarmEventWriter
|
||||
{
|
||||
private readonly Func<AlarmHistorianEventDto, bool> _verdict;
|
||||
|
||||
/// <summary>Initializes a new instance with the given per-event verdict.</summary>
|
||||
/// <param name="verdict">Maps each received event to its persisted/not-persisted result.</param>
|
||||
public RecordingAlarmEventWriter(Func<AlarmHistorianEventDto, bool> verdict) => _verdict = verdict;
|
||||
|
||||
/// <summary>The events the writer was handed, in order.</summary>
|
||||
public List<AlarmHistorianEventDto> Received { get; } = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool[]> WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken)
|
||||
{
|
||||
Received.AddRange(events);
|
||||
return Task.FromResult(events.Select(_verdict).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A read data source the WriteAlarmEvents path never touches — present only to
|
||||
/// satisfy the <see cref="HistorianFrameHandler"/> ctor's non-null requirement.
|
||||
/// </summary>
|
||||
private sealed class StubHistorian : IHistorianDataSource
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public Task<List<HistorianSample>> ReadRawAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime, double intervalMs, string aggregateColumn, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(
|
||||
string tagName, DateTime[] timestamps, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<List<global::ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend.HistorianEventDto>> ReadEventsAsync(
|
||||
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
-297
@@ -1,297 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip tests for <see cref="TcpFrameServer"/> added with the TCP transport. Each
|
||||
/// scenario binds the server on <c>127.0.0.1:0</c>, connects a real <see cref="TcpClient"/>,
|
||||
/// performs the Hello handshake, and exercises a request/reply over the wire framing — both
|
||||
/// plaintext and over TLS. These target net48 and run on Windows in CI; on the macOS dev box
|
||||
/// they only compile.
|
||||
/// </summary>
|
||||
public sealed class TcpRoundTripTests
|
||||
{
|
||||
private static readonly ILogger Quiet = Logger.None;
|
||||
|
||||
// Generous timeout so the deterministic tests don't hang CI if the server misbehaves.
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Fake handler that echoes a fixed <see cref="ReadRawReply"/> when it sees a
|
||||
/// <see cref="MessageKind.ReadRawRequest"/>, mirroring the client correlation id.
|
||||
/// </summary>
|
||||
private sealed class EchoHandler : IFrameHandler
|
||||
{
|
||||
public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
if (kind != MessageKind.ReadRawRequest)
|
||||
return Task.CompletedTask;
|
||||
|
||||
var request = MessagePackSerializer.Deserialize<ReadRawRequest>(body);
|
||||
var reply = new ReadRawReply
|
||||
{
|
||||
CorrelationId = request.CorrelationId,
|
||||
Success = true,
|
||||
Samples = new[]
|
||||
{
|
||||
new HistorianSampleDto
|
||||
{
|
||||
ValueBytes = MessagePackSerializer.Serialize(42.0),
|
||||
Quality = 192,
|
||||
TimestampUtcTicks = new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc).Ticks,
|
||||
},
|
||||
},
|
||||
};
|
||||
return writer.WriteAsync(MessageKind.ReadRawReply, reply, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Generates an in-memory self-signed RSA cert with a serverAuth EKU and a private key.</summary>
|
||||
private static X509Certificate2 MakeSelfSignedCert()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=otopcua-historian-sidecar-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
req.CertificateExtensions.Add(
|
||||
new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") /* serverAuth */ }, critical: false));
|
||||
using var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
|
||||
// Round-trip through a PFX so the returned cert carries an exportable private key on net48.
|
||||
var pfx = ephemeral.Export(X509ContentType.Pfx, "pw");
|
||||
return new X509Certificate2(pfx, "pw", X509KeyStorageFlags.Exportable);
|
||||
}
|
||||
|
||||
/// <summary>Performs the Hello handshake on the given stream and returns the deserialized ack.</summary>
|
||||
private static async Task<HelloAck> HelloAsync(Stream stream, string secret, CancellationToken ct)
|
||||
{
|
||||
using var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
using var reader = new FrameReader(stream, leaveOpen: true);
|
||||
|
||||
await writer.WriteAsync(MessageKind.Hello,
|
||||
new Hello { ProtocolMajor = Hello.CurrentMajor, PeerName = "test-client", SharedSecret = secret }, ct);
|
||||
|
||||
var ackFrame = await reader.ReadFrameAsync(ct);
|
||||
ackFrame.ShouldNotBeNull();
|
||||
ackFrame!.Value.Kind.ShouldBe(MessageKind.HelloAck);
|
||||
return MessagePackSerializer.Deserialize<HelloAck>(ackFrame.Value.Body);
|
||||
}
|
||||
|
||||
/// <summary>Wraps a connected client socket stream in an SslStream that pins the server cert thumbprint.</summary>
|
||||
private static async Task<SslStream> ClientTlsAsync(NetworkStream inner, string expectedThumbprint, CancellationToken ct)
|
||||
{
|
||||
var ssl = new SslStream(inner, leaveInnerStreamOpen: false,
|
||||
userCertificateValidationCallback: (_, cert, _, _) =>
|
||||
cert is not null &&
|
||||
string.Equals(
|
||||
cert.GetCertHashString(),
|
||||
expectedThumbprint,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
await ssl.AuthenticateAsClientAsync("otopcua-historian-sidecar-test", clientCertificates: null,
|
||||
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
/// <summary>Plaintext: Hello (good secret) is accepted and a ReadRaw request is echoed back.</summary>
|
||||
[Fact]
|
||||
public async Task Plaintext_RoundTrip_HelloAcceptedAndRequestEchoed()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: null, Quiet);
|
||||
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
||||
var stream = client.GetStream();
|
||||
|
||||
var ack = await HelloAsync(stream, "shh", cts.Token);
|
||||
ack.Accepted.ShouldBeTrue();
|
||||
|
||||
using var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
using var reader = new FrameReader(stream, leaveOpen: true);
|
||||
|
||||
await writer.WriteAsync(MessageKind.ReadRawRequest,
|
||||
new ReadRawRequest { TagName = "Tank.Level", MaxValues = 10, CorrelationId = "corr-1" }, cts.Token);
|
||||
|
||||
var replyFrame = await reader.ReadFrameAsync(cts.Token);
|
||||
replyFrame.ShouldNotBeNull();
|
||||
replyFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawReply);
|
||||
var reply = MessagePackSerializer.Deserialize<ReadRawReply>(replyFrame.Value.Body);
|
||||
reply.Success.ShouldBeTrue();
|
||||
reply.CorrelationId.ShouldBe("corr-1");
|
||||
reply.Samples.Length.ShouldBe(1);
|
||||
MessagePackSerializer.Deserialize<double>(reply.Samples[0].ValueBytes!).ShouldBe(42.0);
|
||||
|
||||
client.Close();
|
||||
await serverTask;
|
||||
}
|
||||
|
||||
/// <summary>TLS: a self-signed server cert; the client pins its thumbprint; same exchange succeeds.</summary>
|
||||
[Fact]
|
||||
public async Task Tls_RoundTrip_HelloAcceptedAndRequestEchoed()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
using var cert = MakeSelfSignedCert();
|
||||
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: cert, Quiet);
|
||||
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
||||
using var ssl = await ClientTlsAsync(client.GetStream(), cert.Thumbprint, cts.Token);
|
||||
|
||||
var ack = await HelloAsync(ssl, "shh", cts.Token);
|
||||
ack.Accepted.ShouldBeTrue();
|
||||
|
||||
using var writer = new FrameWriter(ssl, leaveOpen: true);
|
||||
using var reader = new FrameReader(ssl, leaveOpen: true);
|
||||
|
||||
await writer.WriteAsync(MessageKind.ReadRawRequest,
|
||||
new ReadRawRequest { TagName = "Tank.Level", MaxValues = 10, CorrelationId = "tls-1" }, cts.Token);
|
||||
|
||||
var replyFrame = await reader.ReadFrameAsync(cts.Token);
|
||||
replyFrame.ShouldNotBeNull();
|
||||
replyFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawReply);
|
||||
var reply = MessagePackSerializer.Deserialize<ReadRawReply>(replyFrame.Value.Body);
|
||||
reply.Success.ShouldBeTrue();
|
||||
reply.CorrelationId.ShouldBe("tls-1");
|
||||
|
||||
client.Close();
|
||||
await serverTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TLS: when the client pins a wrong thumbprint the validation callback returns false,
|
||||
/// causing <see cref="SslStream.AuthenticateAsClientAsync"/> to throw
|
||||
/// <see cref="AuthenticationException"/> before any Hello is exchanged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Tls_BadThumbprint_AuthenticationFails()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
using var cert = MakeSelfSignedCert();
|
||||
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: cert, Quiet);
|
||||
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
||||
|
||||
// Deliberately pin the wrong thumbprint — all zeros.
|
||||
const string wrongThumbprint = "0000000000000000000000000000000000000000";
|
||||
var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false,
|
||||
userCertificateValidationCallback: (_, serverCert, _, _) =>
|
||||
serverCert is not null &&
|
||||
string.Equals(serverCert.GetCertHashString(), wrongThumbprint, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
await Should.ThrowAsync<AuthenticationException>(async () =>
|
||||
await ssl.AuthenticateAsClientAsync("otopcua-historian-sidecar-test", clientCertificates: null,
|
||||
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false));
|
||||
|
||||
ssl.Dispose();
|
||||
// Server will see the broken TLS handshake and end the connection; let it finish.
|
||||
try { await serverTask; } catch { /* server may throw on the aborted TLS */ }
|
||||
}
|
||||
|
||||
/// <summary>Bad secret: Hello is rejected with Accepted=false and the shared-secret-mismatch reason.</summary>
|
||||
[Fact]
|
||||
public async Task BadSecret_HelloRejected()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "right-secret", tlsCert: null, Quiet);
|
||||
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
||||
|
||||
var ack = await HelloAsync(client.GetStream(), "wrong-secret", cts.Token);
|
||||
ack.Accepted.ShouldBeFalse();
|
||||
ack.RejectReason.ShouldBe("shared-secret-mismatch");
|
||||
|
||||
client.Close();
|
||||
await serverTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-active serial accept: while client A is connected (Hello done), client B's
|
||||
/// Hello does not complete until A disconnects. The server only accepts one connection
|
||||
/// per <see cref="TcpFrameServer.RunOneConnectionAsync"/>, so B's handshake is served by
|
||||
/// the second loop iteration that runs only after A's connection ends.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SingleActive_SecondClientHelloCompletesOnlyAfterFirstCloses()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: null, Quiet);
|
||||
|
||||
// Run the server loop: it accepts one connection at a time, serially.
|
||||
var serverLoop = server.RunAsync(new EchoHandler(), cts.Token);
|
||||
|
||||
// Client A connects and completes its Hello — it now owns the single active slot.
|
||||
using var clientA = new TcpClient();
|
||||
await clientA.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
||||
var ackA = await HelloAsync(clientA.GetStream(), "shh", cts.Token);
|
||||
ackA.Accepted.ShouldBeTrue();
|
||||
|
||||
// Client B connects. The TCP connect may complete (OS backlog) but the server is still
|
||||
// busy with A, so B's Hello round-trip must NOT complete yet.
|
||||
using var clientB = new TcpClient();
|
||||
await clientB.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
||||
var bHelloTask = HelloAsync(clientB.GetStream(), "shh", cts.Token);
|
||||
|
||||
// Give B a chance to (wrongly) complete — it must remain pending while A is connected.
|
||||
var earlyWinner = await Task.WhenAny(bHelloTask, Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token));
|
||||
earlyWinner.ShouldNotBe(bHelloTask, "client B's Hello completed while client A was still connected");
|
||||
|
||||
// Now disconnect A. The server's next loop iteration accepts B and serves its Hello.
|
||||
clientA.Close();
|
||||
|
||||
var ackB = await bHelloTask;
|
||||
ackB.Accepted.ShouldBeTrue();
|
||||
|
||||
// Tear down: cancel the loop and let it unwind.
|
||||
cts.Cancel();
|
||||
try { await serverLoop; } catch (OperationCanceledException) { /* expected */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BindFailure_SurfacesBindError_NotPermanentNotListening()
|
||||
{
|
||||
// Regression (live-caught 2026-06-12): when TcpFrameServer's listener bind fails (port in a
|
||||
// Windows excluded range → WSAEACCES, or already in use), the failure must surface as the
|
||||
// bind SocketException on EVERY accept attempt — NOT a one-time bind error followed by a
|
||||
// permanent InvalidOperationException "Not listening". The latter is the assign-before-Start
|
||||
// wedge: a non-null-but-unstarted listener that EnsureListening's guard never re-Starts,
|
||||
// which crash-looped the live sidecar on the reserved port 32569.
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
|
||||
// Occupy a loopback port exclusively so the server's Start() bind is forbidden.
|
||||
var blocker = new TcpListener(IPAddress.Loopback, 0) { ExclusiveAddressUse = true };
|
||||
blocker.Start();
|
||||
try
|
||||
{
|
||||
var takenPort = ((IPEndPoint)blocker.LocalEndpoint).Port;
|
||||
using var server = new TcpFrameServer(IPAddress.Loopback, takenPort, "shh", tlsCert: null, Quiet);
|
||||
|
||||
// First accept attempt: the bind fails with a SocketException.
|
||||
await Should.ThrowAsync<SocketException>(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token));
|
||||
|
||||
// Second attempt MUST also be the bind SocketException — not InvalidOperationException
|
||||
// "Not listening". This is the assertion that fails against the assign-before-Start bug.
|
||||
var second = await Should.ThrowAsync<Exception>(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token));
|
||||
second.ShouldBeOfType<SocketException>();
|
||||
}
|
||||
finally { blocker.Stop(); }
|
||||
}
|
||||
}
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// PR C.2 — pins the env-var contract that gates whether the sidecar boots an
|
||||
/// alarm-event writer. Default-on (when the historian itself is enabled) so a
|
||||
/// fresh deploy picks up the writer without a service-config edit; explicit
|
||||
/// <c>false</c> opts a read-only deployment out.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ProgramAlarmWriterTests
|
||||
{
|
||||
/// <summary>Verifies that BuildAlarmWriter returns a writer when the environment variable is unset.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmWriter_returns_writer_when_env_unset()
|
||||
{
|
||||
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", null);
|
||||
|
||||
var writer = Program.BuildAlarmWriter();
|
||||
|
||||
writer.ShouldNotBeNull();
|
||||
writer.ShouldBeOfType<AahClientManagedAlarmEventWriter>();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BuildAlarmWriter returns a writer when the environment variable is explicitly true.</summary>
|
||||
/// <param name="value">The truthy environment variable string value to test.</param>
|
||||
[Theory]
|
||||
[InlineData("true")]
|
||||
[InlineData("True")]
|
||||
[InlineData("TRUE")]
|
||||
public void BuildAlarmWriter_returns_writer_when_env_explicitly_true(string value)
|
||||
{
|
||||
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", value);
|
||||
|
||||
var writer = Program.BuildAlarmWriter();
|
||||
|
||||
writer.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BuildAlarmWriter returns null when the environment variable is false.</summary>
|
||||
/// <param name="value">The falsy environment variable string value to test.</param>
|
||||
[Theory]
|
||||
[InlineData("false")]
|
||||
[InlineData("False")]
|
||||
[InlineData("FALSE")]
|
||||
public void BuildAlarmWriter_returns_null_when_env_false(string value)
|
||||
{
|
||||
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", value);
|
||||
|
||||
var writer = Program.BuildAlarmWriter();
|
||||
|
||||
writer.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BuildAlarmWriter treats unrecognized values as enabled.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmWriter_treats_unrecognized_value_as_enabled()
|
||||
{
|
||||
// Anything other than the literal "false" (case-insensitive) keeps the writer
|
||||
// wired — fail-open under accidental misconfiguration so an alarm-write deploy
|
||||
// doesn't silently lose alarms because of a typo.
|
||||
using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", "yes");
|
||||
|
||||
var writer = Program.BuildAlarmWriter();
|
||||
|
||||
writer.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private static IDisposable ScopedEnv(string name, string? value)
|
||||
{
|
||||
var prior = Environment.GetEnvironmentVariable(name);
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
return new DisposableAction(() => Environment.SetEnvironmentVariable(name, prior));
|
||||
}
|
||||
|
||||
/// <summary>Disposable wrapper for an action that executes on disposal.</summary>
|
||||
private sealed class DisposableAction : IDisposable
|
||||
{
|
||||
private readonly Action _action;
|
||||
/// <summary>Initializes a new instance that will execute the given action on disposal.</summary>
|
||||
/// <param name="action">The action to execute when disposed.</param>
|
||||
public DisposableAction(Action action) { _action = action; }
|
||||
/// <summary>Executes the stored action.</summary>
|
||||
public void Dispose() => _action();
|
||||
}
|
||||
}
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke test confirming the sidecar project links and the test project resolves a
|
||||
/// ProjectReference to it. Real behavioural tests live with the TCP frame server
|
||||
/// (<c>TcpFrameServer</c>); here we just verify the assembly identity is what the
|
||||
/// csproj declares.
|
||||
/// </summary>
|
||||
public class ProgramSmokeTests
|
||||
{
|
||||
/// <summary>Verifies that the Program assembly has the expected name.</summary>
|
||||
[Fact]
|
||||
public void Program_Assembly_HasExpectedName()
|
||||
{
|
||||
typeof(Program).Assembly.GetName().Name
|
||||
.ShouldBe("OtOpcUa.Driver.Historian.Wonderware");
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Wonderware Historian SDK — SdkAlarmHistorianWriteBackendTests pins the
|
||||
error-code (HistorianAccessError.ErrorValue) and connection-arg shaping;
|
||||
a DLL <Reference> doesn't flow transitively through the ProjectReference. -->
|
||||
<Reference Include="aahClientManaged">
|
||||
<HintPath>..\..\..\lib\aahClientManaged.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -62,7 +62,7 @@ public sealed class DriverPageJsonConverterTests
|
||||
var allDriverPages = typeof(S7DriverPage).Assembly.GetTypes()
|
||||
.Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract)
|
||||
.ToList();
|
||||
allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-page fleet");
|
||||
allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-page fleet");
|
||||
DriverPageTypes.Count.ShouldBe(allDriverPages.Count,
|
||||
"every *DriverPage must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
|
||||
}
|
||||
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
|
||||
public sealed class HistorianWonderwareDriverPageFormSerializationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesKnownFields()
|
||||
{
|
||||
var original = new WonderwareHistorianClientOptions(
|
||||
Host: "historian-prod.zb.local",
|
||||
Port: 32569,
|
||||
SharedSecret: "t0ps3cr3t",
|
||||
PeerName: "OtOpcUa-Primary",
|
||||
ConnectTimeout: TimeSpan.FromSeconds(20),
|
||||
CallTimeout: TimeSpan.FromSeconds(60))
|
||||
{
|
||||
ProbeTimeoutSeconds = 25,
|
||||
UseTls = true,
|
||||
ServerCertThumbprint = "A1B2C3D4E5F60718293A4B5C6D7E8F9012345678",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, _opts);
|
||||
var back = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _opts);
|
||||
|
||||
back.ShouldNotBeNull();
|
||||
back.Host.ShouldBe("historian-prod.zb.local");
|
||||
back.Port.ShouldBe(32569);
|
||||
back.SharedSecret.ShouldBe("t0ps3cr3t");
|
||||
back.PeerName.ShouldBe("OtOpcUa-Primary");
|
||||
back.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20));
|
||||
back.CallTimeout.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
back.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20));
|
||||
back.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
back.ProbeTimeoutSeconds.ShouldBe(25);
|
||||
back.UseTls.ShouldBeTrue();
|
||||
back.ServerCertThumbprint.ShouldBe("A1B2C3D4E5F60718293A4B5C6D7E8F9012345678");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_NullTimeouts_UsesDefaults()
|
||||
{
|
||||
var original = new WonderwareHistorianClientOptions(
|
||||
Host: "localhost",
|
||||
Port: 32569,
|
||||
SharedSecret: "secret");
|
||||
|
||||
var json = JsonSerializer.Serialize(original, _opts);
|
||||
var back = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _opts);
|
||||
|
||||
back.ShouldNotBeNull();
|
||||
back.ConnectTimeout.ShouldBeNull();
|
||||
back.CallTimeout.ShouldBeNull();
|
||||
back.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
back.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
back.UseTls.ShouldBeFalse();
|
||||
back.ServerCertThumbprint.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_DropsUnknownFields()
|
||||
{
|
||||
var jsonWithExtra = """
|
||||
{
|
||||
"unknownField": "old-value",
|
||||
"host": "historian.zb.local",
|
||||
"port": 32569,
|
||||
"sharedSecret": "s3cr3t",
|
||||
"probeTimeoutSeconds": 20
|
||||
}
|
||||
""";
|
||||
|
||||
var optsWithSkip = new JsonSerializerOptions(_opts)
|
||||
{
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
var back = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(jsonWithExtra, optsWithSkip);
|
||||
back.ShouldNotBeNull();
|
||||
back.ProbeTimeoutSeconds.ShouldBe(20);
|
||||
back.Host.ShouldBe("historian.zb.local");
|
||||
back.Port.ShouldBe(32569);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormModel_RoundTrip_PreservesAllFields()
|
||||
{
|
||||
// Construct a record with non-default values for every property and verify
|
||||
// that WonderwareHistorianClientFormModel.FromRecord → ToRecord is lossless.
|
||||
var original = new WonderwareHistorianClientOptions(
|
||||
Host: "historian-prod.zb.local",
|
||||
Port: 32570,
|
||||
SharedSecret: "sup3rs3cr3t",
|
||||
PeerName: "OtOpcUa-Redundant",
|
||||
ConnectTimeout: TimeSpan.FromSeconds(18),
|
||||
CallTimeout: TimeSpan.FromSeconds(45))
|
||||
{
|
||||
ProbeTimeoutSeconds = 30,
|
||||
UseTls = true,
|
||||
ServerCertThumbprint = "0011223344556677889AABBCCDDEEFF001122334",
|
||||
};
|
||||
|
||||
var form = HistorianWonderwareDriverPage.WonderwareHistorianClientFormModel.FromRecord(original);
|
||||
var result = form.ToRecord();
|
||||
|
||||
result.Host.ShouldBe("historian-prod.zb.local");
|
||||
result.Port.ShouldBe(32570);
|
||||
result.SharedSecret.ShouldBe("sup3rs3cr3t");
|
||||
result.PeerName.ShouldBe("OtOpcUa-Redundant");
|
||||
result.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18));
|
||||
result.CallTimeout.ShouldBe(TimeSpan.FromSeconds(45));
|
||||
result.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18));
|
||||
result.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(45));
|
||||
result.ProbeTimeoutSeconds.ShouldBe(30);
|
||||
result.UseTls.ShouldBeTrue();
|
||||
result.ServerCertThumbprint.ShouldBe("0011223344556677889AABBCCDDEEFF001122334");
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
|
||||
|
||||
public sealed class HistorianWonderwareAddressBuilderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("SysTimeHour", "Cyclic", 60, "SysTimeHour?mode=Cyclic&interval=60")]
|
||||
[InlineData("ReactorTemp", "Last", 1, "ReactorTemp?mode=Last&interval=1")]
|
||||
[InlineData("FlowRate", "Delta", 30, "FlowRate?mode=Delta&interval=30")]
|
||||
public void Build_Canonical(string tag, string mode, int interval, string expected)
|
||||
=> HistorianWonderwareAddressBuilder.Build(tag, mode, interval).ShouldBe(expected);
|
||||
|
||||
/// <summary>A tag name carrying query-reserved characters is percent-encoded so the produced
|
||||
/// address stays a well-formed query string (AdminUI-005). With "A&B?C" the '&' and '?'
|
||||
/// must not be read as a query separator / start, so they are escaped.</summary>
|
||||
[Fact]
|
||||
public void Build_escapes_reserved_characters_in_tag_name()
|
||||
{
|
||||
var result = HistorianWonderwareAddressBuilder.Build("A&B?C", "Cyclic", 60);
|
||||
|
||||
// The only literal '?' is the query separator the builder inserts; the only literal '&'
|
||||
// is the one between mode and interval. The reserved characters in the name are escaped.
|
||||
result.ShouldBe("A%26B%3FC?mode=Cyclic&interval=60");
|
||||
result.IndexOf('?').ShouldBe(result.IndexOf("?mode=", System.StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class HistorianWonderwareTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = HistorianWonderwareTagConfigModel.FromJson(json);
|
||||
|
||||
m.FullName.ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_reads_FullName()
|
||||
{
|
||||
var m = HistorianWonderwareTagConfigModel.FromJson(
|
||||
"""{"FullName":"Reactor1.Temp"}""");
|
||||
|
||||
m.FullName.ShouldBe("Reactor1.Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_FullName()
|
||||
{
|
||||
var m = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" };
|
||||
|
||||
var json = m.ToJson();
|
||||
var m2 = HistorianWonderwareTagConfigModel.FromJson(json);
|
||||
|
||||
m2.FullName.ShouldBe("Reactor1.Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_PascalCase_FullName()
|
||||
{
|
||||
var m = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" };
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
// FullName is the composer/walker contract key — PascalCase, case-sensitive.
|
||||
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
||||
json.ShouldNotContain("\"fullName\"", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = HistorianWonderwareTagConfigModel
|
||||
.FromJson("""{"FullName":"Reactor1.Temp","deadband":0.5}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("deadband");
|
||||
json.ShouldContain("0.5");
|
||||
// and the exposed field still round-trips
|
||||
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_TagModal_merged_history_keys()
|
||||
{
|
||||
// The TagModal-merge seam writes isHistorized/historianTagname at the TagConfig root; this model
|
||||
// does NOT model them, so they must survive a load→save untouched as preserved unknown keys.
|
||||
var json = HistorianWonderwareTagConfigModel
|
||||
.FromJson("""{"FullName":"Reactor1.Temp","isHistorized":true,"historianTagname":"Reactor1.Temp.Override"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("\"isHistorized\":true");
|
||||
json.ShouldContain("\"historianTagname\":\"Reactor1.Temp.Override\"");
|
||||
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_trims_FullName()
|
||||
{
|
||||
var json = new HistorianWonderwareTagConfigModel { FullName = " Reactor1.Temp " }.ToJson();
|
||||
|
||||
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_error_when_FullName_blank()
|
||||
{
|
||||
new HistorianWonderwareTagConfigModel().Validate().ShouldNotBeNullOrEmpty();
|
||||
new HistorianWonderwareTagConfigModel { FullName = " " }.Validate().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_FullName_present()
|
||||
{
|
||||
new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" }.Validate().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ public sealed class TagConfigValidatorTests
|
||||
[InlineData("TwinCat")]
|
||||
[InlineData("Focas")]
|
||||
[InlineData("OpcUaClient")]
|
||||
[InlineData("Historian.Wonderware")]
|
||||
public void Required_field_blank_is_rejected(string driverType)
|
||||
{
|
||||
TagConfigValidator.Validate(driverType, "{}").ShouldNotBeNullOrEmpty();
|
||||
@@ -42,10 +41,6 @@ public sealed class TagConfigValidatorTests
|
||||
public void OpcUaClient_with_full_name_is_valid()
|
||||
=> TagConfigValidator.Validate("OpcUaClient", """{"FullName":"ns=2;s=Line3.Temp"}""").ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void HistorianWonderware_with_full_name_is_valid()
|
||||
=> TagConfigValidator.Validate("Historian.Wonderware", """{"FullName":"Reactor1.Temp"}""").ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void S7_with_address_is_valid()
|
||||
=> TagConfigValidator.Validate("S7", """{"address":"DB1.DBW0"}""").ShouldBeNull();
|
||||
|
||||
@@ -29,7 +29,6 @@ public sealed class DriverProbeRegistrationTests
|
||||
"Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively
|
||||
"OpcUaClient",
|
||||
"GalaxyMxGateway",
|
||||
"Historian.Wonderware",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
|
||||
+9
-36
@@ -112,81 +112,54 @@ public sealed class AlarmHistorianRegistrationTests
|
||||
opts.DeadLetterRetentionDays.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_warns_on_empty_shared_secret_when_enabled()
|
||||
{
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "", DatabasePath = "/var/h.db" };
|
||||
opts.Validate().ShouldContain(w => w.Contains("SharedSecret"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_warns_on_relative_database_path_when_enabled()
|
||||
{
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "alarm-historian.db" };
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db" };
|
||||
opts.Validate().ShouldContain(w => w.Contains("DatabasePath"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_is_silent_when_correctly_configured()
|
||||
{
|
||||
new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db" }.Validate().ShouldBeEmpty();
|
||||
new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db" }.Validate().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_is_silent_when_disabled()
|
||||
{
|
||||
new AlarmHistorianOptions { Enabled = false, SharedSecret = "" }.Validate().ShouldBeEmpty();
|
||||
new AlarmHistorianOptions { Enabled = false }.Validate().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_warns_on_non_positive_drain_interval()
|
||||
{
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", DrainIntervalSeconds = 0 };
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", DrainIntervalSeconds = 0 };
|
||||
opts.Validate().ShouldContain(w => w.Contains("DrainIntervalSeconds"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_warns_on_non_positive_capacity()
|
||||
{
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", Capacity = 0 };
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", Capacity = 0 };
|
||||
opts.Validate().ShouldContain(w => w.Contains("Capacity"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_warns_on_non_positive_retention()
|
||||
{
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", DeadLetterRetentionDays = 0 };
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", DeadLetterRetentionDays = 0 };
|
||||
opts.Validate().ShouldContain(w => w.Contains("DeadLetterRetentionDays"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_accumulates_multiple_warnings()
|
||||
{
|
||||
// relative path + empty secret ⇒ both warnings, not short-circuited on the first.
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "", DatabasePath = "alarm-historian.db" };
|
||||
// relative path + non-positive drain interval ⇒ both warnings, not short-circuited on the first.
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db", DrainIntervalSeconds = 0 };
|
||||
var warnings = opts.Validate();
|
||||
warnings.ShouldContain(w => w.Contains("SharedSecret"));
|
||||
warnings.ShouldContain(w => w.Contains("DatabasePath"));
|
||||
warnings.ShouldContain(w => w.Contains("DrainIntervalSeconds"));
|
||||
warnings.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Section_binds_tcp_host_port_tls_fields()
|
||||
{
|
||||
var config = ConfigFrom(new Dictionary<string, string?>
|
||||
{
|
||||
["AlarmHistorian:Host"] = "historian.example.com",
|
||||
["AlarmHistorian:Port"] = "12345",
|
||||
["AlarmHistorian:UseTls"] = "true",
|
||||
["AlarmHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF",
|
||||
});
|
||||
|
||||
var opts = config.GetSection(AlarmHistorianOptions.SectionName).Get<AlarmHistorianOptions>();
|
||||
|
||||
opts.ShouldNotBeNull();
|
||||
opts.Host.ShouldBe("historian.example.com");
|
||||
opts.Port.ShouldBe(12345);
|
||||
opts.UseTls.ShouldBeTrue();
|
||||
opts.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user