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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user