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:
Joseph Doherty
2026-06-26 19:25:21 -04:00
parent 245db98f5e
commit 0b4b2e4cfd
84 changed files with 37 additions and 9345 deletions
-5
View File
@@ -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/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.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.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.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/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.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" /> <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/"> <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.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.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.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.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.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" /> <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; namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary> /// <summary>
/// The historian sink contract — where qualifying alarm events land. Phase 7 plan /// The historian sink contract — where qualifying alarm events land. Ingestion routes
/// decision #17: ingestion routes through the Wonderware historian sidecar /// through the HistorianGateway alarm writer (the gateway's <c>SendEvent</c> gRPC path)
/// (<c>WonderwareHistorianClient</c>), which owns the <c>aahClientManaged</c> DLLs /// behind the durable store-and-forward queue. Tests use an in-memory fake; production uses
/// and 32-bit constraints. Tests use an in-memory fake; production uses
/// <see cref="SqliteStoreAndForwardSink"/>. /// <see cref="SqliteStoreAndForwardSink"/>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -80,7 +79,7 @@ public enum HistorianDrainState
BackingOff, 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 public enum HistorianWriteOutcome
{ {
/// <summary>Successfully persisted to the historian. Remove from queue.</summary> /// <summary>Successfully persisted to the historian. Remove from queue.</summary>
@@ -91,7 +90,7 @@ public enum HistorianWriteOutcome
PermanentFail, 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 public interface IAlarmHistorianWriter
{ {
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary> /// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
@@ -261,7 +261,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
/// requested timestamp, in request order. Returned samples are indexed by timestamp ticks; /// 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 /// 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 /// (<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> /// </summary>
private static IReadOnlyList<DataValueSnapshot> AlignAtTimeSnapshots( private static IReadOnlyList<DataValueSnapshot> AlignAtTimeSnapshots(
IReadOnlyList<DateTime> timestampsUtc, IReadOnlyList<HistorianSample> samples) IReadOnlyList<DateTime> timestampsUtc, IReadOnlyList<HistorianSample> samples)
@@ -5,11 +5,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// uint. /// uint.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Byte-identical port of /// Byte-identical port of the historical Wonderware client's <c>QualityMapper.Map</c> (itself a
/// <c>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal.QualityMapper.Map</c> (itself a /// port of the original historian sidecar's <c>HistorianQualityMapper.Map</c>). Those projects have
/// port of the sidecar's <c>HistorianQualityMapper.Map</c>). The table is duplicated rather than /// since been retired; this is now the canonical quality table. Parity with the OPC DA quality
/// shared because the projects do not share an assembly; a change to the quality table must be /// semantics is pinned by the per-byte tests.
/// applied in every copy and is kept in parity by the per-byte tests.
/// </remarks> /// </remarks>
internal static class GatewayQualityMapper internal static class GatewayQualityMapper
{ {
@@ -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: 165535.</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>"} }}";
}
@@ -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>
@@ -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;
}
}
@@ -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;
}
@@ -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();
}
@@ -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);
}
}
}
@@ -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>
@@ -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;
}
}
}
@@ -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,
}
}
@@ -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; }
}
}
}
@@ -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; }
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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; }
}
}
@@ -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();
}
}
@@ -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 &gt;= 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; }
}
}
@@ -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);
}
}
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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 &amp; 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),
};
}
}
@@ -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>
@@ -60,7 +60,6 @@ else
["Focas"] = typeof(FocasDriverPage), ["Focas"] = typeof(FocasDriverPage),
["OpcUaClient"] = typeof(OpcUaClientDriverPage), ["OpcUaClient"] = typeof(OpcUaClientDriverPage),
["GalaxyMxGateway"] = typeof(GalaxyDriverPage), ["GalaxyMxGateway"] = typeof(GalaxyDriverPage),
["Historian.Wonderware"] = typeof(HistorianWonderwareDriverPage),
}; };
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -45,6 +45,5 @@
new DriverTypeEntry("Focas", "focas", "[FOC]", "Fanuc CNC via FOCAS library."), new DriverTypeEntry("Focas", "focas", "[FOC]", "Fanuc CNC via FOCAS library."),
new DriverTypeEntry("OpcUaClient", "opcuaclient", "[OPC]", "Upstream OPC UA server pull."), new DriverTypeEntry("OpcUaClient", "opcuaclient", "[OPC]", "Upstream OPC UA server pull."),
new DriverTypeEntry("Galaxy", "galaxy", "[Gx]", "AVEVA System Platform (Wonderware) via mxaccessgw."), new DriverTypeEntry("Galaxy", "galaxy", "[Gx]", "AVEVA System Platform (Wonderware) via mxaccessgw."),
new DriverTypeEntry("Historian.Wonderware", "historianwonderware","[Hx]", "Wonderware Historian replay/cyclic reads."),
}; };
} }
@@ -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") &middot; <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&hellip;</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,
};
}
}
@@ -36,7 +36,6 @@
<option value="Focas">Focas</option> <option value="Focas">Focas</option>
<option value="OpcUaClient">OpcUaClient</option> <option value="OpcUaClient">OpcUaClient</option>
<option value="GalaxyMxGateway">Galaxy</option> <option value="GalaxyMxGateway">Galaxy</option>
<option value="Historian.Wonderware">Historian.Wonderware</option>
</InputSelect> </InputSelect>
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div> <div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
</div> </div>
@@ -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&amp;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}";
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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), ["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), ["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
["OpcUaClient"] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), ["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> /// <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(), ["TwinCat"] = j => TwinCATTagConfigModel.FromJson(j).Validate(),
["Focas"] = j => FocasTagConfigModel.FromJson(j).Validate(), ["Focas"] = j => FocasTagConfigModel.FromJson(j).Validate(),
["OpcUaClient"] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(), ["OpcUaClient"] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
["Historian.Wonderware"] = j => HistorianWonderwareTagConfigModel.FromJson(j).Validate(),
}; };
/// <summary> /// <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.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.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.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.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"/> <ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
</ItemGroup> </ItemGroup>
@@ -15,7 +15,6 @@ using TwinCATProbe = Driver.TwinCAT.TwinCATDriverProbe;
using FocasProbe = Driver.FOCAS.FocasDriverProbe; using FocasProbe = Driver.FOCAS.FocasDriverProbe;
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe; using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe; using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
using HistorianProbe = Driver.Historian.Wonderware.Client.WonderwareHistorianDriverProbe;
/// <summary> /// <summary>
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c> /// 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, FocasProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>()); services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>()); services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, HistorianProbe>());
return services; return services;
} }
+2 -3
View File
@@ -99,9 +99,8 @@ if (hasDriver)
// overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins) // overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins)
// with a SqliteStoreAndForwardSink draining to the gateway SendEvent writer. The alarm-write path // 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 // 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. // from the ServerHistorian section. AlarmHistorianOptions supplies only the Enabled gate + the
// AlarmHistorianOptions still supplies the Enabled gate + the SQLite store-and-forward knobs // SQLite store-and-forward knobs (consumed inside AddAlarmHistorian) — it carries no connection fields.
// (consumed inside AddAlarmHistorian), so its Wonderware connection fields are intentionally unused.
// Runtime owns the gating + Sqlite construction; the Host supplies the concrete gateway downstream // 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 // 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 // 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) 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 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. 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 The historian read/write backend is the Historian.Gateway driver (gRPC to HistorianGateway);
net10 .Client gRPC wrapper is what production binds when the historian role is needed. --> the retired Wonderware historian sidecar projects are no longer referenced. -->
<ItemGroup> <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.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.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.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.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.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.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.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.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> /// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and /// 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: /// configured roles. Only the legacy v1 in-process <c>"Galaxy"</c> type stays Windows-only:
/// <list type="bullet"> /// the legacy MXAccess COM proxy (retired in PR 7.2; gated for any leftover DriverInstance
/// <item><c>"Galaxy"</c> — legacy MXAccess COM proxy (retired in PR 7.2; gated for any /// rows that still reference the old type name).
/// 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>
/// The v2 <c>"GalaxyMxGateway"</c> driver talks gRPC to an external mxaccessgw process, /// 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. /// so it runs on any platform .NET 10 supports — Linux containers included. Not stubbed.
/// </summary> /// </summary>
@@ -247,7 +243,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="roles">Operational roles configured for this instance.</param> /// <param name="roles">Operational roles configured for this instance.</param>
public static bool ShouldStub(string driverType, IEnumerable<string> roles) 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 (!OperatingSystem.IsWindows() && isWindowsOnly) return true;
if (roles.Contains("dev") && isWindowsOnly) return true; if (roles.Contains("dev") && isWindowsOnly) return true;
return false; return false;
@@ -8,8 +8,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// Binds the <c>AlarmHistorian</c> configuration section that gates the durable /// Binds the <c>AlarmHistorian</c> configuration section that gates the durable
/// store-and-forward alarm sink. When <see cref="Enabled"/> is <c>true</c>, /// store-and-forward alarm sink. When <see cref="Enabled"/> is <c>true</c>,
/// <c>AddAlarmHistorian</c> registers a <c>SqliteStoreAndForwardSink</c> (draining to the /// <c>AddAlarmHistorian</c> registers a <c>SqliteStoreAndForwardSink</c> (draining to the
/// Wonderware TCP writer supplied by the Host) in place of the /// gateway alarm writer supplied by the Host) in place of the
/// <c>NullAlarmHistorianSink</c> default; otherwise the Null default survives. /// <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> /// </summary>
public sealed class AlarmHistorianOptions 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> /// <summary>Filesystem path to the local SQLite store-and-forward queue database.</summary>
public string DatabasePath { get; init; } = "alarm-historian.db"; 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> /// <summary>Maximum number of queued rows the drain worker forwards in a single batch.</summary>
public int BatchSize { get; init; } = 100; public int BatchSize { get; init; } = 100;
@@ -64,8 +51,6 @@ public sealed class AlarmHistorianOptions
{ {
var warnings = new List<string>(); var warnings = new List<string>();
if (!Enabled) return warnings; 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)) 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."); 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) if (DrainIntervalSeconds <= 0)
@@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
/// <summary> /// <summary>
/// Registers shared runtime services. Currently binds <see cref="IAlarmHistorianSink"/> /// Registers shared runtime services. Currently binds <see cref="IAlarmHistorianSink"/>
/// to <see cref="NullAlarmHistorianSink"/> as the default; production deployments /// 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>. /// Call this BEFORE <c>AddAkka</c>.
/// </summary> /// </summary>
/// <param name="services">The service collection to register with.</param> /// <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 /// <c>Enabled=true</c>, registers a <see cref="SqliteStoreAndForwardSink"/> (draining via the
/// <paramref name="writerFactory"/>-supplied writer) as the <see cref="IAlarmHistorianSink"/>, /// <paramref name="writerFactory"/>-supplied writer) as the <see cref="IAlarmHistorianSink"/>,
/// overriding the <see cref="NullAlarmHistorianSink"/> default. Otherwise a no-op (Null stays). /// 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 /// The writer is injected so the durable downstream (the HistorianGateway alarm writer) can be
/// by the Host, which is the only project that references it. /// supplied by the Host, which is the only project that references it.
/// </summary> /// </summary>
/// <param name="services">The service collection to register with.</param> /// <param name="services">The service collection to register with.</param>
/// <param name="configuration">The configuration carrying the <c>AlarmHistorian</c> section.</param> /// <param name="configuration">The configuration carrying the <c>AlarmHistorian</c> section.</param>
/// <param name="writerFactory"> /// <param name="writerFactory">
/// Factory the Host supplies to build the concrete <see cref="IAlarmHistorianWriter"/> /// 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> /// </param>
/// <returns>The same <paramref name="services"/> instance for chaining.</returns> /// <returns>The same <paramref name="services"/> instance for chaining.</returns>
public static IServiceCollection AddAlarmHistorian( public static IServiceCollection AddAlarmHistorian(
@@ -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);
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
}
@@ -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>
@@ -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));
}
}
}
}
@@ -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);
}
}
}
@@ -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}");
}
}
}
@@ -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);
}
}
@@ -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 });
}
}
@@ -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");
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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}");
}
}
}
@@ -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() { }
}
}
}
@@ -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(); }
}
}
@@ -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();
}
}
}
@@ -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");
}
}
@@ -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() var allDriverPages = typeof(S7DriverPage).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract) .Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract)
.ToList(); .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, DriverPageTypes.Count.ShouldBe(allDriverPages.Count,
"every *DriverPage must declare a _jsonOpts config serializer so the string-enum converter guard covers it"); "every *DriverPage must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
} }
@@ -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");
}
}
@@ -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&amp;B?C" the '&amp;' 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));
}
}
@@ -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("TwinCat")]
[InlineData("Focas")] [InlineData("Focas")]
[InlineData("OpcUaClient")] [InlineData("OpcUaClient")]
[InlineData("Historian.Wonderware")]
public void Required_field_blank_is_rejected(string driverType) public void Required_field_blank_is_rejected(string driverType)
{ {
TagConfigValidator.Validate(driverType, "{}").ShouldNotBeNullOrEmpty(); TagConfigValidator.Validate(driverType, "{}").ShouldNotBeNullOrEmpty();
@@ -42,10 +41,6 @@ public sealed class TagConfigValidatorTests
public void OpcUaClient_with_full_name_is_valid() public void OpcUaClient_with_full_name_is_valid()
=> TagConfigValidator.Validate("OpcUaClient", """{"FullName":"ns=2;s=Line3.Temp"}""").ShouldBeNull(); => 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] [Fact]
public void S7_with_address_is_valid() public void S7_with_address_is_valid()
=> TagConfigValidator.Validate("S7", """{"address":"DB1.DBW0"}""").ShouldBeNull(); => 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 "Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively
"OpcUaClient", "OpcUaClient",
"GalaxyMxGateway", "GalaxyMxGateway",
"Historian.Wonderware",
]; ];
[Fact] [Fact]
@@ -112,81 +112,54 @@ public sealed class AlarmHistorianRegistrationTests
opts.DeadLetterRetentionDays.ShouldBe(7); 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] [Fact]
public void Validate_warns_on_relative_database_path_when_enabled() 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")); opts.Validate().ShouldContain(w => w.Contains("DatabasePath"));
} }
[Fact] [Fact]
public void Validate_is_silent_when_correctly_configured() 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] [Fact]
public void Validate_is_silent_when_disabled() public void Validate_is_silent_when_disabled()
{ {
new AlarmHistorianOptions { Enabled = false, SharedSecret = "" }.Validate().ShouldBeEmpty(); new AlarmHistorianOptions { Enabled = false }.Validate().ShouldBeEmpty();
} }
[Fact] [Fact]
public void Validate_warns_on_non_positive_drain_interval() 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")); opts.Validate().ShouldContain(w => w.Contains("DrainIntervalSeconds"));
} }
[Fact] [Fact]
public void Validate_warns_on_non_positive_capacity() 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")); opts.Validate().ShouldContain(w => w.Contains("Capacity"));
} }
[Fact] [Fact]
public void Validate_warns_on_non_positive_retention() 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")); opts.Validate().ShouldContain(w => w.Contains("DeadLetterRetentionDays"));
} }
[Fact] [Fact]
public void Validate_accumulates_multiple_warnings() public void Validate_accumulates_multiple_warnings()
{ {
// relative path + empty secret ⇒ both warnings, not short-circuited on the first. // relative path + non-positive drain interval ⇒ both warnings, not short-circuited on the first.
var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "", DatabasePath = "alarm-historian.db" }; var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db", DrainIntervalSeconds = 0 };
var warnings = opts.Validate(); var warnings = opts.Validate();
warnings.ShouldContain(w => w.Contains("SharedSecret"));
warnings.ShouldContain(w => w.Contains("DatabasePath")); warnings.ShouldContain(w => w.Contains("DatabasePath"));
warnings.ShouldContain(w => w.Contains("DrainIntervalSeconds"));
warnings.Count.ShouldBeGreaterThanOrEqualTo(2); 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");
}
} }