diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/PipeChannel.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/PipeChannel.cs
new file mode 100644
index 0000000..c58a48c
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/PipeChannel.cs
@@ -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;
+
+///
+/// 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.
+///
+///
+/// Single in-flight call at a time — the sidecar's pipe protocol is request/response
+/// over a single bidirectional stream, so multiple concurrent
+/// calls would interleave replies. A serializes them. PR 6.x
+/// can layer batching on top.
+///
+internal sealed class PipeChannel : IAsyncDisposable
+{
+ private readonly WonderwareHistorianClientOptions _options;
+ private readonly Func> _connect;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _callGate = new(1, 1);
+
+ private Stream? _stream;
+ private FrameReader? _reader;
+ private FrameWriter? _writer;
+ private bool _disposed;
+
+ ///
+ /// Default factory: connects to a real by name.
+ ///
+ public static Func> 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> 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;
+
+ ///
+ /// Connects + performs the Hello handshake. Returns when the sidecar has accepted the
+ /// hello. Throws on rejection (bad secret, version mismatch, or transport failure).
+ ///
+ 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(); }
+ }
+
+ ///
+ /// Sends one request, waits for the matching reply. On transport failure, reconnects
+ /// once and retries — broader retry policy lives in the calling layer.
+ ///
+ public async Task InvokeAsync(
+ 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(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(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false);
+ }
+ }
+ finally { _callGate.Release(); }
+ }
+
+ private async Task ExchangeAsync(
+ 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(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(ackFrame.Body);
+ if (!ack.Accepted)
+ {
+ ResetTransport();
+ throw new UnauthorizedAccessException(
+ $"Sidecar rejected Hello: {ack.RejectReason ?? ""}.");
+ }
+
+ _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;
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/QualityMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/QualityMapper.cs
new file mode 100644
index 0000000..845bba7
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/QualityMapper.cs
@@ -0,0 +1,39 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
+
+///
+/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's OpcQuality)
+/// to an OPC UA StatusCode uint. Byte-identical port of the sidecar's
+/// HistorianQualityMapper.Map — 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.
+///
+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,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Contracts.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Contracts.cs
new file mode 100644
index 0000000..70089b2
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Contracts.cs
@@ -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).
+// ============================================================================
+
+/// Single historical data point. Quality is the raw OPC DA byte; client maps to OPC UA StatusCode.
+[MessagePackObject]
+public sealed class HistorianSampleDto
+{
+ /// MessagePack-serialized value bytes. Client deserializes per the tag's mx_data_type.
+ [Key(0)] public byte[]? ValueBytes { get; set; }
+
+ /// Raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality).
+ [Key(1)] public byte Quality { get; set; }
+
+ [Key(2)] public long TimestampUtcTicks { get; set; }
+}
+
+/// Aggregate bucket; Value is null when the aggregate is unavailable for the bucket.
+[MessagePackObject]
+public sealed class HistorianAggregateSampleDto
+{
+ [Key(0)] public double? Value { get; set; }
+ [Key(1)] public long TimestampUtcTicks { get; set; }
+}
+
+/// Historian event row.
+[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; }
+}
+
+/// Alarm event to persist back into the historian event store.
+[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();
+}
+
+// ===== 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; }
+
+ ///
+ /// Wonderware AnalogSummary column name: "Average", "Minimum", "Maximum", "ValueCount".
+ /// The .NET 10 client maps OPC UA aggregate enum → column.
+ ///
+ [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();
+}
+
+// ===== 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();
+ [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();
+}
+
+// ===== 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();
+}
+
+// ===== Write Alarm Events =====
+
+[MessagePackObject]
+public sealed class WriteAlarmEventsRequest
+{
+ [Key(0)] public AlarmHistorianEventDto[] Events { get; set; } = Array.Empty();
+ [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; }
+
+ /// Per-event success flag, parallel to .
+ [Key(3)] public bool[] PerEventOk { get; set; } = Array.Empty();
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameReader.cs
new file mode 100644
index 0000000..4992651
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameReader.cs
@@ -0,0 +1,63 @@
+using MessagePack;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
+
+///
+/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
+/// from multiple threads against the same instance. Mirror of
+/// the sidecar's FrameReader; kept byte-identical so the wire protocol stays stable.
+///
+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(byte[] body) => MessagePackSerializer.Deserialize(body);
+
+ private async Task 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();
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameWriter.cs
new file mode 100644
index 0000000..420d146
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameWriter.cs
@@ -0,0 +1,51 @@
+using MessagePack;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
+
+///
+/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
+/// . Byte-identical mirror of the sidecar's FrameWriter.
+///
+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(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();
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Framing.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Framing.cs
new file mode 100644
index 0000000..7b56bb2
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Framing.cs
@@ -0,0 +1,48 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
+
+///
+/// Length-prefixed framing constants for the Wonderware historian sidecar pipe protocol.
+/// Each frame on the wire is:
+/// [4-byte big-endian length][1-byte message kind][MessagePack body].
+/// Length is the body size only; the kind byte is not part of the prefixed length.
+///
+///
+/// Byte-identical mirror of the sidecar's Driver.Historian.Wonderware.Ipc.Framing.
+/// 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.
+///
+public static class Framing
+{
+ public const int LengthPrefixSize = 4;
+ public const int KindByteSize = 1;
+
+ /// 16 MiB cap protects the receiver from a hostile or buggy peer.
+ public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
+}
+
+///
+/// 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.
+///
+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,
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Hello.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Hello.cs
new file mode 100644
index 0000000..4bc8e13
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Hello.cs
@@ -0,0 +1,33 @@
+using MessagePack;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
+
+///
+/// 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 Hello contract.
+///
+[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;
+
+ /// Per-process shared secret — verified against the value the supervisor passed at spawn time.
+ [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;
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs
new file mode 100644
index 0000000..916ec64
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs
@@ -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;
+
+///
+/// .NET 10 client for the Wonderware historian sidecar (PR 3.3 protocol). Implements both
+/// (read paths consumed by
+/// Server.History.IHistoryRouter) and
+/// (alarm-event drain consumed by Core.AlarmHistorian.SqliteStoreAndForwardSink).
+///
+///
+/// The client owns a single 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.
+///
+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;
+
+ ///
+ /// Creates a client over a real named-pipe connection. Tests that need an in-process
+ /// duplex pair use the factory.
+ ///
+ public WonderwareHistorianClient(WonderwareHistorianClientOptions options, ILogger? logger = null)
+ : this(options, ct => PipeChannel.DefaultNamedPipeConnectFactory(options, ct), logger)
+ {
+ }
+
+ /// Test seam — inject an arbitrary connect callback.
+ public static WonderwareHistorianClient ForTests(
+ WonderwareHistorianClientOptions options,
+ Func> connect,
+ ILogger? logger = null)
+ => new(options, connect, logger);
+
+ private WonderwareHistorianClient(
+ WonderwareHistorianClientOptions options,
+ Func> connect,
+ ILogger? logger)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ var log = (ILogger?)logger ?? NullLogger.Instance;
+ _channel = new PipeChannel(options, connect, log);
+ }
+
+ // ===== IHistorianDataSource =====
+
+ public async Task 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(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 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(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 ReadAtTimeAsync(
+ string fullReference, IReadOnlyList 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(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 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(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> WriteBatchAsync(
+ IReadOnlyList 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(
+ 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 Invoke(
+ MessageKind requestKind, MessageKind expectedReplyKind, TRequest request, CancellationToken ct)
+ where TReply : class
+ {
+ Interlocked.Increment(ref _totalQueries);
+ try
+ {
+ var reply = await _channel.InvokeAsync(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 ?? ""}.");
+ }
+ }
+
+ 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 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