PR 3.4 — Wonderware historian sidecar .NET 10 client
New project Driver.Historian.Wonderware.Client (net10 x64) implements both Core.Abstractions.IHistorianDataSource (read paths consumed by the server's IHistoryRouter) and Core.AlarmHistorian.IAlarmHistorianWriter (alarm-event drain consumed by SqliteStoreAndForwardSink) against the sidecar's PR 3.3 pipe protocol. Wire-format files (Framing/MessageKind, Hello, Contracts, FrameReader, FrameWriter) are byte-identical mirrors of the sidecar's net48 originals — the sidecar can't be referenced as a ProjectReference because of the runtime/bitness gap, so we duplicate and pin the wire bytes via tests. PipeChannel owns one bidirectional NamedPipeClientStream + Hello handshake + serializes calls. Single in-flight at a time (semaphore); transport failures trigger one in-flight reconnect-and-retry before propagating. Connect is abstracted behind a Func<CancellationToken, Task<Stream>> so tests inject in-process pipes. WonderwareHistorianClient maps: - HistorianSampleDto.Quality (raw OPC DA byte) → OPC UA StatusCode uint via QualityMapper (port of HistorianQualityMapper from sidecar). - HistorianAggregateSampleDto.Value=null → BadNoData (0x800E0000). - WriteAlarmEventsReply.PerEventOk[i]=true → Ack, false → RetryPlease. Whole-call failure or transport exception → RetryPlease for every event in the batch (drain worker handles backoff). - AlarmHistorianEvent → AlarmHistorianEventDto with severity bucketed via AlarmSeverity-to-ushort mapping (Low=250, Medium=500, High=700, Crit=900). GetHealthSnapshot tracks transport success + sidecar-reported failure separately; ConsecutiveFailures rises on operation-level errors, not just transport drops. 10 round-trip tests via FakeSidecarServer (in-process net10 fake using the client's own framing): byte→uint quality mapping, null-bucket BadNoData, at-time order preservation, event-field round-trip, sidecar error surfacing, WriteBatch per-event status, whole-call retry-please mapping, Hello shared-secret rejection, transport-drop reconnect-and-retry, health snapshot counters. PR 3.W will register this client as IHistorianDataSource + IAlarmHistorianWriter in OpcUaServerService DI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
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 connection to the Wonderware historian sidecar pipe. 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 pipe 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 PipeChannel : 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 factory: connects to a real <see cref="NamedPipeClientStream"/> by name.
|
||||||
|
/// </summary>
|
||||||
|
public static Func<WonderwareHistorianClientOptions, CancellationToken, Task<Stream>> DefaultNamedPipeConnectFactory =
|
||||||
|
async (opts, ct) =>
|
||||||
|
{
|
||||||
|
var pipe = new NamedPipeClientStream(
|
||||||
|
serverName: ".",
|
||||||
|
pipeName: opts.PipeName,
|
||||||
|
direction: PipeDirection.InOut,
|
||||||
|
options: PipeOptions.Asynchronous);
|
||||||
|
|
||||||
|
await pipe.ConnectAsync((int)opts.EffectiveConnectTimeout.TotalMilliseconds, ct).ConfigureAwait(false);
|
||||||
|
return pipe;
|
||||||
|
};
|
||||||
|
|
||||||
|
public PipeChannel(
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
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>
|
||||||
|
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 pipe 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 pipe 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 pipe 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 pipe connected — host={Host}", ack.HostName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetTransport()
|
||||||
|
{
|
||||||
|
_writer?.Dispose();
|
||||||
|
_reader?.Dispose();
|
||||||
|
_stream?.Dispose();
|
||||||
|
_writer = null;
|
||||||
|
_reader = null;
|
||||||
|
_stream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed) return ValueTask.CompletedTask;
|
||||||
|
_disposed = true;
|
||||||
|
ResetTransport();
|
||||||
|
_callGate.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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 x86 and the client is .NET 10 x64.
|
||||||
|
/// </summary>
|
||||||
|
internal static class QualityMapper
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
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 x86; 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; }
|
||||||
|
|
||||||
|
[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
|
||||||
|
{
|
||||||
|
[Key(0)] public double? Value { get; set; }
|
||||||
|
[Key(1)] public long TimestampUtcTicks { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Historian event row.</summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class HistorianEventDto
|
||||||
|
{
|
||||||
|
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public string? Source { get; set; }
|
||||||
|
[Key(2)] public long EventTimeUtcTicks { get; set; }
|
||||||
|
[Key(3)] public long ReceivedTimeUtcTicks { get; set; }
|
||||||
|
[Key(4)] public string? DisplayText { get; set; }
|
||||||
|
[Key(5)] public ushort Severity { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Alarm event to persist back into the historian event store.</summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class AlarmHistorianEventDto
|
||||||
|
{
|
||||||
|
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public string SourceName { get; set; } = string.Empty;
|
||||||
|
[Key(2)] public string? ConditionId { get; set; }
|
||||||
|
[Key(3)] public string AlarmType { get; set; } = string.Empty;
|
||||||
|
[Key(4)] public string? Message { get; set; }
|
||||||
|
[Key(5)] public ushort Severity { get; set; }
|
||||||
|
[Key(6)] public long EventTimeUtcTicks { get; set; }
|
||||||
|
[Key(7)] public string? AckComment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Read Raw =====
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ReadRawRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public string TagName { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public long StartUtcTicks { get; set; }
|
||||||
|
[Key(2)] public long EndUtcTicks { get; set; }
|
||||||
|
[Key(3)] public int MaxValues { get; set; }
|
||||||
|
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ReadRawReply
|
||||||
|
{
|
||||||
|
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public bool Success { get; set; }
|
||||||
|
[Key(2)] public string? Error { get; set; }
|
||||||
|
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Read Processed =====
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ReadProcessedRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public string TagName { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public long StartUtcTicks { get; set; }
|
||||||
|
[Key(2)] public long EndUtcTicks { get; set; }
|
||||||
|
[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;
|
||||||
|
[Key(5)] public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ReadProcessedReply
|
||||||
|
{
|
||||||
|
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public bool Success { get; set; }
|
||||||
|
[Key(2)] public string? Error { get; set; }
|
||||||
|
[Key(3)] public HistorianAggregateSampleDto[] Buckets { get; set; } = Array.Empty<HistorianAggregateSampleDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Read At-Time =====
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ReadAtTimeRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public string TagName { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public long[] TimestampsUtcTicks { get; set; } = Array.Empty<long>();
|
||||||
|
[Key(2)] public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ReadAtTimeReply
|
||||||
|
{
|
||||||
|
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public bool Success { get; set; }
|
||||||
|
[Key(2)] public string? Error { get; set; }
|
||||||
|
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Read Events =====
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ReadEventsRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public string? SourceName { get; set; }
|
||||||
|
[Key(1)] public long StartUtcTicks { get; set; }
|
||||||
|
[Key(2)] public long EndUtcTicks { get; set; }
|
||||||
|
[Key(3)] public int MaxEvents { get; set; }
|
||||||
|
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ReadEventsReply
|
||||||
|
{
|
||||||
|
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public bool Success { get; set; }
|
||||||
|
[Key(2)] public string? Error { get; set; }
|
||||||
|
[Key(3)] public HistorianEventDto[] Events { get; set; } = Array.Empty<HistorianEventDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Write Alarm Events =====
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class WriteAlarmEventsRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public AlarmHistorianEventDto[] Events { get; set; } = Array.Empty<AlarmHistorianEventDto>();
|
||||||
|
[Key(1)] public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class WriteAlarmEventsReply
|
||||||
|
{
|
||||||
|
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public bool Success { get; set; }
|
||||||
|
[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>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public FrameReader(Stream stream, bool leaveOpen = false)
|
||||||
|
{
|
||||||
|
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||||
|
_leaveOpen = leaveOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_leaveOpen) _stream.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public FrameWriter(Stream stream, bool leaveOpen = false)
|
||||||
|
{
|
||||||
|
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||||
|
_leaveOpen = leaveOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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, ct).ConfigureAwait(false);
|
||||||
|
_stream.WriteByte((byte)kind);
|
||||||
|
await _stream.WriteAsync(body, ct).ConfigureAwait(false);
|
||||||
|
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally { _gate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_gate.Dispose();
|
||||||
|
if (!_leaveOpen) _stream.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 x86; this client is .NET 10 x64 — 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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
|
||||||
|
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class HelloAck
|
||||||
|
{
|
||||||
|
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
|
||||||
|
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
|
||||||
|
|
||||||
|
[Key(2)] public bool Accepted { get; set; }
|
||||||
|
[Key(3)] public string? RejectReason { get; set; }
|
||||||
|
[Key(4)] public string HostName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
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="PipeChannel"/> 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 PipeChannel _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 over a real named-pipe connection. Tests that need an in-process
|
||||||
|
/// duplex pair use the <see cref="ForTests"/> factory.
|
||||||
|
/// </summary>
|
||||||
|
public WonderwareHistorianClient(WonderwareHistorianClientOptions options, ILogger<WonderwareHistorianClient>? logger = null)
|
||||||
|
: this(options, ct => PipeChannel.DefaultNamedPipeConnectFactory(options, ct), logger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test seam — inject an arbitrary connect callback.</summary>
|
||||||
|
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 PipeChannel(options, connect, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== IHistorianDataSource =====
|
||||||
|
|
||||||
|
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 Invoke<ReadRawRequest, ReadRawReply>(MessageKind.ReadRawRequest, MessageKind.ReadRawReply, req, cancellationToken).ConfigureAwait(false);
|
||||||
|
ThrowIfFailed(reply.Success, reply.Error, "ReadRaw");
|
||||||
|
return new HistoryReadResult(ToSnapshots(reply.Samples), ContinuationPoint: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HistoryReadResult> ReadProcessedAsync(
|
||||||
|
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||||
|
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var req = new ReadProcessedRequest
|
||||||
|
{
|
||||||
|
TagName = fullReference,
|
||||||
|
StartUtcTicks = startUtc.Ticks,
|
||||||
|
EndUtcTicks = endUtc.Ticks,
|
||||||
|
IntervalMs = interval.TotalMilliseconds,
|
||||||
|
AggregateColumn = MapAggregate(aggregate),
|
||||||
|
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||||
|
};
|
||||||
|
var reply = await Invoke<ReadProcessedRequest, ReadProcessedReply>(MessageKind.ReadProcessedRequest, MessageKind.ReadProcessedReply, req, cancellationToken).ConfigureAwait(false);
|
||||||
|
ThrowIfFailed(reply.Success, reply.Error, "ReadProcessed");
|
||||||
|
return new HistoryReadResult(ToAggregateSnapshots(reply.Buckets), ContinuationPoint: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Invoke<ReadAtTimeRequest, ReadAtTimeReply>(MessageKind.ReadAtTimeRequest, MessageKind.ReadAtTimeReply, req, cancellationToken).ConfigureAwait(false);
|
||||||
|
ThrowIfFailed(reply.Success, reply.Error, "ReadAtTime");
|
||||||
|
return new HistoryReadResult(ToSnapshots(reply.Samples), ContinuationPoint: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Invoke<ReadEventsRequest, ReadEventsReply>(MessageKind.ReadEventsRequest, MessageKind.ReadEventsReply, req, cancellationToken).ConfigureAwait(false);
|
||||||
|
ThrowIfFailed(reply.Success, reply.Error, "ReadEvents");
|
||||||
|
return new HistoricalEventsResult(ToHistoricalEvents(reply.Events), ContinuationPoint: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 =====
|
||||||
|
|
||||||
|
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 Invoke<WriteAlarmEventsRequest, WriteAlarmEventsReply>(
|
||||||
|
MessageKind.WriteAlarmEventsRequest, MessageKind.WriteAlarmEventsReply, req, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-event status: PerEventOk[i] = true → Ack; false → RetryPlease.
|
||||||
|
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.
|
||||||
|
var fail = new HistorianWriteOutcome[batch.Count];
|
||||||
|
Array.Fill(fail, HistorianWriteOutcome.RetryPlease);
|
||||||
|
return fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
|
||||||
|
private async Task<TReply> Invoke<TRequest, TReply>(
|
||||||
|
MessageKind requestKind, MessageKind expectedReplyKind, TRequest request, CancellationToken ct)
|
||||||
|
where TReply : class
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _totalQueries);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var reply = await _channel.InvokeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, ct).ConfigureAwait(false);
|
||||||
|
RecordSuccess();
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
RecordFailure(ex.Message);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordSuccess()
|
||||||
|
{
|
||||||
|
lock (_healthLock)
|
||||||
|
{
|
||||||
|
_totalSuccesses++;
|
||||||
|
_consecutiveFailures = 0;
|
||||||
|
_lastSuccessUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordFailure(string message)
|
||||||
|
{
|
||||||
|
lock (_healthLock)
|
||||||
|
{
|
||||||
|
_totalFailures++;
|
||||||
|
_consecutiveFailures++;
|
||||||
|
_lastFailureUtc = DateTime.UtcNow;
|
||||||
|
_lastError = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfFailed(bool success, string? error, string op)
|
||||||
|
{
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
// Sidecar-reported failure counts as an operation failure even though the
|
||||||
|
// transport delivered a reply. The Invoke wrapper already recorded transport
|
||||||
|
// success — undo that and record the failure so the health snapshot reflects
|
||||||
|
// operation-level success rates rather than just connectivity.
|
||||||
|
ReclassifySuccessAsFailure(error);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Sidecar {op} failed: {error ?? "<no message>"}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReclassifySuccessAsFailure(string? message)
|
||||||
|
{
|
||||||
|
lock (_healthLock)
|
||||||
|
{
|
||||||
|
// Transport-level RecordSuccess happened a moment ago; reverse it.
|
||||||
|
_totalSuccesses--;
|
||||||
|
_totalFailures++;
|
||||||
|
_consecutiveFailures++;
|
||||||
|
_lastFailureUtc = DateTime.UtcNow;
|
||||||
|
_lastError = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
var value = dto.ValueBytes is null ? null : MessagePackSerializer.Deserialize<object>(dto.ValueBytes);
|
||||||
|
snapshots[i] = new DataValueSnapshot(
|
||||||
|
Value: value,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string MapAggregate(HistoryAggregateType aggregate) => aggregate switch
|
||||||
|
{
|
||||||
|
HistoryAggregateType.Average => "Average",
|
||||||
|
HistoryAggregateType.Minimum => "Minimum",
|
||||||
|
HistoryAggregateType.Maximum => "Maximum",
|
||||||
|
HistoryAggregateType.Count => "ValueCount",
|
||||||
|
HistoryAggregateType.Total => throw new NotSupportedException(
|
||||||
|
"HistoryAggregateType.Total is not supported by the Wonderware AnalogSummary query — use Average/Minimum/Maximum/Count."),
|
||||||
|
_ => throw new NotSupportedException($"Unknown HistoryAggregateType {aggregate}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => _channel.DisposeAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synchronous dispose required by <see cref="IDisposable"/> on
|
||||||
|
/// <see cref="IHistorianDataSource"/>. The underlying channel's async cleanup is
|
||||||
|
/// non-blocking (just resets transport state + disposes streams), so the
|
||||||
|
/// GetAwaiter()/GetResult() bridge is safe.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose() => _channel.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connection options for <see cref="WonderwareHistorianClient"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="PipeName">Named-pipe name the sidecar listens on (matches the sidecar's <c>OTOPCUA_HISTORIAN_PIPE</c>).</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 named-pipe connect + Hello round trip on each (re)connect.</param>
|
||||||
|
/// <param name="CallTimeout">Cap on a single read/write call once connected.</param>
|
||||||
|
/// <param name="ReconnectInitialBackoff">Backoff between the first failed reconnect attempts.</param>
|
||||||
|
/// <param name="ReconnectMaxBackoff">Upper bound on the exponential backoff between reconnects.</param>
|
||||||
|
public sealed record WonderwareHistorianClientOptions(
|
||||||
|
string PipeName,
|
||||||
|
string SharedSecret,
|
||||||
|
string PeerName = "OtOpcUa",
|
||||||
|
TimeSpan? ConnectTimeout = null,
|
||||||
|
TimeSpan? CallTimeout = null,
|
||||||
|
TimeSpan? ReconnectInitialBackoff = null,
|
||||||
|
TimeSpan? ReconnectMaxBackoff = null)
|
||||||
|
{
|
||||||
|
public TimeSpan EffectiveConnectTimeout => ConnectTimeout ?? TimeSpan.FromSeconds(10);
|
||||||
|
public TimeSpan EffectiveCallTimeout => CallTimeout ?? TimeSpan.FromSeconds(30);
|
||||||
|
public TimeSpan EffectiveReconnectInitialBackoff => ReconnectInitialBackoff ?? TimeSpan.FromMilliseconds(500);
|
||||||
|
public TimeSpan EffectiveReconnectMaxBackoff => ReconnectMaxBackoff ?? TimeSpan.FromSeconds(30);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<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" Version="2.5.187"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\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>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeSidecarServer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly string _pipeName;
|
||||||
|
private readonly string _expectedSecret;
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private Task? _loop;
|
||||||
|
|
||||||
|
public Func<ReadRawRequest, ReadRawReply> OnReadRaw { get; set; } = _ => new ReadRawReply { Success = true };
|
||||||
|
public Func<ReadProcessedRequest, ReadProcessedReply> OnReadProcessed { get; set; } = _ => new ReadProcessedReply { Success = true };
|
||||||
|
public Func<ReadAtTimeRequest, ReadAtTimeReply> OnReadAtTime { get; set; } = _ => new ReadAtTimeReply { Success = true };
|
||||||
|
public Func<ReadEventsRequest, ReadEventsReply> OnReadEvents { get; set; } = _ => new ReadEventsReply { Success = true };
|
||||||
|
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; }
|
||||||
|
|
||||||
|
public FakeSidecarServer(string pipeName, string expectedSecret)
|
||||||
|
{
|
||||||
|
_pipeName = pipeName;
|
||||||
|
_expectedSecret = expectedSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PipeName => _pipeName;
|
||||||
|
|
||||||
|
public Task StartAsync()
|
||||||
|
{
|
||||||
|
_loop = Task.Run(() => RunAsync(_cts.Token));
|
||||||
|
// Give the listener a moment to start so client connect doesn't race.
|
||||||
|
return Task.Delay(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await using var pipe = new NamedPipeServerStream(
|
||||||
|
_pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
|
||||||
|
PipeTransmissionMode.Byte, PipeOptions.Asynchronous,
|
||||||
|
inBufferSize: 64 * 1024, outBufferSize: 64 * 1024);
|
||||||
|
|
||||||
|
try { await pipe.WaitForConnectionAsync(ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new FrameReader(pipe, leaveOpen: true);
|
||||||
|
using var writer = new FrameWriter(pipe, 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
|
||||||
|
pipe.Disconnect();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var frame = await reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||||
|
if (frame is null) break;
|
||||||
|
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
if (_loop is not null)
|
||||||
|
{
|
||||||
|
try { await _loop.ConfigureAwait(false); } catch { /* ignore shutdown errors */ }
|
||||||
|
}
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
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 through a real named pipe 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 string UniquePipeName() => $"otopcua-historian-test-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
private static WonderwareHistorianClientOptions OptsFor(string pipe) => new(
|
||||||
|
PipeName: pipe,
|
||||||
|
SharedSecret: Secret,
|
||||||
|
PeerName: "test",
|
||||||
|
ConnectTimeout: TimeSpan.FromSeconds(2),
|
||||||
|
CallTimeout: TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadRawAsync_RoundTripsSamples_AndMapsQualityByteToOpcUaStatusCode()
|
||||||
|
{
|
||||||
|
var pipe = UniquePipeName();
|
||||||
|
await using var server = new FakeSidecarServer(pipe, 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 = new WonderwareHistorianClient(OptsFor(pipe));
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadProcessedAsync_NullBuckets_MapToBadNoData()
|
||||||
|
{
|
||||||
|
var pipe = UniquePipeName();
|
||||||
|
await using var server = new FakeSidecarServer(pipe, 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 = new WonderwareHistorianClient(OptsFor(pipe));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAtTimeAsync_PreservesTimestampOrder()
|
||||||
|
{
|
||||||
|
var pipe = UniquePipeName();
|
||||||
|
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(pipe, 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 = new WonderwareHistorianClient(OptsFor(pipe));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadEventsAsync_PreservesEventFields()
|
||||||
|
{
|
||||||
|
var pipe = UniquePipeName();
|
||||||
|
var eid = Guid.NewGuid().ToString("N");
|
||||||
|
await using var server = new FakeSidecarServer(pipe, 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 = new WonderwareHistorianClient(OptsFor(pipe));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadRawAsync_ServerError_ThrowsInvalidOperation()
|
||||||
|
{
|
||||||
|
var pipe = UniquePipeName();
|
||||||
|
await using var server = new FakeSidecarServer(pipe, Secret)
|
||||||
|
{
|
||||||
|
OnReadRaw = _ => new ReadRawReply { Success = false, Error = "historian unreachable" },
|
||||||
|
};
|
||||||
|
await server.StartAsync();
|
||||||
|
|
||||||
|
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
|
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||||
|
ex.Message.ShouldContain("historian unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteBatchAsync_PerEventOk_MapsToAckOrRetryPlease()
|
||||||
|
{
|
||||||
|
var pipe = UniquePipeName();
|
||||||
|
await using var server = new FakeSidecarServer(pipe, Secret)
|
||||||
|
{
|
||||||
|
OnWriteAlarmEvents = req => new WriteAlarmEventsReply
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
PerEventOk = req.Events.Select(e => e.EventId != "ev-fail").ToArray(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await server.StartAsync();
|
||||||
|
|
||||||
|
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteBatchAsync_WholeCallFailure_ReturnsRetryPleaseForEveryEvent()
|
||||||
|
{
|
||||||
|
var pipe = UniquePipeName();
|
||||||
|
await using var server = new FakeSidecarServer(pipe, Secret)
|
||||||
|
{
|
||||||
|
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "historian event-store down",
|
||||||
|
PerEventOk = new bool[2],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await server.StartAsync();
|
||||||
|
|
||||||
|
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Hello_BadSecret_ThrowsUnauthorizedAccess()
|
||||||
|
{
|
||||||
|
var pipe = UniquePipeName();
|
||||||
|
await using var server = new FakeSidecarServer(pipe, "different-secret");
|
||||||
|
await server.StartAsync();
|
||||||
|
|
||||||
|
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<UnauthorizedAccessException>(() =>
|
||||||
|
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||||
|
ex.Message.ShouldContain("shared-secret-mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconnect_AfterTransportDrop_RetriesOnce()
|
||||||
|
{
|
||||||
|
var pipe = UniquePipeName();
|
||||||
|
await using var server = new FakeSidecarServer(pipe, 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 = new WonderwareHistorianClient(OptsFor(pipe));
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHealthSnapshot_TracksSuccessAndFailureCounts()
|
||||||
|
{
|
||||||
|
var pipe = UniquePipeName();
|
||||||
|
var failNext = false;
|
||||||
|
await using var server = new FakeSidecarServer(pipe, Secret)
|
||||||
|
{
|
||||||
|
OnReadRaw = _ => failNext
|
||||||
|
? new ReadRawReply { Success = false, Error = "boom" }
|
||||||
|
: new ReadRawReply { Success = true },
|
||||||
|
};
|
||||||
|
await server.StartAsync();
|
||||||
|
|
||||||
|
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<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" Version="2.5.187"/>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user