refactor(historian-gateway): retire Wonderware historian projects (gateway is sole backend)

The HistorianGateway driver is now the sole historian read/write+alarm backend, so the
Wonderware sidecar projects are dead code. Removes the 5 Wonderware projects (driver,
.Client, .Client.Contracts, + their 2 test projects) from the solution and tree, and fully
retires the vestigial 'Historian.Wonderware' driver type (UI/probe-only; it had no driver
factory): the Host probe registration, the AdminUI driver-config surface (driver page,
tag-config editor/model/validator entry, address picker/builder, driver-type catalog +
dropdown + edit-router entries), and their tests. Prunes the now-unused Wonderware
connection fields (Host/Port/UseTls/ServerCertThumbprint/SharedSecret) from
AlarmHistorianOptions (keeping Enabled + the SQLite store-and-forward knobs) and refreshes
the stale XML docs that named Wonderware as the production backend.

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-26 19:25:21 -04:00
parent 245db98f5e
commit 0b4b2e4cfd
84 changed files with 37 additions and 9345 deletions
@@ -1,10 +1,9 @@
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// The historian sink contract — where qualifying alarm events land. Phase 7 plan
/// decision #17: ingestion routes through the Wonderware historian sidecar
/// (<c>WonderwareHistorianClient</c>), which owns the <c>aahClientManaged</c> DLLs
/// and 32-bit constraints. Tests use an in-memory fake; production uses
/// The historian sink contract — where qualifying alarm events land. Ingestion routes
/// through the HistorianGateway alarm writer (the gateway's <c>SendEvent</c> gRPC path)
/// behind the durable store-and-forward queue. Tests use an in-memory fake; production uses
/// <see cref="SqliteStoreAndForwardSink"/>.
/// </summary>
/// <remarks>
@@ -80,7 +79,7 @@ public enum HistorianDrainState
BackingOff,
}
/// <summary>Returned by the Wonderware historian sidecar per event — drain worker uses this to decide retry cadence.</summary>
/// <summary>Returned by the historian alarm writer per event — drain worker uses this to decide retry cadence.</summary>
public enum HistorianWriteOutcome
{
/// <summary>Successfully persisted to the historian. Remove from queue.</summary>
@@ -91,7 +90,7 @@ public enum HistorianWriteOutcome
PermanentFail,
}
/// <summary>What the drain worker delegates writes to — production is <c>WonderwareHistorianClient</c> (the Wonderware historian sidecar).</summary>
/// <summary>What the drain worker delegates writes to — production is the HistorianGateway alarm writer (the gateway's <c>SendEvent</c> gRPC path).</summary>
public interface IAlarmHistorianWriter
{
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
@@ -261,7 +261,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
/// requested timestamp, in request order. Returned samples are indexed by timestamp ticks;
/// any requested timestamp the gateway did not return is filled with a Bad-quality
/// (<c>0x80000000</c>) snapshot stamped at the requested time rather than positionally
/// misaligning values. Ported from <c>WonderwareHistorianClient.AlignAtTimeSnapshots</c>.
/// misaligning values. The alignment logic was ported from the now-retired Wonderware
/// client's at-time snapshot reconciliation.
/// </summary>
private static IReadOnlyList<DataValueSnapshot> AlignAtTimeSnapshots(
IReadOnlyList<DateTime> timestampsUtc, IReadOnlyList<HistorianSample> samples)
@@ -5,11 +5,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// uint.
/// </summary>
/// <remarks>
/// Byte-identical port of
/// <c>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal.QualityMapper.Map</c> (itself a
/// port of the sidecar's <c>HistorianQualityMapper.Map</c>). The table is duplicated rather than
/// shared because the projects do not share an assembly; a change to the quality table must be
/// applied in every copy and is kept in parity by the per-byte tests.
/// Byte-identical port of the historical Wonderware client's <c>QualityMapper.Map</c> (itself a
/// port of the original historian sidecar's <c>HistorianQualityMapper.Map</c>). Those projects have
/// since been retired; this is now the canonical quality table. Parity with the OPC DA quality
/// semantics is pinned by the per-byte tests.
/// </remarks>
internal static class GatewayQualityMapper
{
@@ -1,71 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
/// <summary>
/// Connection options for <c>WonderwareHistorianClient</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Retry / backoff ownership (finding 006):</b> this module performs exactly one
/// in-place transport reconnect inside <c>FrameChannel.InvokeAsync</c> with no delay,
/// and does NOT implement exponential reconnect backoff. Broader retry/backoff is the
/// caller's responsibility — the alarm drain worker
/// (<c>Core.AlarmHistorian.SqliteStoreAndForwardSink</c>) and the read-side
/// history router are expected to layer their own backoff on top.
/// </para>
/// </remarks>
/// <param name="Host">Sidecar TCP host (DNS name or IP) the client dials.</param>
/// <param name="Port">Sidecar TCP port (matches the sidecar's <c>OTOPCUA_HISTORIAN_TCP_PORT</c>). Valid range: 165535.</param>
/// <param name="SharedSecret">Per-process shared secret the sidecar will verify in the Hello frame.</param>
/// <param name="PeerName">Diagnostic peer identifier sent in Hello — typically the OtOpcUa instance id.</param>
/// <param name="ConnectTimeout">Cap on the TCP connect + Hello round trip on each (re)connect.</param>
/// <param name="CallTimeout">Cap on a single read/write call once connected.</param>
public sealed record WonderwareHistorianClientOptions(
string Host,
[Range(1, 65535)] int Port,
string SharedSecret,
string PeerName = "OtOpcUa",
TimeSpan? ConnectTimeout = null,
TimeSpan? CallTimeout = null)
{
/// <summary>Gets the effective connect timeout, using the default if not explicitly set.</summary>
public TimeSpan EffectiveConnectTimeout => ConnectTimeout ?? TimeSpan.FromSeconds(10);
/// <summary>Gets the effective call timeout, using the default if not explicitly set.</summary>
public TimeSpan EffectiveCallTimeout => CallTimeout ?? TimeSpan.FromSeconds(30);
/// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
/// </summary>
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 15s.", GroupName = "Diagnostics")]
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = 15;
/// <summary>When true, the client wraps the TCP stream in TLS before the Hello handshake.</summary>
public bool UseTls { get; init; }
/// <summary>
/// Optional SHA-1 thumbprint (40 hex characters, no spaces, case-insensitive) the client
/// pins the sidecar's TLS server cert against. When null/empty and
/// <see cref="UseTls"/> is true, the client validates the cert chain normally
/// (CA-issued cert).
/// </summary>
/// <remarks>
/// The consumer matches against <c>X509Certificate.GetCertHashString()</c> (SHA-1, 40
/// hex chars). Supplying a SHA-256 thumbprint (64 hex chars, the format shown by modern
/// tooling such as <c>certutil</c> or Windows Certificate Manager) will never match and
/// will cause the TLS handshake to fail silently. Only 40-character SHA-1 hex strings
/// are accepted.
/// </remarks>
public string? ServerCertThumbprint { get; init; }
/// <inheritdoc/>
/// <remarks>
/// Redacts <see cref="SharedSecret"/> so the value cannot appear in log output when the
/// options object is passed to a structured-logging statement.
/// </remarks>
public override string ToString() =>
$"WonderwareHistorianClientOptions {{ Host={Host}, Port={Port}, PeerName={PeerName}, UseTls={UseTls}, ServerCertThumbprint={ServerCertThumbprint ?? "<null>"} }}";
}
@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. NO ProjectReference. -->
</Project>
@@ -1,230 +0,0 @@
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using MessagePack;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
/// <summary>
/// Owns one TCP connection to the Wonderware historian sidecar. Handles the Hello
/// handshake, serializes outgoing requests + waits for the matching reply frame, and
/// reconnects on transport failure with exponential backoff.
/// </summary>
/// <remarks>
/// Single in-flight call at a time — the sidecar's TCP protocol is request/response
/// over a single bidirectional stream, so multiple concurrent <see cref="InvokeAsync"/>
/// calls would interleave replies. A <see cref="SemaphoreSlim"/> serializes them. PR 6.x
/// can layer batching on top.
/// </remarks>
internal sealed class FrameChannel : IAsyncDisposable
{
private readonly WonderwareHistorianClientOptions _options;
private readonly Func<CancellationToken, Task<Stream>> _connect;
private readonly ILogger _logger;
private readonly SemaphoreSlim _callGate = new(1, 1);
private Stream? _stream;
private FrameReader? _reader;
private FrameWriter? _writer;
private bool _disposed;
/// <summary>
/// Default TCP factory: connects to the sidecar over TCP, optionally wrapping the stream
/// in TLS (server-auth; pinned-thumbprint or CA-chain validation). The Hello handshake +
/// shared secret still authenticate the caller on top of this.
/// </summary>
public static readonly Func<WonderwareHistorianClientOptions, CancellationToken, Task<Stream>> DefaultTcpConnectFactory =
async (opts, ct) =>
{
if (string.IsNullOrWhiteSpace(opts.Host))
throw new InvalidOperationException("WonderwareHistorianClientOptions.Host is required for the TCP transport.");
var tcp = new TcpClient();
try
{
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
connectCts.CancelAfter(opts.EffectiveConnectTimeout);
await tcp.ConnectAsync(opts.Host, opts.Port, connectCts.Token).ConfigureAwait(false);
}
catch
{
tcp.Dispose();
throw;
}
tcp.NoDelay = true;
// The returned NetworkStream owns the socket (TcpClient.GetStream() uses ownsSocket: true),
// so FrameChannel.ResetTransport() disposing this stream closes the underlying socket.
Stream stream = tcp.GetStream();
if (!opts.UseTls) return stream;
var ssl = new SslStream(stream, leaveInnerStreamOpen: false, (_, cert, _, errors) =>
{
if (!string.IsNullOrEmpty(opts.ServerCertThumbprint))
return string.Equals(cert?.GetCertHashString(), opts.ServerCertThumbprint, StringComparison.OrdinalIgnoreCase);
return errors == SslPolicyErrors.None;
});
try
{
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = opts.Host }, ct).ConfigureAwait(false);
}
catch
{
await ssl.DisposeAsync().ConfigureAwait(false);
throw;
}
return ssl;
};
/// <summary>Initializes a new instance of the <see cref="FrameChannel"/> class.</summary>
/// <param name="options">Configuration options for the historian client.</param>
/// <param name="connect">Function to establish a connection stream.</param>
/// <param name="logger">Logger instance for diagnostics.</param>
public FrameChannel(
WonderwareHistorianClientOptions options,
Func<CancellationToken, Task<Stream>> connect,
ILogger logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_connect = connect ?? throw new ArgumentNullException(nameof(connect));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Gets a value indicating whether the channel is currently connected.</summary>
public bool IsConnected => _stream is not null;
/// <summary>
/// Connects + performs the Hello handshake. Returns when the sidecar has accepted the
/// hello. Throws on rejection (bad secret, version mismatch, or transport failure).
/// </summary>
/// <param name="ct">Cancellation token to stop the operation.</param>
/// <returns>A task representing the asynchronous connection operation.</returns>
public async Task ConnectAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _callGate.WaitAsync(ct).ConfigureAwait(false);
try
{
await ConnectInternalAsync(ct).ConfigureAwait(false);
}
finally { _callGate.Release(); }
}
/// <summary>
/// Sends one request, waits for the matching reply. On transport failure, reconnects
/// once and retries — broader retry policy lives in the calling layer.
/// </summary>
/// <typeparam name="TRequest">The type of the request payload.</typeparam>
/// <typeparam name="TReply">The type of the reply payload.</typeparam>
/// <param name="requestKind">The message kind of the request.</param>
/// <param name="expectedReplyKind">The expected message kind of the reply.</param>
/// <param name="request">The request payload to send.</param>
/// <param name="cancellationToken">Cancellation token to stop the operation.</param>
/// <returns>A task that returns the reply payload.</returns>
public async Task<TReply> InvokeAsync<TRequest, TReply>(
MessageKind requestKind,
MessageKind expectedReplyKind,
TRequest request,
CancellationToken cancellationToken)
where TReply : class
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_options.EffectiveCallTimeout);
await _callGate.WaitAsync(timeout.Token).ConfigureAwait(false);
try
{
// Lazy connect on first call.
if (_stream is null) await ConnectInternalAsync(timeout.Token).ConfigureAwait(false);
try
{
return await ExchangeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or EndOfStreamException or ObjectDisposedException)
{
_logger.LogWarning(ex, "Sidecar TCP transport failure on {Kind}; reconnecting", requestKind);
ResetTransport();
await ConnectInternalAsync(timeout.Token).ConfigureAwait(false);
// One retry. If the second attempt also fails, propagate.
return await ExchangeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false);
}
}
finally { _callGate.Release(); }
}
private async Task<TReply> ExchangeAsync<TRequest, TReply>(
MessageKind requestKind, MessageKind expectedReplyKind, TRequest request, CancellationToken ct)
{
await _writer!.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
var frame = await _reader!.ReadFrameAsync(ct).ConfigureAwait(false)
?? throw new EndOfStreamException("Sidecar closed connection before reply.");
if (frame.Kind != expectedReplyKind)
{
throw new InvalidDataException(
$"Sidecar replied with kind {frame.Kind}; expected {expectedReplyKind}.");
}
return MessagePackSerializer.Deserialize<TReply>(frame.Body);
}
private async Task ConnectInternalAsync(CancellationToken ct)
{
ResetTransport();
_stream = await _connect(ct).ConfigureAwait(false);
_reader = new FrameReader(_stream, leaveOpen: true);
_writer = new FrameWriter(_stream, leaveOpen: true);
var hello = new Hello
{
ProtocolMajor = Hello.CurrentMajor,
ProtocolMinor = Hello.CurrentMinor,
PeerName = _options.PeerName,
SharedSecret = _options.SharedSecret,
};
await _writer.WriteAsync(MessageKind.Hello, hello, ct).ConfigureAwait(false);
var ackFrame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false)
?? throw new EndOfStreamException("Sidecar closed connection before HelloAck.");
if (ackFrame.Kind != MessageKind.HelloAck)
{
ResetTransport();
throw new InvalidDataException($"Sidecar replied to Hello with kind {ackFrame.Kind}; expected HelloAck.");
}
var ack = MessagePackSerializer.Deserialize<HelloAck>(ackFrame.Body);
if (!ack.Accepted)
{
ResetTransport();
throw new UnauthorizedAccessException(
$"Sidecar rejected Hello: {ack.RejectReason ?? "<no reason>"}.");
}
_logger.LogInformation("Sidecar TCP connected — host={Host}", ack.HostName);
}
private void ResetTransport()
{
_writer?.Dispose();
_reader?.Dispose();
_stream?.Dispose();
_writer = null;
_reader = null;
_stream = null;
}
/// <summary>Releases all resources associated with this channel.</summary>
/// <returns>A task representing the asynchronous disposal operation.</returns>
public ValueTask DisposeAsync()
{
if (_disposed) return ValueTask.CompletedTask;
_disposed = true;
ResetTransport();
_callGate.Dispose();
return ValueTask.CompletedTask;
}
}
@@ -1,42 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
/// <summary>
/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's <c>OpcQuality</c>)
/// to an OPC UA <c>StatusCode</c> uint. Byte-identical port of the sidecar's
/// <c>HistorianQualityMapper.Map</c> — kept in sync via parity tests rather than a
/// shared assembly because the sidecar is .NET 4.8 (x64) and the client is .NET 10 (x64).
/// </summary>
internal static class QualityMapper
{
/// <summary>Maps an OPC DA quality byte to an OPC UA StatusCode.</summary>
/// <param name="q">The OPC DA quality byte value.</param>
/// <returns>An OPC UA StatusCode as a uint.</returns>
public static uint Map(byte q) => q switch
{
// Good family (192+)
192 => 0x00000000u, // Good
216 => 0x00D80000u, // Good_LocalOverride
// Uncertain family (64-191)
64 => 0x40000000u, // Uncertain
68 => 0x40900000u, // Uncertain_LastUsableValue
80 => 0x40930000u, // Uncertain_SensorNotAccurate
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
88 => 0x40950000u, // Uncertain_SubNormal
// Bad family (0-63)
0 => 0x80000000u, // Bad
4 => 0x80890000u, // Bad_ConfigurationError
8 => 0x808A0000u, // Bad_NotConnected
12 => 0x808B0000u, // Bad_DeviceFailure
16 => 0x808C0000u, // Bad_SensorFailure
20 => 0x80050000u, // Bad_CommunicationError
24 => 0x808D0000u, // Bad_OutOfService
32 => 0x80320000u, // Bad_WaitingForInitialData
// Unknown — fall back to category bucket so callers still get something usable.
_ when q >= 192 => 0x00000000u,
_ when q >= 64 => 0x40000000u,
_ => 0x80000000u,
};
}
@@ -1,232 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
// ============================================================================
// Wire DTOs for the sidecar pipe protocol — byte-identical mirror of the
// sidecar's Contracts.cs. The sidecar is .NET 4.8 x64; this client is .NET 10
// x64. Both ends carry their own copy of these MessagePack DTOs and stay in
// sync via the round-trip tests in PR 3.4 + the byte-equality parity test.
//
// MessagePack [Key] indices MUST match the sidecar's exactly. Adding a field
// is an additive change as long as it lands at a fresh index on both sides;
// reordering or removing keys is a wire break.
//
// Timestamps cross the wire as DateTime ticks (long) to dodge MessagePack's
// DateTime kind/timezone quirks; both sides convert with DateTime(ticks, Utc).
// ============================================================================
/// <summary>Single historical data point. Quality is the raw OPC DA byte; client maps to OPC UA StatusCode.</summary>
[MessagePackObject]
public sealed class HistorianSampleDto
{
/// <summary>MessagePack-serialized value bytes. Client deserializes per the tag's mx_data_type.</summary>
[Key(0)] public byte[]? ValueBytes { get; set; }
/// <summary>Raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality).</summary>
[Key(1)] public byte Quality { get; set; }
/// <summary>Gets the UTC timestamp in ticks.</summary>
[Key(2)] public long TimestampUtcTicks { get; set; }
}
/// <summary>Aggregate bucket; <c>Value</c> is null when the aggregate is unavailable for the bucket.</summary>
[MessagePackObject]
public sealed class HistorianAggregateSampleDto
{
/// <summary>Gets or sets the aggregate value.</summary>
[Key(0)] public double? Value { get; set; }
/// <summary>Gets or sets the UTC timestamp in ticks.</summary>
[Key(1)] public long TimestampUtcTicks { get; set; }
}
/// <summary>Historian event row.</summary>
[MessagePackObject]
public sealed class HistorianEventDto
{
/// <summary>Gets or sets the event identifier.</summary>
[Key(0)] public string EventId { get; set; } = string.Empty;
/// <summary>Gets or sets the event source name.</summary>
[Key(1)] public string? Source { get; set; }
/// <summary>Gets or sets the event time in UTC ticks.</summary>
[Key(2)] public long EventTimeUtcTicks { get; set; }
/// <summary>Gets or sets the received time in UTC ticks.</summary>
[Key(3)] public long ReceivedTimeUtcTicks { get; set; }
/// <summary>Gets or sets the event display text.</summary>
[Key(4)] public string? DisplayText { get; set; }
/// <summary>Gets or sets the event severity.</summary>
[Key(5)] public ushort Severity { get; set; }
}
/// <summary>Alarm event to persist back into the historian event store.</summary>
[MessagePackObject]
public sealed class AlarmHistorianEventDto
{
/// <summary>Gets or sets the event identifier.</summary>
[Key(0)] public string EventId { get; set; } = string.Empty;
/// <summary>Gets or sets the source name.</summary>
[Key(1)] public string SourceName { get; set; } = string.Empty;
/// <summary>Gets or sets the condition identifier.</summary>
[Key(2)] public string? ConditionId { get; set; }
/// <summary>Gets or sets the alarm type.</summary>
[Key(3)] public string AlarmType { get; set; } = string.Empty;
/// <summary>Gets or sets the alarm message.</summary>
[Key(4)] public string? Message { get; set; }
/// <summary>Gets or sets the alarm severity.</summary>
[Key(5)] public ushort Severity { get; set; }
/// <summary>Gets or sets the event time in UTC ticks.</summary>
[Key(6)] public long EventTimeUtcTicks { get; set; }
/// <summary>Gets or sets the acknowledgment comment.</summary>
[Key(7)] public string? AckComment { get; set; }
}
// ===== Read Raw =====
[MessagePackObject]
public sealed class ReadRawRequest
{
/// <summary>Gets or sets the tag name.</summary>
[Key(0)] public string TagName { get; set; } = string.Empty;
/// <summary>Gets or sets the start time in UTC ticks.</summary>
[Key(1)] public long StartUtcTicks { get; set; }
/// <summary>Gets or sets the end time in UTC ticks.</summary>
[Key(2)] public long EndUtcTicks { get; set; }
/// <summary>Gets or sets the maximum number of values to read.</summary>
[Key(3)] public int MaxValues { get; set; }
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadRawReply
{
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the operation succeeded.</summary>
[Key(1)] public bool Success { get; set; }
/// <summary>Gets or sets the error message if the operation failed.</summary>
[Key(2)] public string? Error { get; set; }
/// <summary>Gets or sets the historian samples.</summary>
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
}
// ===== Read Processed =====
[MessagePackObject]
public sealed class ReadProcessedRequest
{
/// <summary>Gets or sets the tag name.</summary>
[Key(0)] public string TagName { get; set; } = string.Empty;
/// <summary>Gets or sets the start time in UTC ticks.</summary>
[Key(1)] public long StartUtcTicks { get; set; }
/// <summary>Gets or sets the end time in UTC ticks.</summary>
[Key(2)] public long EndUtcTicks { get; set; }
/// <summary>Gets or sets the interval in milliseconds.</summary>
[Key(3)] public double IntervalMs { get; set; }
/// <summary>
/// Wonderware AnalogSummary column name: "Average", "Minimum", "Maximum", "ValueCount".
/// The .NET 10 client maps OPC UA aggregate enum → column.
/// </summary>
[Key(4)] public string AggregateColumn { get; set; } = string.Empty;
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(5)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadProcessedReply
{
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the operation succeeded.</summary>
[Key(1)] public bool Success { get; set; }
/// <summary>Gets or sets the error message if the operation failed.</summary>
[Key(2)] public string? Error { get; set; }
/// <summary>Gets or sets the aggregate sample buckets.</summary>
[Key(3)] public HistorianAggregateSampleDto[] Buckets { get; set; } = Array.Empty<HistorianAggregateSampleDto>();
}
// ===== Read At-Time =====
[MessagePackObject]
public sealed class ReadAtTimeRequest
{
/// <summary>Gets or sets the tag name.</summary>
[Key(0)] public string TagName { get; set; } = string.Empty;
/// <summary>Gets or sets the timestamps in UTC ticks.</summary>
[Key(1)] public long[] TimestampsUtcTicks { get; set; } = Array.Empty<long>();
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(2)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadAtTimeReply
{
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the operation succeeded.</summary>
[Key(1)] public bool Success { get; set; }
/// <summary>Gets or sets the error message if the operation failed.</summary>
[Key(2)] public string? Error { get; set; }
/// <summary>Gets or sets the historian samples.</summary>
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
}
// ===== Read Events =====
[MessagePackObject]
public sealed class ReadEventsRequest
{
/// <summary>Gets or sets the source name.</summary>
[Key(0)] public string? SourceName { get; set; }
/// <summary>Gets or sets the start time in UTC ticks.</summary>
[Key(1)] public long StartUtcTicks { get; set; }
/// <summary>Gets or sets the end time in UTC ticks.</summary>
[Key(2)] public long EndUtcTicks { get; set; }
/// <summary>Gets or sets the maximum number of events to read.</summary>
[Key(3)] public int MaxEvents { get; set; }
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadEventsReply
{
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the operation succeeded.</summary>
[Key(1)] public bool Success { get; set; }
/// <summary>Gets or sets the error message if the operation failed.</summary>
[Key(2)] public string? Error { get; set; }
/// <summary>Gets or sets the historian events.</summary>
[Key(3)] public HistorianEventDto[] Events { get; set; } = Array.Empty<HistorianEventDto>();
}
// ===== Write Alarm Events =====
[MessagePackObject]
public sealed class WriteAlarmEventsRequest
{
/// <summary>Gets or sets the alarm historian events to write.</summary>
[Key(0)] public AlarmHistorianEventDto[] Events { get; set; } = Array.Empty<AlarmHistorianEventDto>();
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(1)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class WriteAlarmEventsReply
{
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the operation succeeded.</summary>
[Key(1)] public bool Success { get; set; }
/// <summary>Gets or sets the error message if the operation failed.</summary>
[Key(2)] public string? Error { get; set; }
/// <summary>Per-event success flag, parallel to <see cref="WriteAlarmEventsRequest.Events"/>.</summary>
[Key(3)] public bool[] PerEventOk { get; set; } = Array.Empty<bool>();
/// <summary>Per-event status parallel to the request's Events: 0=Ack, 1=Retry, 2=Permanent.
/// Empty ⇒ an older sidecar that only sent <see cref="PerEventOk"/>; the client falls back to it.</summary>
[Key(4)] public byte[] PerEventStatus { get; set; } = Array.Empty<byte>();
}
@@ -1,78 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
/// <summary>
/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
/// <see cref="ReadFrameAsync"/> from multiple threads against the same instance. Mirror of
/// the sidecar's <c>FrameReader</c>; kept byte-identical so the wire protocol stays stable.
/// </summary>
public sealed class FrameReader : IDisposable
{
private readonly Stream _stream;
private readonly bool _leaveOpen;
/// <summary>Initializes a new instance of the <see cref="FrameReader"/> class.</summary>
/// <param name="stream">The stream to read frames from.</param>
/// <param name="leaveOpen">True to leave the stream open after disposal; false to dispose it.</param>
public FrameReader(Stream stream, bool leaveOpen = false)
{
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
_leaveOpen = leaveOpen;
}
/// <summary>Reads a single frame from the stream.</summary>
/// <param name="ct">A cancellation token.</param>
/// <returns>A tuple of the message kind and body bytes, or null at end-of-stream.</returns>
public async Task<(MessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
{
var lengthPrefix = new byte[Framing.LengthPrefixSize];
if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
return null; // clean EOF on frame boundary
var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
if (length < 0 || length > Framing.MaxFrameBodyBytes)
throw new InvalidDataException($"Sidecar IPC frame length {length} out of range.");
// Read the kind byte asynchronously and cancellably — a synchronous ReadByte()
// blocks the thread-pool thread and cannot be interrupted by the call-timeout token
// if the peer stalls mid-frame (finding 005).
var kindBuffer = new byte[Framing.KindByteSize];
if (!await ReadExactAsync(kindBuffer, ct).ConfigureAwait(false))
throw new EndOfStreamException("EOF after length prefix, before kind byte.");
var body = new byte[length];
if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
throw new EndOfStreamException("EOF mid-frame.");
return ((MessageKind)kindBuffer[0], body);
}
/// <summary>Deserializes a frame body from MessagePack binary format.</summary>
/// <typeparam name="T">The target type to deserialize the body into.</typeparam>
/// <param name="body">The frame body bytes to deserialize.</param>
/// <returns>The deserialized object of the specified type.</returns>
public static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
private async Task<bool> ReadExactAsync(byte[] buffer, CancellationToken ct)
{
var offset = 0;
while (offset < buffer.Length)
{
var read = await _stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), ct).ConfigureAwait(false);
if (read == 0)
{
if (offset == 0) return false;
throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
}
offset += read;
}
return true;
}
/// <summary>Releases the stream resources if <c>leaveOpen</c> was false.</summary>
public void Dispose()
{
if (!_leaveOpen) _stream.Dispose();
}
}
@@ -1,64 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
/// <summary>
/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
/// <see cref="SemaphoreSlim"/>. Byte-identical mirror of the sidecar's FrameWriter.
/// </summary>
public sealed class FrameWriter : IDisposable
{
private readonly Stream _stream;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly bool _leaveOpen;
/// <summary>Initializes a new instance of the FrameWriter class.</summary>
/// <param name="stream">The underlying stream to write frames to.</param>
/// <param name="leaveOpen">If true, the stream is not disposed when this writer is disposed.</param>
public FrameWriter(Stream stream, bool leaveOpen = false)
{
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
_leaveOpen = leaveOpen;
}
/// <summary>Writes a length-prefixed, kind-tagged MessagePack frame to the stream.</summary>
/// <typeparam name="T">The type of the message to serialize.</typeparam>
/// <param name="kind">The frame message kind tag.</param>
/// <param name="message">The message object to serialize and write.</param>
/// <param name="ct">The cancellation token.</param>
public async Task WriteAsync<T>(MessageKind kind, T message, CancellationToken ct)
{
var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
if (body.Length > Framing.MaxFrameBodyBytes)
throw new InvalidOperationException(
$"Sidecar IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
// 5-byte header: [4-byte big-endian body length][1-byte message kind].
// The kind byte is folded into the header array so every write inside the gate
// is async+cancellable — a synchronous Stream.WriteByte() blocks the calling
// thread-pool thread and cannot be interrupted by the call-timeout token when
// the peer's receive window is full (same class of bug as finding 005 on reads).
var header = new byte[Framing.LengthPrefixSize + Framing.KindByteSize];
header[0] = (byte)((body.Length >> 24) & 0xFF);
header[1] = (byte)((body.Length >> 16) & 0xFF);
header[2] = (byte)((body.Length >> 8) & 0xFF);
header[3] = (byte)( body.Length & 0xFF);
header[4] = (byte)kind;
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
await _stream.WriteAsync(header, ct).ConfigureAwait(false);
await _stream.WriteAsync(body, ct).ConfigureAwait(false);
await _stream.FlushAsync(ct).ConfigureAwait(false);
}
finally { _gate.Release(); }
}
/// <summary>Disposes the writer and underlying stream (if not left open).</summary>
public void Dispose()
{
_gate.Dispose();
if (!_leaveOpen) _stream.Dispose();
}
}
@@ -1,48 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
/// <summary>
/// Length-prefixed framing constants for the Wonderware historian sidecar pipe protocol.
/// Each frame on the wire is:
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
/// Length is the body size only; the kind byte is not part of the prefixed length.
/// </summary>
/// <remarks>
/// Byte-identical mirror of the sidecar's <c>Driver.Historian.Wonderware.Ipc.Framing</c>.
/// The sidecar is .NET 4.8 x64; this client is .NET 10 x64 — the differing target
/// frameworks mean they cannot share an assembly, so the wire constants are duplicated
/// here. PR 3.4 ships round-trip tests that pin the byte-level parity.
/// </remarks>
public static class Framing
{
public const int LengthPrefixSize = 4;
public const int KindByteSize = 1;
/// <summary>16 MiB cap protects the receiver from a hostile or buggy peer.</summary>
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
}
/// <summary>
/// Wire identifier for each historian sidecar message. Values are stable — never reorder;
/// append new contracts at the end. The .NET 10 client and the .NET 4.8 sidecar must
/// agree on every value here. Byte-identical with the sidecar enum.
/// </summary>
public enum MessageKind : byte
{
Hello = 0x01,
HelloAck = 0x02,
ReadRawRequest = 0x10,
ReadRawReply = 0x11,
ReadProcessedRequest = 0x12,
ReadProcessedReply = 0x13,
ReadAtTimeRequest = 0x14,
ReadAtTimeReply = 0x15,
ReadEventsRequest = 0x16,
ReadEventsReply = 0x17,
WriteAlarmEventsRequest = 0x20,
WriteAlarmEventsReply = 0x21,
}
@@ -1,44 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
/// <summary>
/// First frame of every connection. Advertises the sidecar protocol version and the
/// per-process shared secret the supervisor passed at spawn time. Byte-identical mirror
/// of the sidecar's <c>Hello</c> contract.
/// </summary>
[MessagePackObject]
public sealed class Hello
{
public const int CurrentMajor = 1;
public const int CurrentMinor = 0;
/// <summary>Gets or sets the protocol major version.</summary>
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
/// <summary>Gets or sets the protocol minor version.</summary>
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
/// <summary>Gets or sets the peer name identifying the client.</summary>
[Key(2)] public string PeerName { get; set; } = string.Empty;
/// <summary>Per-process shared secret — verified against the value the supervisor passed at spawn time.</summary>
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
}
/// <summary>
/// Acknowledgment response to a <see cref="Hello"/> frame. Indicates acceptance and the remote host name.
/// </summary>
[MessagePackObject]
public sealed class HelloAck
{
/// <summary>Gets or sets the protocol major version.</summary>
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
/// <summary>Gets or sets the protocol minor version.</summary>
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
/// <summary>Gets or sets a value indicating whether the connection was accepted.</summary>
[Key(2)] public bool Accepted { get; set; }
/// <summary>Gets or sets the rejection reason if the connection was not accepted.</summary>
[Key(3)] public string? RejectReason { get; set; }
/// <summary>Gets or sets the host name of the remote server.</summary>
[Key(4)] public string HostName { get; set; } = string.Empty;
}
@@ -1,607 +0,0 @@
using MessagePack;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
using ClientHistorianEventDto = ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc.HistorianEventDto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
/// <summary>
/// .NET 10 client for the Wonderware historian sidecar (PR 3.3 protocol). Implements both
/// <see cref="IHistorianDataSource"/> (read paths consumed by
/// <c>Server.History.IHistoryRouter</c>) and <see cref="IAlarmHistorianWriter"/>
/// (alarm-event drain consumed by <c>Core.AlarmHistorian.SqliteStoreAndForwardSink</c>).
/// </summary>
/// <remarks>
/// The client owns a single <see cref="FrameChannel"/> with one in-flight call at a time;
/// concurrent calls serialize on the channel's gate. Reconnect is handled inside the
/// channel — transient transport failures retry once before propagating.
/// </remarks>
public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHistorianWriter, IAsyncDisposable
{
private readonly FrameChannel _channel;
private readonly object _healthLock = new();
private DateTime? _lastSuccessUtc;
private DateTime? _lastFailureUtc;
private string? _lastError;
private long _totalQueries;
private long _totalSuccesses;
private long _totalFailures;
private int _consecutiveFailures;
/// <summary>
/// Creates a client that connects to the Wonderware historian sidecar over TCP.
/// Tests that need an in-process duplex pair use the <see cref="ForTests"/> factory.
/// </summary>
/// <param name="options">The client connection options.</param>
/// <param name="logger">Optional logger for diagnostic output.</param>
public WonderwareHistorianClient(WonderwareHistorianClientOptions options, ILogger<WonderwareHistorianClient>? logger = null)
: this(options, ct => FrameChannel.DefaultTcpConnectFactory(options, ct), logger)
{
}
/// <summary>Test seam — inject an arbitrary connect callback.</summary>
/// <param name="options">The client connection options.</param>
/// <param name="connect">A callback that establishes the connection stream.</param>
/// <param name="logger">Optional logger for diagnostic output.</param>
/// <returns>A new WonderwareHistorianClient configured for testing.</returns>
public static WonderwareHistorianClient ForTests(
WonderwareHistorianClientOptions options,
Func<CancellationToken, Task<Stream>> connect,
ILogger<WonderwareHistorianClient>? logger = null)
=> new(options, connect, logger);
private WonderwareHistorianClient(
WonderwareHistorianClientOptions options,
Func<CancellationToken, Task<Stream>> connect,
ILogger<WonderwareHistorianClient>? logger)
{
ArgumentNullException.ThrowIfNull(options);
var log = (ILogger?)logger ?? NullLogger.Instance;
_channel = new FrameChannel(options, connect, log);
}
// ===== IHistorianDataSource =====
/// <summary>Asynchronously reads raw historical data for a tag within a time range.</summary>
/// <param name="fullReference">The full reference path of the tag to read.</param>
/// <param name="startUtc">The start time in UTC for the read range.</param>
/// <param name="endUtc">The end time in UTC for the read range.</param>
/// <param name="maxValuesPerNode">The maximum number of values to return.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns the historical read result.</returns>
public async Task<HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
{
var req = new ReadRawRequest
{
TagName = fullReference,
StartUtcTicks = startUtc.Ticks,
EndUtcTicks = endUtc.Ticks,
MaxValues = (int)Math.Min(maxValuesPerNode, int.MaxValue),
CorrelationId = Guid.NewGuid().ToString("N"),
};
var reply = await InvokeAndClassifyAsync<ReadRawRequest, ReadRawReply>(
MessageKind.ReadRawRequest, MessageKind.ReadRawReply, req,
r => (r.Success, r.Error), "ReadRaw", cancellationToken).ConfigureAwait(false);
return new HistoryReadResult(ToSnapshots(reply.Samples), ContinuationPoint: null);
}
/// <summary>Asynchronously reads processed historical data with aggregation for a tag within a time range.</summary>
/// <remarks>
/// <see cref="HistoryAggregateType.Total"/> is derived client-side as the time-weighted
/// Average × interval-seconds; Wonderware AnalogSummary exposes no Total column. The wire
/// request is issued with the Average column and each returned bucket value is scaled by
/// <c>interval.TotalSeconds</c>, preserving the bucket's status code and timestamp. All
/// other aggregates pass through unchanged.
/// </remarks>
/// <param name="fullReference">The full reference path of the tag to read.</param>
/// <param name="startUtc">The start time in UTC for the read range.</param>
/// <param name="endUtc">The end time in UTC for the read range.</param>
/// <param name="interval">The time interval for aggregation.</param>
/// <param name="aggregate">The type of aggregation to apply.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns the historical read result with aggregated data.</returns>
public async Task<HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
// Total has no AnalogSummary column — request the time-weighted Average and scale
// client-side below (Total = Average × interval-seconds).
var isDerivedTotal = aggregate == HistoryAggregateType.Total;
var wireAggregate = isDerivedTotal ? HistoryAggregateType.Average : aggregate;
var req = new ReadProcessedRequest
{
TagName = fullReference,
StartUtcTicks = startUtc.Ticks,
EndUtcTicks = endUtc.Ticks,
IntervalMs = interval.TotalMilliseconds,
AggregateColumn = MapAggregate(wireAggregate),
CorrelationId = Guid.NewGuid().ToString("N"),
};
var reply = await InvokeAndClassifyAsync<ReadProcessedRequest, ReadProcessedReply>(
MessageKind.ReadProcessedRequest, MessageKind.ReadProcessedReply, req,
r => (r.Success, r.Error), "ReadProcessed", cancellationToken).ConfigureAwait(false);
var buckets = isDerivedTotal
? ScaleAverageToTotal(reply.Buckets, interval.TotalSeconds)
: reply.Buckets;
return new HistoryReadResult(ToAggregateSnapshots(buckets), ContinuationPoint: null);
}
/// <summary>
/// Derives <see cref="HistoryAggregateType.Total"/> buckets from time-weighted Average
/// buckets using the time-integral identity Total = Average × interval-seconds. Null
/// (unavailable) buckets are carried through unscaled so the downstream null→BadNoData
/// mapping still fires; non-null values are multiplied by <paramref name="intervalSeconds"/>.
/// </summary>
private static HistorianAggregateSampleDto[] ScaleAverageToTotal(
HistorianAggregateSampleDto[] averages, double intervalSeconds)
{
if (averages.Length == 0) return averages;
var totals = new HistorianAggregateSampleDto[averages.Length];
for (var i = 0; i < averages.Length; i++)
{
var avg = averages[i];
totals[i] = new HistorianAggregateSampleDto
{
// Null (unavailable) average → null total (→ BadNoData downstream).
Value = avg.Value is { } v ? v * intervalSeconds : null,
TimestampUtcTicks = avg.TimestampUtcTicks,
};
}
return totals;
}
/// <summary>Asynchronously reads historical data at specific timestamps for a tag.</summary>
/// <param name="fullReference">The full reference path of the tag to read.</param>
/// <param name="timestampsUtc">The specific timestamps in UTC to read values for.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns the historical read result with values at the specified times.</returns>
public async Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
var ticks = new long[timestampsUtc.Count];
for (var i = 0; i < timestampsUtc.Count; i++) ticks[i] = timestampsUtc[i].Ticks;
var req = new ReadAtTimeRequest
{
TagName = fullReference,
TimestampsUtcTicks = ticks,
CorrelationId = Guid.NewGuid().ToString("N"),
};
var reply = await InvokeAndClassifyAsync<ReadAtTimeRequest, ReadAtTimeReply>(
MessageKind.ReadAtTimeRequest, MessageKind.ReadAtTimeReply, req,
r => (r.Success, r.Error), "ReadAtTime", cancellationToken).ConfigureAwait(false);
return new HistoryReadResult(AlignAtTimeSnapshots(timestampsUtc, reply.Samples), ContinuationPoint: null);
}
/// <summary>
/// Reconciles a <c>ReadAtTime</c> sidecar reply against the requested timestamps to
/// honour the <see cref="IHistorianDataSource.ReadAtTimeAsync"/> contract: the result
/// MUST have exactly one snapshot per requested timestamp, in request order. The sidecar
/// is not required to return a sample for every timestamp (e.g. it may drop
/// boundary-less timestamps) nor to preserve order, so each requested timestamp is
/// matched by ticks; any timestamp the sidecar did not return is filled with a
/// Bad-quality (<c>0x80000000</c>) snapshot rather than positionally misaligning values.
/// </summary>
private static IReadOnlyList<DataValueSnapshot> AlignAtTimeSnapshots(
IReadOnlyList<DateTime> timestampsUtc, HistorianSampleDto[] samples)
{
// Index returned samples by timestamp ticks. Duplicate timestamps keep the first.
var byTicks = new Dictionary<long, HistorianSampleDto>(samples.Length);
foreach (var sample in samples)
byTicks.TryAdd(sample.TimestampUtcTicks, sample);
var result = new DataValueSnapshot[timestampsUtc.Count];
for (var i = 0; i < timestampsUtc.Count; i++)
{
var requested = DateTime.SpecifyKind(timestampsUtc[i], DateTimeKind.Utc);
if (byTicks.TryGetValue(requested.Ticks, out var dto))
{
result[i] = new DataValueSnapshot(
Value: DeserializeSampleValue(dto.ValueBytes),
StatusCode: QualityMapper.Map(dto.Quality),
SourceTimestampUtc: requested,
ServerTimestampUtc: DateTime.UtcNow);
}
else
{
// Gap — sidecar returned no sample for this timestamp. Per the contract this
// is a Bad-quality snapshot stamped at the requested time, not a dropped row.
result[i] = new DataValueSnapshot(
Value: null,
StatusCode: 0x80000000u, // Bad
SourceTimestampUtc: requested,
ServerTimestampUtc: DateTime.UtcNow);
}
}
return result;
}
/// <summary>Asynchronously reads historical events within a time range.</summary>
/// <param name="sourceName">The source name filter for events, or null to read all sources.</param>
/// <param name="startUtc">The start time in UTC for the read range.</param>
/// <param name="endUtc">The end time in UTC for the read range.</param>
/// <param name="maxEvents">The maximum number of events to return.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns the historical events result.</returns>
public async Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken)
{
var req = new ReadEventsRequest
{
SourceName = sourceName,
StartUtcTicks = startUtc.Ticks,
EndUtcTicks = endUtc.Ticks,
MaxEvents = maxEvents,
CorrelationId = Guid.NewGuid().ToString("N"),
};
var reply = await InvokeAndClassifyAsync<ReadEventsRequest, ReadEventsReply>(
MessageKind.ReadEventsRequest, MessageKind.ReadEventsReply, req,
r => (r.Success, r.Error), "ReadEvents", cancellationToken).ConfigureAwait(false);
return new HistoricalEventsResult(ToHistoricalEvents(reply.Events), ContinuationPoint: null);
}
/// <summary>
/// Returns a snapshot of operation counters and the single TCP channel's connection
/// state.
/// </summary>
/// <remarks>
/// This client owns one TCP channel to the sidecar — it has no notion of
/// separate process / event connections and no per-node telemetry. The single channel's
/// connected state is reported for both <see cref="HistorianHealthSnapshot.ProcessConnectionOpen"/>
/// and <see cref="HistorianHealthSnapshot.EventConnectionOpen"/>, and
/// <see cref="HistorianHealthSnapshot.ActiveProcessNode"/> /
/// <see cref="HistorianHealthSnapshot.ActiveEventNode"/> /
/// <see cref="HistorianHealthSnapshot.Nodes"/> are intentionally null/empty. Consumers
/// that need to distinguish two connections should read another driver. (Finding 010.)
/// <para>
/// All six counter fields (TotalQueries, TotalSuccesses, TotalFailures,
/// ConsecutiveFailures, LastSuccessTime, LastFailureTime, LastError) are mutated
/// exclusively under <c>_healthLock</c>, so the snapshot is internally consistent —
/// in particular <c>TotalSuccesses + TotalFailures == TotalQueries</c> at every
/// observed snapshot (a call that has started but not yet completed has not
/// incremented any counter). (Finding 003 / 004.)
/// </para>
/// </remarks>
public HistorianHealthSnapshot GetHealthSnapshot()
{
lock (_healthLock)
{
return new HistorianHealthSnapshot(
TotalQueries: _totalQueries,
TotalSuccesses: _totalSuccesses,
TotalFailures: _totalFailures,
ConsecutiveFailures: _consecutiveFailures,
LastSuccessTime: _lastSuccessUtc,
LastFailureTime: _lastFailureUtc,
LastError: _lastError,
ProcessConnectionOpen: _channel.IsConnected,
EventConnectionOpen: _channel.IsConnected,
ActiveProcessNode: null,
ActiveEventNode: null,
Nodes: []);
}
}
// ===== IAlarmHistorianWriter =====
/// <summary>
/// Writes a batch of alarm events to the Wonderware historian via the sidecar.
/// </summary>
/// <remarks>
/// <para>
/// <b>Per-event status:</b> when the sidecar populates the additive
/// <see cref="WriteAlarmEventsReply.PerEventStatus"/> wire field (0=Ack, 1=Retry,
/// 2=Permanent), each slot maps directly to <see cref="HistorianWriteOutcome.Ack"/> /
/// <see cref="HistorianWriteOutcome.RetryPlease"/> / <see cref="HistorianWriteOutcome.PermanentFail"/>.
/// The sidecar emits <c>Permanent</c> for structurally-malformed (poison) events,
/// so the store-and-forward drain worker dead-letters them immediately instead of
/// looping to the retry cap. An older sidecar that sends only the legacy
/// <see cref="WriteAlarmEventsReply.PerEventOk"/> boolean is handled by the
/// fallback path below (true→Ack, false→RetryPlease) for rolling-deploy back-compat.
/// </para>
/// <para>
/// <b>Documented boundary:</b> only <i>structurally</i>-malformed events surface as
/// <see cref="HistorianWriteOutcome.PermanentFail"/>. A structurally-valid event that
/// the AAH historian SDK rejects for a deeper, semantic reason still maps to
/// <see cref="HistorianWriteOutcome.RetryPlease"/> (→ retry cap), because the sidecar's
/// writer returns only a transient/persisted boolean for events it actually attempts.
/// Surfacing richer SDK-semantic permanent rejections requires the infra-gated
/// <c>AahClientManagedAlarmEventWriter</c> to report a status code rather than a bool.
/// </para>
/// <para>
/// Transport or deserialization failures, and any whole-call failure
/// (<c>Success=false</c>), return <see cref="HistorianWriteOutcome.RetryPlease"/> for
/// every event in the batch; the drain worker's backoff controls recovery.
/// </para>
/// </remarks>
/// <param name="batch">The batch of alarm historian events to write.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns per-event write outcomes.</returns>
public async Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(batch);
if (batch.Count == 0) return [];
var dtos = new AlarmHistorianEventDto[batch.Count];
for (var i = 0; i < batch.Count; i++) dtos[i] = ToDto(batch[i]);
var req = new WriteAlarmEventsRequest
{
Events = dtos,
CorrelationId = Guid.NewGuid().ToString("N"),
};
try
{
var reply = await InvokeAsync<WriteAlarmEventsRequest, WriteAlarmEventsReply>(
MessageKind.WriteAlarmEventsRequest, MessageKind.WriteAlarmEventsReply, req,
r => (r.Success, r.Error), cancellationToken).ConfigureAwait(false);
// Whole-call failure → transient retry for every event in the batch.
if (!reply.Success)
{
var fail = new HistorianWriteOutcome[batch.Count];
Array.Fill(fail, HistorianWriteOutcome.RetryPlease);
return fail;
}
// Prefer the granular per-event status when the sidecar provides it (new wire
// field); fall back to the legacy PerEventOk bool for older sidecars. The sidecar
// emits status 2 (Permanent) for structurally-malformed poison events so they
// dead-letter immediately rather than retrying to the cap.
if (reply.PerEventStatus is { Length: > 0 } status && status.Length == batch.Count)
{
var statusOutcomes = new HistorianWriteOutcome[batch.Count];
for (var i = 0; i < batch.Count; i++)
statusOutcomes[i] = status[i] switch
{
0 => HistorianWriteOutcome.Ack,
2 => HistorianWriteOutcome.PermanentFail,
_ => HistorianWriteOutcome.RetryPlease, // 1 or unknown
};
return statusOutcomes;
}
// Legacy fallback: PerEventOk[i] = true → Ack; false → RetryPlease. An older
// sidecar without PerEventStatus can never signal PermanentFail through this
// path, so a poison event retries to the drain worker's cap.
var outcomes = new HistorianWriteOutcome[batch.Count];
for (var i = 0; i < batch.Count; i++)
{
var ok = i < reply.PerEventOk.Length && reply.PerEventOk[i];
outcomes[i] = ok ? HistorianWriteOutcome.Ack : HistorianWriteOutcome.RetryPlease;
}
return outcomes;
}
catch
{
// Transport / deserialization failure — every event is retry-please. The drain
// worker's backoff handles recovery. PermanentFail is only emitted from the
// success path's PerEventStatus mapping, never from a transport failure.
var fail = new HistorianWriteOutcome[batch.Count];
Array.Fill(fail, HistorianWriteOutcome.RetryPlease);
return fail;
}
}
// ===== Constants =====
/// <summary>
/// Per-sample ValueBytes size cap. MessagePack with the default
/// <see cref="MessagePack.Resolvers.StandardResolver"/> (primitive-only — no typeless
/// or dynamic-type resolution) is not susceptible to type-confusion gadget chains, but
/// we still cap the per-sample byte budget to guard against a buggy or unexpectedly
/// large peer payload. 64 KiB is well above any primitive historian value.
/// (Finding 007 — NuGetAuditSuppress GHSA-37gx-xxp4-5rgx / GHSA-w3x6-4m5h-cxqf.)
/// </summary>
private const int MaxValueBytesPerSample = 64 * 1024;
// ===== Helpers =====
/// <summary>
/// Sends one request through the channel and records the outcome (transport success or
/// transport failure) under a single <c>_healthLock</c> acquisition that also bumps
/// <c>_totalQueries</c>. Sidecar-level success / failure is NOT classified here — the
/// caller passes that through <see cref="InvokeAndClassifyAsync"/> instead. (Finding
/// 003 / 004: all six counter fields share one synchronization mechanism so a snapshot
/// can never observe a torn state.)
/// </summary>
private async Task<TReply> InvokeAsync<TRequest, TReply>(
MessageKind requestKind, MessageKind expectedReplyKind, TRequest request,
Func<TReply, (bool ok, string? error)> evaluate, CancellationToken ct)
where TReply : class
{
try
{
var reply = await _channel.InvokeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, ct).ConfigureAwait(false);
// Classify transport+sidecar in one lock so TotalQueries/TotalSuccesses/
// TotalFailures move together and no intermediate "success-then-undo" state is
// visible to a concurrent GetHealthSnapshot.
var (ok, error) = evaluate(reply);
RecordOutcome(ok, error);
return reply;
}
catch (Exception ex)
{
RecordOutcome(success: false, ex.Message);
throw;
}
}
/// <summary>
/// Convenience wrapper around <see cref="InvokeAsync"/> that throws
/// <see cref="InvalidOperationException"/> on a sidecar-reported failure. Used by the
/// <see cref="IHistorianDataSource"/> read methods.
/// </summary>
private async Task<TReply> InvokeAndClassifyAsync<TRequest, TReply>(
MessageKind requestKind, MessageKind expectedReplyKind, TRequest request,
Func<TReply, (bool ok, string? error)> evaluate, string op, CancellationToken ct)
where TReply : class
{
var reply = await InvokeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, evaluate, ct).ConfigureAwait(false);
var (ok, error) = evaluate(reply);
if (!ok)
{
throw new InvalidOperationException(
$"Sidecar {op} failed: {error ?? "<no message>"}.");
}
return reply;
}
/// <summary>
/// Records the outcome of a single call — increments <c>_totalQueries</c> and exactly
/// one of <c>_totalSuccesses</c> / <c>_totalFailures</c> under a single
/// <c>_healthLock</c> acquisition. (Findings 003 + 004.)
/// </summary>
private void RecordOutcome(bool success, string? error)
{
lock (_healthLock)
{
_totalQueries++;
if (success)
{
_totalSuccesses++;
_consecutiveFailures = 0;
_lastSuccessUtc = DateTime.UtcNow;
}
else
{
_totalFailures++;
_consecutiveFailures++;
_lastFailureUtc = DateTime.UtcNow;
_lastError = error;
}
}
}
/// <summary>
/// Deserializes a sample's value bytes using the MessagePack default
/// <see cref="MessagePack.Resolvers.StandardResolver"/> (primitive types only — no
/// typeless or dynamic-type resolution). A per-sample size cap guards against a
/// hostile or buggy peer sending an unexpectedly large payload before deserialization
/// allocates memory for it. (Finding 007.)
/// </summary>
private static object? DeserializeSampleValue(byte[]? valueBytes)
{
if (valueBytes is null) return null;
if (valueBytes.Length > MaxValueBytesPerSample)
throw new InvalidDataException(
$"Sidecar sample ValueBytes length {valueBytes.Length} exceeds the {MaxValueBytesPerSample}-byte cap.");
// Deserializes using the default resolver which only handles primitive types
// (bool, int, long, float, double, string, byte[], DateTime, etc.). The resolver
// does NOT support TypelessContractlessStandardResolver so no type-confusion gadget
// chains are reachable from this call site.
return MessagePackSerializer.Deserialize<object>(valueBytes);
}
private static IReadOnlyList<DataValueSnapshot> ToSnapshots(HistorianSampleDto[] dtos)
{
if (dtos.Length == 0) return [];
var snapshots = new DataValueSnapshot[dtos.Length];
for (var i = 0; i < dtos.Length; i++)
{
var dto = dtos[i];
snapshots[i] = new DataValueSnapshot(
Value: DeserializeSampleValue(dto.ValueBytes),
StatusCode: QualityMapper.Map(dto.Quality),
SourceTimestampUtc: new DateTime(dto.TimestampUtcTicks, DateTimeKind.Utc),
ServerTimestampUtc: DateTime.UtcNow);
}
return snapshots;
}
private static IReadOnlyList<DataValueSnapshot> ToAggregateSnapshots(HistorianAggregateSampleDto[] dtos)
{
if (dtos.Length == 0) return [];
var snapshots = new DataValueSnapshot[dtos.Length];
for (var i = 0; i < dtos.Length; i++)
{
var dto = dtos[i];
// Null aggregate value → BadNoData per Core.Abstractions HistoryReadResult convention.
snapshots[i] = new DataValueSnapshot(
Value: dto.Value,
StatusCode: dto.Value is null ? 0x800E0000u /* BadNoData */ : 0x00000000u /* Good */,
SourceTimestampUtc: new DateTime(dto.TimestampUtcTicks, DateTimeKind.Utc),
ServerTimestampUtc: DateTime.UtcNow);
}
return snapshots;
}
private static IReadOnlyList<HistoricalEvent> ToHistoricalEvents(ClientHistorianEventDto[] dtos)
{
if (dtos.Length == 0) return [];
var events = new HistoricalEvent[dtos.Length];
for (var i = 0; i < dtos.Length; i++)
{
var dto = dtos[i];
events[i] = new HistoricalEvent(
EventId: dto.EventId,
SourceName: dto.Source,
EventTimeUtc: new DateTime(dto.EventTimeUtcTicks, DateTimeKind.Utc),
ReceivedTimeUtc: new DateTime(dto.ReceivedTimeUtcTicks, DateTimeKind.Utc),
Message: dto.DisplayText,
Severity: dto.Severity);
}
return events;
}
private static AlarmHistorianEventDto ToDto(AlarmHistorianEvent evt) => new()
{
EventId = evt.AlarmId,
SourceName = evt.EquipmentPath,
ConditionId = evt.AlarmName,
AlarmType = evt.AlarmTypeName + ":" + evt.EventKind,
Message = evt.Message,
Severity = MapSeverity(evt.Severity),
EventTimeUtcTicks = evt.TimestampUtc.Ticks,
AckComment = evt.Comment,
};
private static ushort MapSeverity(AlarmSeverity severity) => severity switch
{
AlarmSeverity.Low => 250,
AlarmSeverity.Medium => 500,
AlarmSeverity.High => 700,
AlarmSeverity.Critical => 900,
_ => 500,
};
/// <summary>
/// Maps an OPC UA aggregate to its Wonderware AnalogSummary column name. There is no
/// Total column — <see cref="HistoryAggregateType.Total"/> is derived client-side in
/// <see cref="ReadProcessedAsync"/> by requesting Average, so it is never passed here.
/// </summary>
private static string MapAggregate(HistoryAggregateType aggregate) => aggregate switch
{
HistoryAggregateType.Average => "Average",
HistoryAggregateType.Minimum => "Minimum",
HistoryAggregateType.Maximum => "Maximum",
HistoryAggregateType.Count => "ValueCount",
_ => throw new NotSupportedException($"Unknown HistoryAggregateType {aggregate}"),
};
/// <summary>Asynchronously disposes the client and its underlying TCP channel.</summary>
/// <returns>A task that completes when the client has been disposed.</returns>
public ValueTask DisposeAsync() => _channel.DisposeAsync();
/// <summary>
/// Synchronous dispose required by <see cref="IDisposable"/> on
/// <see cref="IHistorianDataSource"/>. The underlying channel's async cleanup runs the
/// TCP socket teardown, which can block briefly on OS handle release — strictly speaking
/// it is not non-blocking — but the <c>GetAwaiter()/GetResult()</c> bridge is
/// deadlock-safe because the cleanup never awaits a captured
/// <see cref="System.Threading.SynchronizationContext"/> nor takes any lock that the
/// caller could hold. (Finding 010.)
/// </summary>
public void Dispose() => _channel.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
@@ -1,93 +0,0 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
/// <summary>
/// TCP-connect probe for the <see cref="WonderwareHistorianClientOptions"/>-shaped driver
/// config. Opens a socket to the configured <c>Host:Port</c> (optionally performing the TLS
/// client handshake when <c>UseTls</c> is set, reusing the same pinned-thumbprint / CA-chain
/// validation as <see cref="FrameChannel.DefaultTcpConnectFactory"/>), then sends a
/// <see cref="Hello"/> with the configured shared secret and confirms the sidecar's
/// <see cref="HelloAck"/> is accepted — a true end-to-end reachability + auth check.
/// Surfaces a green tick + latency on success; a clear red message on timeout / connection
/// refused / TLS failure / rejected Hello.
/// </summary>
public sealed class WonderwareHistorianDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// <inheritdoc />
public string DriverType => "Historian.Wonderware";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
WonderwareHistorianClientOptions? opts;
try { opts = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
if (string.IsNullOrWhiteSpace(opts.Host) || opts.Port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
Stream? stream = null;
try
{
// Reuse the runtime connect factory so the probe exercises the exact TCP + TLS
// (pinned-thumbprint or CA-chain) path the client uses in production.
stream = await FrameChannel.DefaultTcpConnectFactory(opts, ct).ConfigureAwait(false);
using var reader = new FrameReader(stream, leaveOpen: true);
using var writer = new FrameWriter(stream, leaveOpen: true);
var hello = new Hello
{
ProtocolMajor = Hello.CurrentMajor,
ProtocolMinor = Hello.CurrentMinor,
PeerName = opts.PeerName,
SharedSecret = opts.SharedSecret,
};
await writer.WriteAsync(MessageKind.Hello, hello, ct).ConfigureAwait(false);
var ackFrame = await reader.ReadFrameAsync(ct).ConfigureAwait(false)
?? throw new EndOfStreamException("Sidecar closed connection before HelloAck.");
if (ackFrame.Kind != MessageKind.HelloAck)
return new(false, $"Sidecar replied to Hello with kind {ackFrame.Kind}; expected HelloAck.", null);
var ack = FrameReader.Deserialize<HelloAck>(ackFrame.Body);
if (!ack.Accepted)
return new(false, $"Sidecar rejected Hello: {ack.RejectReason ?? "<no reason>"}.", null);
sw.Stop();
return new(true, $"Connected to {opts.Host}:{opts.Port} (tls={opts.UseTls})", sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
finally
{
if (stream is not null) await stream.DisposeAsync().ConfigureAwait(false);
}
}
}
@@ -1,30 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests"/>
</ItemGroup>
</Project>
@@ -1,117 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// IPC-side <see cref="IAlarmEventWriter"/> implementation that delegates to an
/// <see cref="IAlarmHistorianWriteBackend"/> (production: aahClientManaged-bound)
/// and maps the trinary <see cref="AlarmHistorianWriteOutcome"/> down to the
/// <c>bool[]</c> the IPC reply contract carries. Per-event outcomes:
/// <list type="bullet">
/// <item><description><see cref="AlarmHistorianWriteOutcome.Ack"/> → <c>true</c> (drop from sender's queue).</description></item>
/// <item><description><see cref="AlarmHistorianWriteOutcome.RetryPlease"/> → <c>false</c> (sender retries on next drain tick).</description></item>
/// <item><description><see cref="AlarmHistorianWriteOutcome.PermanentFail"/> → <c>false</c> (sender's B.4 widens the IPC bool back into the trinary outcome by inspecting structured diagnostics; this slot intentionally collapses to "not-ok" at the wire).</description></item>
/// </list>
/// </summary>
public sealed class AahClientManagedAlarmEventWriter : IAlarmEventWriter
{
private static readonly ILogger Log = Serilog.Log.ForContext<AahClientManagedAlarmEventWriter>();
private readonly IAlarmHistorianWriteBackend _backend;
/// <summary>
/// Initializes a new instance of the AahClientManagedAlarmEventWriter class.
/// </summary>
/// <param name="backend">The alarm historian write backend to delegate to.</param>
public AahClientManagedAlarmEventWriter(IAlarmHistorianWriteBackend backend)
{
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
}
/// <summary>
/// Writes an array of alarm historian events asynchronously.
/// </summary>
/// <param name="events">The alarm events to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<bool[]> WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken)
{
if (events is null || events.Length == 0)
{
return new bool[0];
}
AlarmHistorianWriteOutcome[] outcomes;
try
{
outcomes = await _backend.WriteBatchAsync(events, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Backend-level failure (cluster unreachable, transport error). Treat the
// whole batch as RetryPlease so the sender's queue holds the rows for
// the next drain tick — preferable to dropping them on a transient.
Log.Warning(ex,
"Alarm historian backend WriteBatchAsync threw — marking entire {Count}-event batch RetryPlease.",
events.Length);
var fallback = new bool[events.Length];
return fallback;
}
if (outcomes.Length != events.Length)
{
// Backend contract violation — defensive degrade so a bug in the backend
// doesn't desync the sender's queue accounting. Treat as RetryPlease.
Log.Warning(
"Alarm historian backend returned {ReturnedCount} outcomes for a batch of {InputCount} events; degrading to RetryPlease for the whole batch.",
outcomes.Length, events.Length);
return new bool[events.Length];
}
var perEventOk = new bool[outcomes.Length];
for (var i = 0; i < outcomes.Length; i++)
{
perEventOk[i] = outcomes[i] == AlarmHistorianWriteOutcome.Ack;
}
return perEventOk;
}
/// <summary>
/// Translate the outcome of a single SDK call (raw HRESULT + diagnostic) into the
/// trinary <see cref="AlarmHistorianWriteOutcome"/>. Exposed for the production
/// <see cref="SdkAlarmHistorianWriteBackend"/> to share the mapping with tests.
/// </summary>
/// <param name="hresult">The HRESULT code from the SDK call.</param>
/// <param name="isCommunicationError">Indicates whether the error is a communication-class error.</param>
/// <param name="isMalformedInput">Indicates whether the input was malformed.</param>
public static AlarmHistorianWriteOutcome MapOutcome(int hresult, bool isCommunicationError, bool isMalformedInput)
{
// Order matters: malformed input is permanent regardless of HRESULT pattern;
// communication-class errors are transient regardless of which specific
// HRESULT bit fired.
if (isMalformedInput)
{
return AlarmHistorianWriteOutcome.PermanentFail;
}
if (hresult == 0)
{
return AlarmHistorianWriteOutcome.Ack;
}
if (isCommunicationError)
{
return AlarmHistorianWriteOutcome.RetryPlease;
}
// Default: unknown HRESULT failure — be conservative and let the sender retry.
// The sender's drain worker has its own dead-letter cap so a permanently-broken
// event won't loop forever.
return AlarmHistorianWriteOutcome.RetryPlease;
}
}
}
@@ -1,19 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Per-event outcome from <see cref="IAlarmHistorianWriteBackend.WriteBatchAsync"/>.
/// Sidecar-local twin of <c>Core.AlarmHistorian.HistorianWriteOutcome</c> (the
/// sidecar runs net48 and cannot reference the net10 Core project; the IPC
/// contract narrows this to <c>bool</c> per slot, so the lmxopcua-side consumer
/// widens that back into the trinary outcome at the IPC boundary in PR B.4).
/// </summary>
public enum AlarmHistorianWriteOutcome
{
/// <summary>Event accepted by the historian. Drop from the store-and-forward queue.</summary>
Ack,
/// <summary>Transient failure (server busy, disconnected, timeout). Leave queued; retry on next drain tick.</summary>
RetryPlease,
/// <summary>Permanent failure (malformed event, unrecoverable SDK error). Move to dead-letter on the lmxopcua side.</summary>
PermanentFail,
}
}
@@ -1,148 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Thread-safe, pure-logic endpoint picker for the Wonderware Historian cluster. Tracks which
/// configured nodes are healthy, places failed nodes in a time-bounded cooldown, and hands
/// out an ordered list of eligible candidates for the data source to try in sequence.
/// </summary>
internal sealed class HistorianClusterEndpointPicker
{
private readonly Func<DateTime> _clock;
private readonly TimeSpan _cooldown;
private readonly object _lock = new object();
private readonly List<NodeEntry> _nodes;
/// <summary>Initializes the picker with default system clock.</summary>
/// <param name="config">Historian configuration.</param>
public HistorianClusterEndpointPicker(HistorianConfiguration config)
: this(config, () => DateTime.UtcNow) { }
/// <summary>Initializes the picker with custom clock function.</summary>
/// <param name="config">Historian configuration.</param>
/// <param name="clock">Clock function for testing.</param>
internal HistorianClusterEndpointPicker(HistorianConfiguration config, Func<DateTime> clock)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_cooldown = TimeSpan.FromSeconds(Math.Max(0, config.FailureCooldownSeconds));
var names = (config.ServerNames != null && config.ServerNames.Count > 0)
? config.ServerNames
: new List<string> { config.ServerName };
_nodes = names
.Where(n => !string.IsNullOrWhiteSpace(n))
.Select(n => n.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(n => new NodeEntry { Name = n })
.ToList();
}
/// <summary>Gets the total count of configured nodes.</summary>
public int NodeCount
{
get { lock (_lock) return _nodes.Count; }
}
/// <summary>Gets the list of currently healthy nodes.</summary>
public IReadOnlyList<string> GetHealthyNodes()
{
lock (_lock)
{
var now = _clock();
return _nodes.Where(n => IsHealthyAt(n, now)).Select(n => n.Name).ToList();
}
}
/// <summary>Gets the count of currently healthy nodes.</summary>
public int HealthyNodeCount
{
get
{
lock (_lock)
{
var now = _clock();
return _nodes.Count(n => IsHealthyAt(n, now));
}
}
}
/// <summary>Marks a node as failed and starts its cooldown.</summary>
/// <param name="node">Node name.</param>
/// <param name="error">Optional error message.</param>
public void MarkFailed(string node, string? error)
{
lock (_lock)
{
var entry = FindEntry(node);
if (entry == null) return;
var now = _clock();
entry.FailureCount++;
entry.LastError = error;
entry.LastFailureTime = now;
entry.CooldownUntil = _cooldown.TotalMilliseconds > 0 ? now + _cooldown : (DateTime?)null;
}
}
/// <summary>Marks a node as healthy and clears its cooldown.</summary>
/// <param name="node">Node name.</param>
public void MarkHealthy(string node)
{
lock (_lock)
{
var entry = FindEntry(node);
if (entry == null) return;
entry.CooldownUntil = null;
}
}
/// <summary>Returns a snapshot of all node states.</summary>
public List<HistorianClusterNodeState> SnapshotNodeStates()
{
lock (_lock)
{
var now = _clock();
return _nodes.Select(n => new HistorianClusterNodeState
{
Name = n.Name,
IsHealthy = IsHealthyAt(n, now),
CooldownUntil = IsHealthyAt(n, now) ? null : n.CooldownUntil,
FailureCount = n.FailureCount,
LastError = n.LastError,
LastFailureTime = n.LastFailureTime
}).ToList();
}
}
private static bool IsHealthyAt(NodeEntry entry, DateTime now)
{
return entry.CooldownUntil == null || entry.CooldownUntil <= now;
}
private NodeEntry? FindEntry(string node)
{
for (var i = 0; i < _nodes.Count; i++)
if (string.Equals(_nodes[i].Name, node, StringComparison.OrdinalIgnoreCase))
return _nodes[i];
return null;
}
private sealed class NodeEntry
{
/// <summary>Gets or sets the node name.</summary>
public string Name { get; set; } = "";
/// <summary>Gets or sets when cooldown expires.</summary>
public DateTime? CooldownUntil { get; set; }
/// <summary>Gets or sets the failure count.</summary>
public int FailureCount { get; set; }
/// <summary>Gets or sets the last error message.</summary>
public string? LastError { get; set; }
/// <summary>Gets or sets the last failure time.</summary>
public DateTime? LastFailureTime { get; set; }
}
}
}
@@ -1,29 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Point-in-time state of a single historian cluster node. One entry per configured node
/// appears inside <see cref="HistorianHealthSnapshot"/>.
/// </summary>
public sealed class HistorianClusterNodeState
{
/// <summary>Gets or sets the node name.</summary>
public string Name { get; set; } = "";
/// <summary>Gets or sets a value indicating whether the node is healthy.</summary>
public bool IsHealthy { get; set; }
/// <summary>Gets or sets the time until the node exits cooldown mode.</summary>
public DateTime? CooldownUntil { get; set; }
/// <summary>Gets or sets the count of recent failures.</summary>
public int FailureCount { get; set; }
/// <summary>Gets or sets the last error message.</summary>
public string? LastError { get; set; }
/// <summary>Gets or sets the time of the last failure.</summary>
public DateTime? LastFailureTime { get; set; }
}
}
@@ -1,49 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Wonderware Historian SDK configuration. Populated from environment variables at
/// sidecar startup (see <c>Program.cs</c>): the supervisor (lmxopcua-side
/// <c>WonderwareHistorianClient</c>) spawns the sidecar with these env vars; UA
/// translation lives on the client side of the TCP IPC, so this surface is
/// kept OPC-UA-free. The legacy v1 Galaxy.Host / Proxy host this lived in retired
/// in PR 7.2.
/// </summary>
public sealed class HistorianConfiguration
{
/// <summary>Gets or sets a value indicating whether Historian integration is enabled.</summary>
public bool Enabled { get; set; } = false;
/// <summary>Single-node fallback when <see cref="ServerNames"/> is empty.</summary>
public string ServerName { get; set; } = "localhost";
/// <summary>
/// Ordered cluster nodes. When non-empty, the data source tries each in order on connect,
/// falling through to the next on failure. A failed node is placed in cooldown for
/// <see cref="FailureCooldownSeconds"/> before being re-eligible.
/// </summary>
public List<string> ServerNames { get; set; } = new();
/// <summary>Gets or sets the failure cooldown period in seconds.</summary>
public int FailureCooldownSeconds { get; set; } = 60;
/// <summary>Gets or sets a value indicating whether to use integrated security.</summary>
public bool IntegratedSecurity { get; set; } = true;
/// <summary>Gets or sets the user name for authentication.</summary>
public string? UserName { get; set; }
/// <summary>Gets or sets the password for authentication.</summary>
public string? Password { get; set; }
/// <summary>Gets or sets the Historian server port.</summary>
public int Port { get; set; } = 32568;
/// <summary>Gets or sets the command timeout in seconds.</summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>Gets or sets the maximum number of values per read operation.</summary>
public int MaxValuesPerRead { get; set; } = 10000;
/// <summary>
/// Outer safety timeout applied to sync-over-async Historian operations. Must be
/// comfortably larger than <see cref="CommandTimeoutSeconds"/>.
/// </summary>
public int RequestTimeoutSeconds { get; set; } = 60;
}
}
@@ -1,863 +0,0 @@
using System;
using System.Collections.Generic;
using StringCollection = System.Collections.Specialized.StringCollection;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Reads historical data from the Wonderware Historian via the aahClientManaged SDK.
/// OPC-UA-free — emits <see cref="HistorianSample"/>/<see cref="HistorianAggregateSample"/>
/// which the sidecar serialises onto the TCP wire (PR 3.3 contracts) for the
/// .NET 10 <c>WonderwareHistorianClient</c> to translate into OPC UA <c>DataValue</c>
/// on its side of the IPC. The v1 Galaxy.Host / Proxy architecture this class
/// originally lived in retired in PR 7.2.
/// </summary>
public sealed class HistorianDataSource : IHistorianDataSource
{
private static readonly ILogger Log = Serilog.Log.ForContext<HistorianDataSource>();
private readonly HistorianConfiguration _config;
private readonly object _connectionLock = new object();
private readonly object _eventConnectionLock = new object();
private readonly IHistorianConnectionFactory _factory;
private HistorianAccess? _connection;
private HistorianAccess? _eventConnection;
private bool _disposed;
private readonly object _healthLock = new object();
private long _totalSuccesses;
private long _totalFailures;
private int _consecutiveFailures;
private DateTime? _lastSuccessTime;
private DateTime? _lastFailureTime;
private string? _lastError;
private string? _activeProcessNode;
private string? _activeEventNode;
private readonly HistorianClusterEndpointPicker _picker;
/// <summary>Initializes a new instance of the <see cref="HistorianDataSource"/> class with the default connection factory.</summary>
/// <param name="config">The historian configuration.</param>
public HistorianDataSource(HistorianConfiguration config)
: this(config, new SdkHistorianConnectionFactory(), null) { }
/// <summary>Initializes a new instance of the <see cref="HistorianDataSource"/> class with the specified connection factory and endpoint picker.</summary>
/// <param name="config">The historian configuration.</param>
/// <param name="factory">The historian connection factory.</param>
/// <param name="picker">The optional cluster endpoint picker.</param>
internal HistorianDataSource(
HistorianConfiguration config,
IHistorianConnectionFactory factory,
HistorianClusterEndpointPicker? picker = null)
{
_config = config;
_factory = factory;
_picker = picker ?? new HistorianClusterEndpointPicker(config);
}
// Error codes that signify the connection or server is the problem rather than the
// query itself. A query-class failure (bad tag name, unsupported aggregate, etc.) must
// not force us to tear down and re-open the (relatively expensive) historian
// connection — that would let a burst of bad-tag queries push an otherwise healthy
// cluster node into cooldown. See Driver.Historian.Wonderware-008.
private static readonly HashSet<HistorianAccessError.ErrorValue> ConnectionErrorCodes =
new HashSet<HistorianAccessError.ErrorValue>
{
HistorianAccessError.ErrorValue.FailedToConnect,
HistorianAccessError.ErrorValue.FailedToCreateSession,
HistorianAccessError.ErrorValue.NoReply,
HistorianAccessError.ErrorValue.NotReady,
HistorianAccessError.ErrorValue.NotInitialized,
HistorianAccessError.ErrorValue.Stopping,
HistorianAccessError.ErrorValue.Win32Exception,
HistorianAccessError.ErrorValue.InvalidResponse,
};
/// <summary>
/// Whether an <c>aahClientManaged</c> error code indicates that the
/// <em>connection</em> (rather than the query payload) is the problem and the
/// shared SDK connection should therefore be reset. Internal for unit testing.
/// </summary>
/// <param name="code">The historian access error code.</param>
internal static bool IsConnectionClassError(HistorianAccessError.ErrorValue code)
=> ConnectionErrorCodes.Contains(code);
/// <summary>
/// Whether a failed <c>StartQuery</c> in the per-timestamp at-time loop should reset
/// the shared SDK connection (and abort the read) rather than record a per-timestamp
/// Bad sample and continue. Returns <c>true</c> only for connection-class error
/// codes; query-class / no-data codes (and a missing error) return <c>false</c> so
/// a single bad/empty timestamp does not tear down a connection that is still serving
/// the other timestamps. The <c>HistoryQuery</c> SDK type is non-virtual and has no
/// interface, so the at-time loop can't be driven offline — this pure helper is the
/// unit-testable seam for the classification. See Driver.Historian.Wonderware-014.
/// </summary>
/// <param name="error">The SDK error returned by the failed <c>StartQuery</c>.</param>
internal static bool ShouldResetConnectionForStartQueryFailure(HistorianAccessError? error)
=> IsConnectionClassError(error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure);
/// <summary>
/// Builds the per-read <see cref="CancellationTokenSource"/> linked into the
/// caller's <paramref name="ct"/> and pre-wired to fire after
/// <see cref="HistorianConfiguration.RequestTimeoutSeconds"/> if positive. The
/// read paths use the resulting token in their <c>ThrowIfCancellationRequested</c>
/// checks so a hung <c>StartQuery</c> or slow <c>MoveNext</c> cannot block the
/// single TCP-server connection thread indefinitely. See
/// Driver.Historian.Wonderware-010.
/// </summary>
/// <param name="cfg">The historian configuration.</param>
/// <param name="ct">The cancellation token.</param>
internal static CancellationTokenSource BuildRequestCts(HistorianConfiguration cfg, CancellationToken ct)
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
if (cfg.RequestTimeoutSeconds > 0)
{
cts.CancelAfter(TimeSpan.FromSeconds(cfg.RequestTimeoutSeconds));
}
return cts;
}
private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode(HistorianConnectionType type)
{
var candidates = _picker.GetHealthyNodes();
if (candidates.Count == 0)
{
var total = _picker.NodeCount;
throw new InvalidOperationException(
total == 0
? "No historian nodes configured"
: $"All {total} historian nodes are in cooldown — no healthy endpoints to connect to");
}
Exception? lastException = null;
foreach (var node in candidates)
{
var attemptConfig = CloneConfigWithServerName(node);
try
{
var conn = _factory.CreateAndConnect(attemptConfig, type);
_picker.MarkHealthy(node);
return (conn, node);
}
catch (Exception ex)
{
_picker.MarkFailed(node, ex.Message);
lastException = ex;
Log.Warning(ex, "Historian node {Node} failed during connect attempt; trying next candidate", node);
}
}
var inner = lastException?.Message ?? "(no detail)";
throw new InvalidOperationException(
$"All {candidates.Count} healthy historian candidate(s) failed during connect: {inner}",
lastException);
}
private HistorianConfiguration CloneConfigWithServerName(string serverName)
{
return new HistorianConfiguration
{
Enabled = _config.Enabled,
ServerName = serverName,
ServerNames = _config.ServerNames,
FailureCooldownSeconds = _config.FailureCooldownSeconds,
IntegratedSecurity = _config.IntegratedSecurity,
UserName = _config.UserName,
Password = _config.Password,
Port = _config.Port,
CommandTimeoutSeconds = _config.CommandTimeoutSeconds,
MaxValuesPerRead = _config.MaxValuesPerRead
};
}
/// <summary>Gets a snapshot of the current health status.</summary>
public HistorianHealthSnapshot GetHealthSnapshot()
{
var nodeStates = _picker.SnapshotNodeStates();
var healthyCount = 0;
foreach (var n in nodeStates)
if (n.IsHealthy) healthyCount++;
// Driver.Historian.Wonderware-005: derive the connection-open booleans from the
// active-node strings, both of which live under _healthLock. _connection itself
// is published under _connectionLock — reading it here under a different lock
// could produce an internally inconsistent snapshot (open with no node, or
// closed with a non-null node) at the publish/clear boundary. Treating the
// active-node strings as the single source of truth makes the snapshot
// self-consistent by construction.
lock (_healthLock)
{
return new HistorianHealthSnapshot
{
TotalQueries = _totalSuccesses + _totalFailures,
TotalSuccesses = _totalSuccesses,
TotalFailures = _totalFailures,
ConsecutiveFailures = _consecutiveFailures,
LastSuccessTime = _lastSuccessTime,
LastFailureTime = _lastFailureTime,
LastError = _lastError,
ProcessConnectionOpen = _activeProcessNode != null,
EventConnectionOpen = _activeEventNode != null,
ActiveProcessNode = _activeProcessNode,
ActiveEventNode = _activeEventNode,
NodeCount = nodeStates.Count,
HealthyNodeCount = healthyCount,
Nodes = nodeStates
};
}
}
private void RecordSuccess()
{
lock (_healthLock)
{
_totalSuccesses++;
_lastSuccessTime = DateTime.UtcNow;
_consecutiveFailures = 0;
_lastError = null;
}
}
private void RecordFailure(string error)
{
lock (_healthLock)
{
_totalFailures++;
_lastFailureTime = DateTime.UtcNow;
_consecutiveFailures++;
_lastError = error;
}
}
private void EnsureConnected()
{
if (_disposed)
throw new ObjectDisposedException(nameof(HistorianDataSource));
if (Volatile.Read(ref _connection) != null) return;
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Process);
lock (_connectionLock)
{
if (_disposed)
{
conn.CloseConnection(out _);
conn.Dispose();
throw new ObjectDisposedException(nameof(HistorianDataSource));
}
if (_connection != null)
{
conn.CloseConnection(out _);
conn.Dispose();
return;
}
_connection = conn;
lock (_healthLock) _activeProcessNode = winningNode;
Log.Information("Historian SDK connection opened to {Server}:{Port}", winningNode, _config.Port);
}
}
private void HandleConnectionError(Exception? ex = null)
{
lock (_connectionLock)
{
if (_connection == null) return;
try
{
_connection.CloseConnection(out _);
_connection.Dispose();
}
catch (Exception disposeEx)
{
Log.Debug(disposeEx, "Error disposing Historian SDK connection during error recovery");
}
_connection = null;
string? failedNode;
lock (_healthLock)
{
failedNode = _activeProcessNode;
_activeProcessNode = null;
}
if (failedNode != null) _picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
Log.Warning(ex, "Historian SDK connection reset (node={Node})", failedNode ?? "(unknown)");
}
}
private void EnsureEventConnected()
{
if (_disposed)
throw new ObjectDisposedException(nameof(HistorianDataSource));
if (Volatile.Read(ref _eventConnection) != null) return;
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Event);
lock (_eventConnectionLock)
{
if (_disposed)
{
conn.CloseConnection(out _);
conn.Dispose();
throw new ObjectDisposedException(nameof(HistorianDataSource));
}
if (_eventConnection != null)
{
conn.CloseConnection(out _);
conn.Dispose();
return;
}
_eventConnection = conn;
lock (_healthLock) _activeEventNode = winningNode;
Log.Information("Historian SDK event connection opened to {Server}:{Port}", winningNode, _config.Port);
}
}
/// <summary>
/// Internal exception signalling that <c>StartQuery</c> returned an SDK error
/// whose code is <em>query-class</em> (bad tag name, unsupported aggregate, etc.)
/// and the shared SDK connection therefore must NOT be reset. The outer catch
/// re-throws this so the IPC frame handler surfaces <c>Success=false</c> without
/// touching the connection. See Driver.Historian.Wonderware-008.
/// </summary>
internal sealed class QueryClassStartQueryException : InvalidOperationException
{
/// <summary>Gets the error code that caused the exception.</summary>
public HistorianAccessError.ErrorValue Code { get; }
/// <summary>Initializes a new instance of the <see cref="QueryClassStartQueryException"/> class.</summary>
/// <param name="message">The exception message.</param>
/// <param name="code">The historian access error code.</param>
public QueryClassStartQueryException(string message, HistorianAccessError.ErrorValue code)
: base(message)
{
Code = code;
}
}
/// <summary>
/// Centralised <c>StartQuery</c>-failure handler. Throws so the caller surfaces
/// <c>Success=false</c> in the IPC reply (the previous return-empty-with-success
/// behaviour made an SDK error look like "no data in range" to the client). The
/// connection is only reset when the error code is connection-class —
/// query-class failures (bad tag name, unsupported aggregate, etc.) must leave
/// the shared SDK connection intact, otherwise a burst of bad-tag queries cycles
/// the connection and pushes a healthy cluster node into cooldown.
/// See Driver.Historian.Wonderware-008.
/// </summary>
private void HandleStartQueryFailure(
string operation, HistorianAccessError error, bool isEventConnection)
{
var code = error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure;
var description = error?.ErrorDescription ?? string.Empty;
var connectionClass = IsConnectionClassError(code);
Log.Warning(
"Historian SDK StartQuery failed: {Operation} -> {Code} ({Desc}) [{Kind}]",
operation, code, description,
connectionClass ? "connection-class" : "query-class");
RecordFailure($"{operation}: {code}");
var message = $"Historian SDK StartQuery failed for {operation}: {code} ({description})";
if (connectionClass)
{
if (isEventConnection) HandleEventConnectionError();
else HandleConnectionError();
throw new InvalidOperationException(message);
}
// Query-class — the outer catch block must NOT call HandleConnectionError on this.
throw new QueryClassStartQueryException(message, code);
}
private void HandleEventConnectionError(Exception? ex = null)
{
lock (_eventConnectionLock)
{
if (_eventConnection == null) return;
try
{
_eventConnection.CloseConnection(out _);
_eventConnection.Dispose();
}
catch (Exception disposeEx)
{
Log.Debug(disposeEx, "Error disposing Historian SDK event connection during error recovery");
}
_eventConnection = null;
string? failedNode;
lock (_healthLock)
{
failedNode = _activeEventNode;
_activeEventNode = null;
}
if (failedNode != null) _picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
Log.Warning(ex, "Historian SDK event connection reset (node={Node})", failedNode ?? "(unknown)");
}
}
/// <summary>Reads raw historical samples for the specified tag.</summary>
/// <param name="tagName">The tag name.</param>
/// <param name="startTime">The start time for the query.</param>
/// <param name="endTime">The end time for the query.</param>
/// <param name="maxValues">The maximum number of values to return.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public Task<List<HistorianSample>> ReadRawAsync(
string tagName, DateTime startTime, DateTime endTime, int maxValues,
CancellationToken ct = default)
{
var results = new List<HistorianSample>();
// Driver.Historian.Wonderware-010: wire RequestTimeoutSeconds into the read path
// so a hung StartQuery / slow MoveNext can't block the TCP connection thread forever.
using var requestCts = BuildRequestCts(_config, ct);
var token = requestCts.Token;
try
{
EnsureConnected();
using var query = _connection!.CreateHistoryQuery();
var args = new HistoryQueryArgs
{
TagNames = new StringCollection { tagName },
StartDateTime = startTime,
EndDateTime = endTime,
RetrievalMode = HistorianRetrievalMode.Full
};
if (maxValues > 0)
args.BatchSize = (uint)maxValues;
else if (_config.MaxValuesPerRead > 0)
args.BatchSize = (uint)_config.MaxValuesPerRead;
if (!query.StartQuery(args, out var error))
{
HandleStartQueryFailure(
$"raw query for tag '{tagName}'", error, isEventConnection: false);
}
var count = 0;
var limit = maxValues > 0 ? maxValues : _config.MaxValuesPerRead;
while (query.MoveNext(out error))
{
token.ThrowIfCancellationRequested();
var result = query.QueryResult;
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
results.Add(new HistorianSample
{
Value = SelectValue(result),
TimestampUtc = timestamp,
Quality = (byte)(result.OpcQuality & 0xFF),
});
count++;
if (limit > 0 && count >= limit) break;
}
query.EndQuery(out _);
RecordSuccess();
}
catch (OperationCanceledException) { throw; }
catch (ObjectDisposedException) { throw; }
catch (QueryClassStartQueryException)
{
// Query-class StartQuery failure — HandleStartQueryFailure already logged
// and recorded. Re-throw so the IPC layer surfaces Success=false instead of
// returning an empty list (which would look like "no data in range"). The
// connection is deliberately NOT reset. See Driver.Historian.Wonderware-008.
throw;
}
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead raw failed for {Tag}", tagName);
RecordFailure($"raw: {ex.Message}");
HandleConnectionError(ex);
throw;
}
Log.Debug("HistoryRead raw: {Tag} returned {Count} values ({Start} to {End})",
tagName, results.Count, startTime, endTime);
return Task.FromResult(results);
}
/// <summary>Reads aggregate historical samples for the specified tag.</summary>
/// <param name="tagName">The tag name.</param>
/// <param name="startTime">The start time for the query.</param>
/// <param name="endTime">The end time for the query.</param>
/// <param name="intervalMs">The interval in milliseconds.</param>
/// <param name="aggregateColumn">The aggregate column name.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(
string tagName, DateTime startTime, DateTime endTime,
double intervalMs, string aggregateColumn,
CancellationToken ct = default)
{
var results = new List<HistorianAggregateSample>();
// Driver.Historian.Wonderware-010: outer safety timeout — see ReadRawAsync.
using var requestCts = BuildRequestCts(_config, ct);
var token = requestCts.Token;
try
{
EnsureConnected();
using var query = _connection!.CreateAnalogSummaryQuery();
var args = new AnalogSummaryQueryArgs
{
TagNames = new StringCollection { tagName },
StartDateTime = startTime,
EndDateTime = endTime,
Resolution = (ulong)intervalMs
};
if (!query.StartQuery(args, out var error))
{
HandleStartQueryFailure(
$"aggregate query for tag '{tagName}'", error, isEventConnection: false);
}
// Apply the same bucket cap as the raw-read path so a wide time range with a
// small IntervalMs cannot produce an unbounded result set that would overflow
// the 16 MiB FrameWriter frame cap and lose the entire reply.
var bucketLimit = _config.MaxValuesPerRead;
var bucketCount = 0;
while (query.MoveNext(out error))
{
token.ThrowIfCancellationRequested();
var result = query.QueryResult;
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
var value = ExtractAggregateValue(result, aggregateColumn);
results.Add(new HistorianAggregateSample
{
Value = value,
TimestampUtc = timestamp,
});
bucketCount++;
if (bucketLimit > 0 && bucketCount >= bucketLimit)
{
Log.Warning(
"HistoryRead aggregate ({Aggregate}): {Tag} truncated at {Limit} buckets — widen IntervalMs or reduce time range",
aggregateColumn, tagName, bucketLimit);
break;
}
}
query.EndQuery(out _);
RecordSuccess();
}
catch (OperationCanceledException) { throw; }
catch (ObjectDisposedException) { throw; }
catch (QueryClassStartQueryException) { throw; } // see ReadRawAsync — keep connection
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead aggregate failed for {Tag}", tagName);
RecordFailure($"aggregate: {ex.Message}");
HandleConnectionError(ex);
throw;
}
Log.Debug("HistoryRead aggregate ({Aggregate}): {Tag} returned {Count} values",
aggregateColumn, tagName, results.Count);
return Task.FromResult(results);
}
/// <summary>Reads historical samples at specific timestamps for the specified tag.</summary>
/// <param name="tagName">The tag name.</param>
/// <param name="timestamps">The timestamps to read.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public Task<List<HistorianSample>> ReadAtTimeAsync(
string tagName, DateTime[] timestamps,
CancellationToken ct = default)
{
var results = new List<HistorianSample>();
if (timestamps == null || timestamps.Length == 0)
return Task.FromResult(results);
// Driver.Historian.Wonderware-010: outer safety timeout — see ReadRawAsync.
using var requestCts = BuildRequestCts(_config, ct);
var token = requestCts.Token;
try
{
EnsureConnected();
foreach (var timestamp in timestamps)
{
token.ThrowIfCancellationRequested();
using var query = _connection!.CreateHistoryQuery();
var args = new HistoryQueryArgs
{
TagNames = new StringCollection { tagName },
StartDateTime = timestamp,
EndDateTime = timestamp,
RetrievalMode = HistorianRetrievalMode.Interpolated,
BatchSize = 1
};
if (!query.StartQuery(args, out var error))
{
// Driver.Historian.Wonderware-014: classify the failure like the raw /
// aggregate / event paths. A connection-class code means the shared
// connection is dead — throw so the whole at-time read aborts and the IPC
// layer surfaces Success=false (the outer catch resets the connection and
// marks the node failed). Without this, every remaining timestamp would
// re-fail StartQuery on the dead connection and the method would still
// report Success=true with an all-Bad result, never failing over. A
// query-class / no-data code keeps the connection and records a Bad sample
// for just this timestamp.
if (ShouldResetConnectionForStartQueryFailure(error))
{
var code = error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure;
throw new InvalidOperationException(
$"Historian SDK StartQuery failed for at-time query of tag '{tagName}': {code} ({error?.ErrorDescription})");
}
results.Add(new HistorianSample
{
Value = null,
TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc),
Quality = 0, // Bad
});
continue;
}
if (query.MoveNext(out error))
{
var result = query.QueryResult;
results.Add(new HistorianSample
{
Value = SelectValue(result),
TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc),
Quality = (byte)(result.OpcQuality & 0xFF),
});
}
else
{
results.Add(new HistorianSample
{
Value = null,
TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc),
Quality = 0,
});
}
query.EndQuery(out _);
}
RecordSuccess();
}
catch (OperationCanceledException) { throw; }
catch (ObjectDisposedException) { throw; }
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead at-time failed for {Tag}", tagName);
RecordFailure($"at-time: {ex.Message}");
HandleConnectionError(ex);
throw;
}
Log.Debug("HistoryRead at-time: {Tag} returned {Count} values for {Timestamps} timestamps",
tagName, results.Count, timestamps.Length);
return Task.FromResult(results);
}
/// <summary>Reads historical events within the specified time range.</summary>
/// <param name="sourceName">The optional event source name filter.</param>
/// <param name="startTime">The start time for the query.</param>
/// <param name="endTime">The end time for the query.</param>
/// <param name="maxEvents">The maximum number of events to return.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public Task<List<HistorianEventDto>> ReadEventsAsync(
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
CancellationToken ct = default)
{
var results = new List<HistorianEventDto>();
// Driver.Historian.Wonderware-010: outer safety timeout — see ReadRawAsync.
using var requestCts = BuildRequestCts(_config, ct);
var token = requestCts.Token;
try
{
EnsureEventConnected();
using var query = _eventConnection!.CreateEventQuery();
var args = new EventQueryArgs
{
StartDateTime = startTime,
EndDateTime = endTime,
EventCount = maxEvents > 0 ? (uint)maxEvents : (uint)_config.MaxValuesPerRead,
QueryType = HistorianEventQueryType.Events,
EventOrder = HistorianEventOrder.Ascending
};
if (!string.IsNullOrEmpty(sourceName))
{
query.AddEventFilter("Source", HistorianComparisionType.Equal, sourceName, out _);
}
if (!query.StartQuery(args, out var error))
{
HandleStartQueryFailure(
$"event query for source '{sourceName ?? "(all)"}'", error, isEventConnection: true);
}
var count = 0;
while (query.MoveNext(out error))
{
token.ThrowIfCancellationRequested();
results.Add(ToDto(query.QueryResult));
count++;
if (maxEvents > 0 && count >= maxEvents) break;
}
query.EndQuery(out _);
RecordSuccess();
}
catch (OperationCanceledException) { throw; }
catch (ObjectDisposedException) { throw; }
catch (QueryClassStartQueryException) { throw; } // see ReadRawAsync — keep connection
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead events failed for source {Source}", sourceName ?? "(all)");
RecordFailure($"events: {ex.Message}");
HandleEventConnectionError(ex);
throw;
}
Log.Debug("HistoryRead events: source={Source} returned {Count} events ({Start} to {End})",
sourceName ?? "(all)", results.Count, startTime, endTime);
return Task.FromResult(results);
}
private static HistorianEventDto ToDto(HistorianEvent evt)
{
// The ArchestrA SDK marks these properties obsolete but still returns them; their
// successors aren't wired in the version we bind against. Using them is the documented
// v1 behavior — suppressed locally instead of project-wide so any non-event use of
// deprecated SDK surface still surfaces as an error.
#pragma warning disable CS0618
return new HistorianEventDto
{
Id = evt.Id,
Source = evt.Source,
EventTime = evt.EventTime,
ReceivedTime = evt.ReceivedTime,
DisplayText = evt.DisplayText,
Severity = (ushort)evt.Severity
};
#pragma warning restore CS0618
}
/// <summary>
/// Selects the typed value from a <see cref="HistoryQueryResult"/> row.
/// <para>
/// <b>SDK limitation:</b> <c>HistoryQueryResult</c> exposes only <c>Value</c>
/// (double) and <c>StringValue</c> (string) — there is no tag data-type field on
/// the result. The correct approach would be to branch on the tag's declared
/// data type, but the bound version of <c>aahClientManaged</c> does not surface
/// it per query result. The heuristic below is the best available: prefer
/// <c>StringValue</c> only when it is non-empty AND <c>Value</c> is zero,
/// because string tags in the Historian SDK always project to <c>Value=0</c>
/// while numeric tags may legitimately sample to zero (in which case the SDK
/// does not populate <c>StringValue</c>). A numeric tag at exactly zero with a
/// non-empty formatted <c>StringValue</c> (e.g. "0.00") would be mis-reported
/// as a string; this is a known edge case of the SDK binding.
/// </para>
/// </summary>
/// <param name="result">The history query result.</param>
internal static object? SelectValue(HistoryQueryResult result)
=> SelectValueFromPair(result.Value, result.StringValue);
/// <summary>
/// SDK-independent overload of the string-vs-numeric heuristic. Exposed so unit
/// tests can pin the logic without having to instantiate the SDK
/// <see cref="HistoryQueryResult"/> (whose internal property initialisers make
/// it impractical to fake). See Driver.Historian.Wonderware-012.
/// </summary>
/// <param name="value">The numeric value.</param>
/// <param name="stringValue">The string value.</param>
internal static object? SelectValueFromPair(double value, string? stringValue)
{
if (!string.IsNullOrEmpty(stringValue) && value == 0)
return stringValue;
return value;
}
/// <summary>Extracts the specified aggregate value from an analog summary query result.</summary>
/// <param name="result">The analog summary query result.</param>
/// <param name="column">The aggregate column name.</param>
internal static double? ExtractAggregateValue(AnalogSummaryQueryResult result, string column)
{
switch (column)
{
case "Average": return result.Average;
case "Minimum": return result.Minimum;
case "Maximum": return result.Maximum;
case "ValueCount": return result.ValueCount;
case "First": return result.First;
case "Last": return result.Last;
case "StdDev": return result.StdDev;
default: return null;
}
}
/// <summary>Disposes the historian data source and releases its resources.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try
{
_connection?.CloseConnection(out _);
_connection?.Dispose();
}
catch (Exception ex)
{
Log.Warning(ex, "Error closing Historian SDK connection");
}
try
{
_eventConnection?.CloseConnection(out _);
_eventConnection?.Dispose();
}
catch (Exception ex)
{
Log.Warning(ex, "Error closing Historian SDK event connection");
}
_connection = null;
_eventConnection = null;
}
}
}
@@ -1,29 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// SDK-free representation of a Historian event record. Prevents ArchestrA types from
/// leaking beyond <c>HistorianDataSource</c>.
/// </summary>
public sealed class HistorianEventDto
{
/// <summary>Gets or sets the unique identifier for the event.</summary>
public Guid Id { get; set; }
/// <summary>Gets or sets the source of the event.</summary>
public string? Source { get; set; }
/// <summary>Gets or sets the time when the event occurred.</summary>
public DateTime EventTime { get; set; }
/// <summary>Gets or sets the time when the event was received.</summary>
public DateTime ReceivedTime { get; set; }
/// <summary>Gets or sets the display text for the event.</summary>
public string? DisplayText { get; set; }
/// <summary>Gets or sets the severity level of the event.</summary>
public ushort Severity { get; set; }
}
}
@@ -1,41 +0,0 @@
using System;
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Point-in-time runtime health of the historian subsystem — consumed by the status dashboard
/// via an IPC health query (not wired in PR #5; deferred).
/// </summary>
public sealed class HistorianHealthSnapshot
{
/// <summary>Gets or sets the total number of queries executed.</summary>
public long TotalQueries { get; set; }
/// <summary>Gets or sets the total number of successful queries.</summary>
public long TotalSuccesses { get; set; }
/// <summary>Gets or sets the total number of failed queries.</summary>
public long TotalFailures { get; set; }
/// <summary>Gets or sets the number of consecutive failures.</summary>
public int ConsecutiveFailures { get; set; }
/// <summary>Gets or sets the time of the last successful query.</summary>
public DateTime? LastSuccessTime { get; set; }
/// <summary>Gets or sets the time of the last failed query.</summary>
public DateTime? LastFailureTime { get; set; }
/// <summary>Gets or sets the last error message, if any.</summary>
public string? LastError { get; set; }
/// <summary>Gets or sets a value indicating whether the process connection is open.</summary>
public bool ProcessConnectionOpen { get; set; }
/// <summary>Gets or sets a value indicating whether the event connection is open.</summary>
public bool EventConnectionOpen { get; set; }
/// <summary>Gets or sets the name of the active process node.</summary>
public string? ActiveProcessNode { get; set; }
/// <summary>Gets or sets the name of the active event node.</summary>
public string? ActiveEventNode { get; set; }
/// <summary>Gets or sets the total number of cluster nodes.</summary>
public int NodeCount { get; set; }
/// <summary>Gets or sets the number of healthy cluster nodes.</summary>
public int HealthyNodeCount { get; set; }
/// <summary>Gets or sets the list of cluster node states.</summary>
public List<HistorianClusterNodeState> Nodes { get; set; } = new();
}
}
@@ -1,48 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
/// <summary>
/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's <c>OpcQuality</c>)
/// to an OPC UA <c>StatusCode</c> uint. Preserves specific codes (BadNotConnected,
/// UncertainSubNormal, etc.) instead of collapsing to Good/Uncertain/Bad categories.
/// Mirrors v1 <c>QualityMapper.MapToOpcUaStatusCode</c> without pulling in OPC UA types —
/// the returned value is the 32-bit OPC UA <c>StatusCode</c> wire encoding that the Proxy
/// surfaces directly as <c>DataValueSnapshot.StatusCode</c>.
/// </summary>
public static class HistorianQualityMapper
{
/// <summary>
/// Map an 8-bit OPC DA quality byte to the corresponding OPC UA StatusCode. The byte
/// family bits decide the category (Good &gt;= 192, Uncertain 64-191, Bad 0-63); the
/// low-nibble subcode selects the specific code.
/// </summary>
/// <param name="q">The OPC DA quality byte.</param>
/// <returns>The corresponding OPC UA status code.</returns>
public static uint Map(byte q) => q switch
{
// Good family (192+)
192 => 0x00000000u, // Good
216 => 0x00D80000u, // Good_LocalOverride
// Uncertain family (64-191)
64 => 0x40000000u, // Uncertain
68 => 0x40900000u, // Uncertain_LastUsableValue
80 => 0x40930000u, // Uncertain_SensorNotAccurate
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
88 => 0x40950000u, // Uncertain_SubNormal
// Bad family (0-63)
0 => 0x80000000u, // Bad
4 => 0x80890000u, // Bad_ConfigurationError
8 => 0x808A0000u, // Bad_NotConnected
12 => 0x808B0000u, // Bad_DeviceFailure
16 => 0x808C0000u, // Bad_SensorFailure
20 => 0x80050000u, // Bad_CommunicationError
24 => 0x808D0000u, // Bad_OutOfService
32 => 0x80320000u, // Bad_WaitingForInitialData
// Unknown code — fall back to the category so callers still get a sensible bucket.
_ when q >= 192 => 0x00000000u,
_ when q >= 64 => 0x40000000u,
_ => 0x80000000u,
};
}
@@ -1,35 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// OPC-UA-free representation of a single historical data point. The sidecar serialises
/// these onto the TCP wire (<c>HistorianSampleDto</c>) for the .NET 10
/// <c>WonderwareHistorianClient</c>, which maps quality and value into OPC UA
/// <c>DataValue</c> on its side. Raw OPC DA quality byte is preserved so the client
/// can reuse the same quality mapper it already uses for live reads.
/// </summary>
public sealed class HistorianSample
{
/// <summary>Gets or sets the historical data value.</summary>
public object? Value { get; set; }
/// <summary>Gets or sets the raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality).</summary>
public byte Quality { get; set; }
/// <summary>Gets or sets the UTC timestamp of the historical sample.</summary>
public DateTime TimestampUtc { get; set; }
}
/// <summary>
/// Result of <see cref="IHistorianDataSource.ReadAggregateAsync"/>. When <see cref="Value"/> is
/// null the aggregate is unavailable for that bucket — the client maps to <c>BadNoData</c>.
/// </summary>
public sealed class HistorianAggregateSample
{
/// <summary>Gets or sets the aggregate value, or null if unavailable.</summary>
public double? Value { get; set; }
/// <summary>Gets or sets the UTC timestamp of the aggregate sample.</summary>
public DateTime TimestampUtc { get; set; }
}
}
@@ -1,32 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// The actual aahClientManaged-bound writer. Extracted so unit tests can
/// substitute a fake without touching the SDK; the production
/// implementation lives in <see cref="SdkAlarmHistorianWriteBackend"/>.
/// </summary>
/// <remarks>
/// Implementations are responsible for connection management + cluster
/// failover. The wrapping <see cref="AahClientManagedAlarmEventWriter"/>
/// handles batch-level orchestration but delegates the per-event SDK call
/// here so the unit tests can drive every documented MxStatus outcome
/// without an installed AVEVA Historian.
/// </remarks>
public interface IAlarmHistorianWriteBackend
{
/// <summary>
/// Persist the supplied events to the historian. Returns one outcome per
/// input slot in the same order — must always return an array of the same
/// length as <paramref name="events"/>.
/// </summary>
/// <param name="events">The events to write to the historian.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<AlarmHistorianWriteOutcome[]> WriteBatchAsync(
AlarmHistorianEventDto[] events,
CancellationToken cancellationToken);
}
}
@@ -1,105 +0,0 @@
using System;
using System.Threading;
using ArchestrA;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Creates and opens Historian SDK connections. Extracted so tests can inject fakes that
/// control connection success, failure, and timeout behavior.
/// </summary>
internal interface IHistorianConnectionFactory
{
/// <summary>
/// Opens a Historian SDK connection. <paramref name="readOnly"/> defaults to
/// <c>true</c> for the query path; the alarm-event write backend passes
/// <c>false</c> because <c>HistorianAccess.AddStreamedValue</c> fails with
/// <c>WriteToReadOnlyFile</c> on a read-only session.
/// </summary>
/// <param name="config">The historian configuration.</param>
/// <param name="type">The type of connection to create.</param>
/// <param name="readOnly">Whether the connection should be read-only.</param>
/// <returns>An open HistorianAccess connection.</returns>
HistorianAccess CreateAndConnect(
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true);
}
/// <summary>Production implementation — opens real Historian SDK connections.</summary>
internal sealed class SdkHistorianConnectionFactory : IHistorianConnectionFactory
{
/// <summary>Creates and connects a Historian SDK connection.</summary>
/// <param name="config">The historian configuration.</param>
/// <param name="type">The type of connection to create.</param>
/// <param name="readOnly">Whether the connection should be read-only.</param>
/// <returns>An open HistorianAccess connection.</returns>
public HistorianAccess CreateAndConnect(
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
{
var conn = new HistorianAccess();
var args = BuildConnectionArgs(config, type, readOnly);
if (!conn.OpenConnection(args, out var error))
{
conn.Dispose();
throw new InvalidOperationException(
$"Failed to open Historian SDK connection to {config.ServerName}:{config.Port}: {error.ErrorCode}");
}
var timeoutMs = config.CommandTimeoutSeconds * 1000;
var elapsed = 0;
while (elapsed < timeoutMs)
{
var status = new HistorianConnectionStatus();
conn.GetConnectionStatus(ref status);
if (status.ConnectedToServer)
return conn;
if (status.ErrorOccurred)
{
conn.Dispose();
throw new InvalidOperationException(
$"Historian SDK connection failed: {status.Error}");
}
Thread.Sleep(250);
elapsed += 250;
}
conn.Dispose();
throw new TimeoutException(
$"Historian SDK connection to {config.ServerName}:{config.Port} timed out after {config.CommandTimeoutSeconds}s");
}
/// <summary>
/// Builds the <see cref="HistorianConnectionArgs"/> for a connection. Pure (no SDK
/// side effects) so the read-only-vs-write argument shaping is unit-testable.
/// </summary>
/// <param name="config">The historian configuration.</param>
/// <param name="type">The type of connection to create.</param>
/// <param name="readOnly">Whether the connection should be read-only.</param>
/// <returns>The configured connection arguments.</returns>
internal static HistorianConnectionArgs BuildConnectionArgs(
HistorianConfiguration config, HistorianConnectionType type, bool readOnly)
{
var args = new HistorianConnectionArgs
{
ServerName = config.ServerName,
TcpPort = (ushort)config.Port,
IntegratedSecurity = config.IntegratedSecurity,
UseArchestrAUser = config.IntegratedSecurity,
ConnectionType = type,
ReadOnly = readOnly,
PacketTimeout = (uint)(config.CommandTimeoutSeconds * 1000)
};
if (!config.IntegratedSecurity)
{
args.UserName = config.UserName ?? string.Empty;
args.Password = config.Password ?? string.Empty;
}
return args;
}
}
}
@@ -1,65 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// OPC-UA-free surface for the Wonderware Historian subsystem inside the historian
/// sidecar process. Implementations read via the aahClient* SDK; the .NET 10
/// <c>WonderwareHistorianClient</c> on the other side of the TCP IPC maps
/// returned samples to OPC UA <c>DataValue</c>. The v1 Galaxy.Host / Proxy hosts
/// this lived in retired in PR 7.2.
/// </summary>
public interface IHistorianDataSource : IDisposable
{
/// <summary>Reads raw historical samples asynchronously.</summary>
/// <param name="tagName">The tag name to read from.</param>
/// <param name="startTime">The start time of the time range.</param>
/// <param name="endTime">The end time of the time range.</param>
/// <param name="maxValues">The maximum number of values to return.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation that returns a list of historian samples.</returns>
Task<List<HistorianSample>> ReadRawAsync(
string tagName, DateTime startTime, DateTime endTime, int maxValues,
CancellationToken ct = default);
/// <summary>Reads aggregate historical samples asynchronously.</summary>
/// <param name="tagName">The tag name to read from.</param>
/// <param name="startTime">The start time of the time range.</param>
/// <param name="endTime">The end time of the time range.</param>
/// <param name="intervalMs">The interval in milliseconds for aggregation.</param>
/// <param name="aggregateColumn">The column to aggregate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation that returns a list of aggregate samples.</returns>
Task<List<HistorianAggregateSample>> ReadAggregateAsync(
string tagName, DateTime startTime, DateTime endTime,
double intervalMs, string aggregateColumn,
CancellationToken ct = default);
/// <summary>Reads historical samples at specific times asynchronously.</summary>
/// <param name="tagName">The tag name to read from.</param>
/// <param name="timestamps">The array of timestamps at which to read values.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation that returns a list of historian samples.</returns>
Task<List<HistorianSample>> ReadAtTimeAsync(
string tagName, DateTime[] timestamps,
CancellationToken ct = default);
/// <summary>Reads historical events asynchronously.</summary>
/// <param name="sourceName">The source name to filter events, or null for all sources.</param>
/// <param name="startTime">The start time of the time range.</param>
/// <param name="endTime">The end time of the time range.</param>
/// <param name="maxEvents">The maximum number of events to return.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation that returns a list of historian events.</returns>
Task<List<HistorianEventDto>> ReadEventsAsync(
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
CancellationToken ct = default);
/// <summary>Gets a health snapshot of the data source.</summary>
/// <returns>A HistorianHealthSnapshot containing the current health information.</returns>
HistorianHealthSnapshot GetHealthSnapshot();
}
}
@@ -1,398 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Production <see cref="IAlarmHistorianWriteBackend"/> backed by AVEVA Historian's
/// <c>aahClientManaged</c> SDK. Each <see cref="AlarmHistorianEventDto"/> is written via
/// <c>HistorianAccess.AddStreamedValue(HistorianEvent, out HistorianAccessError)</c> —
/// the alarm-event write entry point pinned during PR C.1.
/// </summary>
/// <remarks>
/// <para>
/// The write path needs its <b>own</b> connection. The query-side
/// <see cref="HistorianDataSource"/> opens <c>ReadOnly</c> sessions, and
/// <c>AddStreamedValue</c> on a read-only session fails with
/// <c>WriteToReadOnlyFile</c>. This backend therefore opens a dedicated
/// <c>ReadOnly = false</c> connection; it shares
/// <see cref="HistorianClusterEndpointPicker"/> for node selection and failover but
/// not the connection object itself.
/// </para>
/// <para>
/// Per-event <c>HistorianAccessError.ErrorValue</c> codes map onto
/// <see cref="AlarmHistorianWriteOutcome"/> via
/// <see cref="AahClientManagedAlarmEventWriter.MapOutcome"/>. A connection-class
/// error aborts the remainder of the batch as
/// <see cref="AlarmHistorianWriteOutcome.RetryPlease"/> and resets the connection so
/// the next drain tick reconnects — possibly to a different cluster node.
/// </para>
/// <para>
/// The exact <c>HistorianEvent</c> field set required by the Historian is confirmed
/// against a live install during the PR D.1 rollout smoke; <see cref="ToHistorianEvent"/>
/// maps the unambiguous fields and carries operator comment / condition id as event
/// properties.
/// </para>
/// </remarks>
public sealed class SdkAlarmHistorianWriteBackend : IAlarmHistorianWriteBackend, IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<SdkAlarmHistorianWriteBackend>();
// ErrorValue codes that mean the connection/server is the problem (transient) rather
// than the event payload. These abort the rest of the batch and trigger a reconnect.
private static readonly HashSet<HistorianAccessError.ErrorValue> ConnectionErrors =
new HashSet<HistorianAccessError.ErrorValue>
{
HistorianAccessError.ErrorValue.FailedToConnect,
HistorianAccessError.ErrorValue.FailedToCreateSession,
HistorianAccessError.ErrorValue.NoReply,
HistorianAccessError.ErrorValue.NotReady,
HistorianAccessError.ErrorValue.NotInitialized,
HistorianAccessError.ErrorValue.Stopping,
HistorianAccessError.ErrorValue.Win32Exception,
HistorianAccessError.ErrorValue.InvalidResponse,
// WriteToReadOnlyFile is a connection-configuration fault, not an event-payload
// fault: the session was opened without ReadOnly = false (a misconfiguration or
// a regression). The event itself is fine, so it must NOT be dead-lettered.
// Classifying it here aborts the batch and resets the connection so the
// reconnect path re-opens a writable (ReadOnly = false) session; the deferred
// events drain on the next tick. See Driver.Historian.Wonderware-001.
HistorianAccessError.ErrorValue.WriteToReadOnlyFile,
};
// ErrorValue codes that mean the event itself is malformed — permanent, never retried.
private static readonly HashSet<HistorianAccessError.ErrorValue> MalformedErrors =
new HashSet<HistorianAccessError.ErrorValue>
{
HistorianAccessError.ErrorValue.InvalidArgument,
HistorianAccessError.ErrorValue.ValidationFailed,
HistorianAccessError.ErrorValue.NullPointerArgument,
HistorianAccessError.ErrorValue.NotImplemented,
HistorianAccessError.ErrorValue.NotApplicable,
};
private readonly HistorianConfiguration _config;
private readonly IHistorianConnectionFactory _factory;
private readonly HistorianClusterEndpointPicker _picker;
private readonly object _connectionLock = new object();
private HistorianAccess? _connection;
private string? _activeNode;
private bool _disposed;
/// <summary>Initializes a new instance using the default SDK connection factory.</summary>
/// <param name="config">The historian configuration.</param>
public SdkAlarmHistorianWriteBackend(HistorianConfiguration config)
: this(config, new SdkHistorianConnectionFactory(), null) { }
/// <summary>Initializes a new instance with injected dependencies (for testing).</summary>
/// <param name="config">The historian configuration.</param>
/// <param name="factory">The connection factory.</param>
/// <param name="picker">The cluster endpoint picker, or null to use a new instance.</param>
internal SdkAlarmHistorianWriteBackend(
HistorianConfiguration config,
IHistorianConnectionFactory factory,
HistorianClusterEndpointPicker? picker = null)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
_picker = picker ?? new HistorianClusterEndpointPicker(config);
}
/// <summary>Writes a batch of alarm events to the historian, returning outcomes for each event.</summary>
/// <param name="events">The alarm events to write.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An array of outcomes corresponding to each input event.</returns>
public Task<AlarmHistorianWriteOutcome[]> WriteBatchAsync(
AlarmHistorianEventDto[] events,
CancellationToken cancellationToken)
{
if (events is null || events.Length == 0)
{
return Task.FromResult(new AlarmHistorianWriteOutcome[0]);
}
var outcomes = new AlarmHistorianWriteOutcome[events.Length];
HistorianAccess connection;
try
{
connection = EnsureConnected();
}
catch (ObjectDisposedException)
{
throw;
}
catch (Exception ex)
{
// No reachable node — defer the whole batch so the lmxopcua-side SQLite
// store-and-forward sink retains the rows for the next drain tick.
Log.Warning(ex,
"Alarm historian write connection unavailable — deferring {Count} event(s) as RetryPlease",
events.Length);
FillRemaining(outcomes, 0, AlarmHistorianWriteOutcome.RetryPlease);
return Task.FromResult(outcomes);
}
for (var i = 0; i < events.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var historianEvent = ToHistorianEvent(events[i]);
if (connection.AddStreamedValue(historianEvent, out var error))
{
outcomes[i] = AlarmHistorianWriteOutcome.Ack;
continue;
}
var code = error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure;
if (ConnectionErrors.Contains(code))
{
// Connection died mid-batch — drop it and defer this event + the rest.
Log.Warning(
"Alarm historian write hit connection-level error {Code} ({Desc}); resetting connection, deferring {Remaining} event(s)",
code, error?.ErrorDescription, events.Length - i);
HandleConnectionError(error?.ErrorDescription);
FillRemaining(outcomes, i, AlarmHistorianWriteOutcome.RetryPlease);
return Task.FromResult(outcomes);
}
outcomes[i] = ClassifyOutcome(code);
Log.Warning(
"Alarm historian write rejected event {EventId}: {Code} ({Desc}) -> {Outcome}",
events[i].EventId, code, error?.ErrorDescription, outcomes[i]);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Transport-level throw (SDK marshalling fault, broken connection) —
// reset and defer this event + the rest.
Log.Warning(ex,
"Alarm historian write threw for event {EventId}; resetting connection, deferring {Remaining} event(s)",
events[i].EventId, events.Length - i);
HandleConnectionError(ex.Message);
FillRemaining(outcomes, i, AlarmHistorianWriteOutcome.RetryPlease);
return Task.FromResult(outcomes);
}
}
return Task.FromResult(outcomes);
}
/// <summary>
/// Maps an <see cref="AlarmHistorianEventDto"/> onto the SDK's
/// <c>HistorianEvent</c>. Operator comment and originating condition id ride as
/// event properties — operator-comment fidelity is the field the value-driven
/// fallback path cannot carry.
/// </summary>
/// <param name="dto">The alarm event data transfer object.</param>
/// <returns>The mapped HistorianEvent.</returns>
internal static HistorianEvent ToHistorianEvent(AlarmHistorianEventDto dto)
{
// The ArchestrA SDK marks these HistorianEvent members obsolete but still honours
// them on write; their successors aren't wired in the version we bind against.
// Using them is the documented v1 behaviour — mirrors HistorianDataSource.ToDto,
// suppressed locally so any other deprecated-surface use still surfaces as an error.
#pragma warning disable CS0618
var historianEvent = new HistorianEvent
{
IsAlarm = true,
Source = dto.SourceName ?? string.Empty,
EventType = string.IsNullOrEmpty(dto.AlarmType) ? "Alarm" : dto.AlarmType,
EventTime = new DateTime(dto.EventTimeUtcTicks, DateTimeKind.Utc),
ReceivedTime = DateTime.UtcNow,
Severity = dto.Severity,
DisplayText = dto.Message ?? string.Empty,
};
if (Guid.TryParse(dto.EventId, out var id))
{
historianEvent.Id = id;
}
else
{
// Driver.Historian.Wonderware-004: an unparseable / empty EventId previously
// left Id as Guid.Empty, which made every such alarm collide on the same id
// with no diagnostic. Synthesize a fresh Guid so each event still gets a
// unique identifier (the historian still accepts the write — outcome stays
// Ack — and the sender can correlate the synthesized id via the warning log).
var synthesized = Guid.NewGuid();
Log.Warning(
"Alarm historian event has non-parseable EventId {EventId} for source {Source}; synthesizing Id={SynthesizedId}",
dto.EventId ?? "(null)", dto.SourceName ?? "(none)", synthesized);
historianEvent.Id = synthesized;
}
#pragma warning restore CS0618
if (!string.IsNullOrEmpty(dto.AckComment))
{
historianEvent.AddProperty("Comment", dto.AckComment, out _);
}
if (!string.IsNullOrEmpty(dto.ConditionId))
{
historianEvent.AddProperty("ConditionId", dto.ConditionId, out _);
}
return historianEvent;
}
/// <summary>
/// Classifies a non-connection-class <c>HistorianAccessError.ErrorValue</c> into an
/// <see cref="AlarmHistorianWriteOutcome"/> by routing it through the shared
/// <see cref="AahClientManagedAlarmEventWriter.MapOutcome"/> mapping. Exposed for
/// unit tests — connection-class codes are handled separately by the batch loop.
/// </summary>
/// <param name="code">The error code to classify.</param>
/// <returns>The corresponding write outcome.</returns>
internal static AlarmHistorianWriteOutcome ClassifyOutcome(HistorianAccessError.ErrorValue code)
=> AahClientManagedAlarmEventWriter.MapOutcome(
(int)code,
isCommunicationError: ConnectionErrors.Contains(code),
isMalformedInput: MalformedErrors.Contains(code));
private static void FillRemaining(
AlarmHistorianWriteOutcome[] outcomes, int from, AlarmHistorianWriteOutcome value)
{
for (var i = from; i < outcomes.Length; i++)
{
outcomes[i] = value;
}
}
private HistorianAccess EnsureConnected()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SdkAlarmHistorianWriteBackend));
}
var existing = Volatile.Read(ref _connection);
if (existing != null) return existing;
var (conn, node) = ConnectToAnyHealthyNode();
lock (_connectionLock)
{
if (_disposed)
{
SafeClose(conn);
throw new ObjectDisposedException(nameof(SdkAlarmHistorianWriteBackend));
}
if (_connection != null)
{
SafeClose(conn);
return _connection;
}
_connection = conn;
_activeNode = node;
Log.Information("Alarm historian write connection opened to {Server}:{Port}", node, _config.Port);
return conn;
}
}
private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode()
{
var candidates = _picker.GetHealthyNodes();
if (candidates.Count == 0)
{
throw new InvalidOperationException(
_picker.NodeCount == 0
? "No historian nodes configured"
: $"All {_picker.NodeCount} historian nodes are in cooldown — no healthy endpoints");
}
Exception? lastException = null;
foreach (var node in candidates)
{
try
{
var conn = _factory.CreateAndConnect(
CloneConfigWithServerName(node), HistorianConnectionType.Event, readOnly: false);
_picker.MarkHealthy(node);
return (conn, node);
}
catch (Exception ex)
{
_picker.MarkFailed(node, ex.Message);
lastException = ex;
Log.Warning(ex, "Alarm historian node {Node} failed during write-connect; trying next", node);
}
}
throw new InvalidOperationException(
$"All {candidates.Count} healthy historian candidate(s) failed during write-connect: " +
(lastException?.Message ?? "(no detail)"),
lastException);
}
private void HandleConnectionError(string? detail)
{
lock (_connectionLock)
{
if (_connection == null) return;
SafeClose(_connection);
_connection = null;
var failedNode = _activeNode;
_activeNode = null;
if (failedNode != null) _picker.MarkFailed(failedNode, detail ?? "mid-batch failure");
Log.Warning("Alarm historian write connection reset (node={Node})", failedNode ?? "(unknown)");
}
}
private static void SafeClose(HistorianAccess conn)
{
try
{
conn.CloseConnection(out _);
conn.Dispose();
}
catch (Exception ex)
{
Log.Debug(ex, "Error closing alarm historian write connection");
}
}
private HistorianConfiguration CloneConfigWithServerName(string serverName) => new HistorianConfiguration
{
Enabled = _config.Enabled,
ServerName = serverName,
ServerNames = _config.ServerNames,
FailureCooldownSeconds = _config.FailureCooldownSeconds,
IntegratedSecurity = _config.IntegratedSecurity,
UserName = _config.UserName,
Password = _config.Password,
Port = _config.Port,
CommandTimeoutSeconds = _config.CommandTimeoutSeconds,
MaxValuesPerRead = _config.MaxValuesPerRead,
RequestTimeoutSeconds = _config.RequestTimeoutSeconds,
};
/// <summary>Disposes the connection and releases resources.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
lock (_connectionLock)
{
if (_connection != null)
{
SafeClose(_connection);
_connection = null;
}
}
}
}
}
@@ -1,270 +0,0 @@
using System;
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
// ============================================================================
// Wire DTOs for the sidecar TCP protocol. The sidecar speaks its own legacy
// shape (List<HistorianSample> etc.) — the .NET 10 client (PR 3.4) translates
// to / from Core.Abstractions.DataValueSnapshot + HistoricalEvent.
//
// Timestamps cross the wire as DateTime ticks (long) to dodge MessagePack's
// DateTime kind/timezone quirks; both sides convert with DateTime(ticks, Utc).
// ============================================================================
/// <summary>Single historical data point. Quality is the raw OPC DA byte; client maps to OPC UA StatusCode.</summary>
[MessagePackObject]
public sealed class HistorianSampleDto
{
/// <summary>MessagePack-serialized value bytes. Client deserializes per the tag's mx_data_type.</summary>
[Key(0)] public byte[]? ValueBytes { get; set; }
/// <summary>Raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality).</summary>
[Key(1)] public byte Quality { get; set; }
/// <summary>Gets or sets the timestamp in UTC ticks.</summary>
[Key(2)] public long TimestampUtcTicks { get; set; }
}
/// <summary>Aggregate bucket; <c>Value</c> is null when the aggregate is unavailable for the bucket.</summary>
[MessagePackObject]
public sealed class HistorianAggregateSampleDto
{
/// <summary>Gets or sets the aggregate value.</summary>
[Key(0)] public double? Value { get; set; }
/// <summary>Gets or sets the timestamp in UTC ticks.</summary>
[Key(1)] public long TimestampUtcTicks { get; set; }
}
/// <summary>Historian event row.</summary>
[MessagePackObject]
public sealed class HistorianEventDto
{
/// <summary>Gets or sets the event identifier.</summary>
[Key(0)] public string EventId { get; set; } = string.Empty;
/// <summary>Gets or sets the event source name.</summary>
[Key(1)] public string? Source { get; set; }
/// <summary>Gets or sets the event time in UTC ticks.</summary>
[Key(2)] public long EventTimeUtcTicks { get; set; }
/// <summary>Gets or sets the received time in UTC ticks.</summary>
[Key(3)] public long ReceivedTimeUtcTicks { get; set; }
/// <summary>Gets or sets the display text.</summary>
[Key(4)] public string? DisplayText { get; set; }
/// <summary>Gets or sets the severity.</summary>
[Key(5)] public ushort Severity { get; set; }
}
/// <summary>Alarm event to persist back into the historian event store.</summary>
[MessagePackObject]
public sealed class AlarmHistorianEventDto
{
/// <summary>Gets or sets the event identifier.</summary>
[Key(0)] public string EventId { get; set; } = string.Empty;
/// <summary>Gets or sets the source name.</summary>
[Key(1)] public string SourceName { get; set; } = string.Empty;
/// <summary>Gets or sets the condition identifier.</summary>
[Key(2)] public string? ConditionId { get; set; }
/// <summary>Gets or sets the alarm type.</summary>
[Key(3)] public string AlarmType { get; set; } = string.Empty;
/// <summary>Gets or sets the alarm message.</summary>
[Key(4)] public string? Message { get; set; }
/// <summary>Gets or sets the severity.</summary>
[Key(5)] public ushort Severity { get; set; }
/// <summary>Gets or sets the event time in UTC ticks.</summary>
[Key(6)] public long EventTimeUtcTicks { get; set; }
/// <summary>Gets or sets the acknowledgment comment.</summary>
[Key(7)] public string? AckComment { get; set; }
}
// ===== Read Raw =====
[MessagePackObject]
public sealed class ReadRawRequest
{
/// <summary>Gets or sets the tag name.</summary>
[Key(0)] public string TagName { get; set; } = string.Empty;
/// <summary>Gets or sets the start time in UTC ticks.</summary>
[Key(1)] public long StartUtcTicks { get; set; }
/// <summary>Gets or sets the end time in UTC ticks.</summary>
[Key(2)] public long EndUtcTicks { get; set; }
/// <summary>Gets or sets the maximum number of values to return.</summary>
[Key(3)] public int MaxValues { get; set; }
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadRawReply
{
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the request succeeded.</summary>
[Key(1)] public bool Success { get; set; }
/// <summary>Gets or sets the error message if the request failed.</summary>
[Key(2)] public string? Error { get; set; }
/// <summary>Gets or sets the historical samples.</summary>
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
}
// ===== Read Processed =====
[MessagePackObject]
public sealed class ReadProcessedRequest
{
/// <summary>Gets or sets the tag name.</summary>
[Key(0)] public string TagName { get; set; } = string.Empty;
/// <summary>Gets or sets the start time in UTC ticks.</summary>
[Key(1)] public long StartUtcTicks { get; set; }
/// <summary>Gets or sets the end time in UTC ticks.</summary>
[Key(2)] public long EndUtcTicks { get; set; }
/// <summary>Gets or sets the interval in milliseconds.</summary>
[Key(3)] public double IntervalMs { get; set; }
/// <summary>
/// Wonderware AnalogSummary column name: "Average", "Minimum", "Maximum", "ValueCount".
/// The .NET 10 client maps OPC UA aggregate enum → column.
/// </summary>
[Key(4)] public string AggregateColumn { get; set; } = string.Empty;
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(5)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadProcessedReply
{
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the request succeeded.</summary>
[Key(1)] public bool Success { get; set; }
/// <summary>Gets or sets the error message if the request failed.</summary>
[Key(2)] public string? Error { get; set; }
/// <summary>Gets or sets the aggregate sample buckets.</summary>
[Key(3)] public HistorianAggregateSampleDto[] Buckets { get; set; } = Array.Empty<HistorianAggregateSampleDto>();
}
// ===== Read At-Time =====
[MessagePackObject]
public sealed class ReadAtTimeRequest
{
/// <summary>Gets or sets the tag name.</summary>
[Key(0)] public string TagName { get; set; } = string.Empty;
/// <summary>Gets or sets the timestamps in UTC ticks.</summary>
[Key(1)] public long[] TimestampsUtcTicks { get; set; } = Array.Empty<long>();
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(2)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadAtTimeReply
{
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the request succeeded.</summary>
[Key(1)] public bool Success { get; set; }
/// <summary>Gets or sets the error message if the request failed.</summary>
[Key(2)] public string? Error { get; set; }
/// <summary>Gets or sets the historical samples.</summary>
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
}
// ===== Read Events =====
[MessagePackObject]
public sealed class ReadEventsRequest
{
/// <summary>Gets or sets the source name.</summary>
[Key(0)] public string? SourceName { get; set; }
/// <summary>Gets or sets the start time in UTC ticks.</summary>
[Key(1)] public long StartUtcTicks { get; set; }
/// <summary>Gets or sets the end time in UTC ticks.</summary>
[Key(2)] public long EndUtcTicks { get; set; }
/// <summary>Gets or sets the maximum number of events to return.</summary>
[Key(3)] public int MaxEvents { get; set; }
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadEventsReply
{
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the request succeeded.</summary>
[Key(1)] public bool Success { get; set; }
/// <summary>Gets or sets the error message if the request failed.</summary>
[Key(2)] public string? Error { get; set; }
/// <summary>Gets or sets the historian events.</summary>
[Key(3)] public HistorianEventDto[] Events { get; set; } = Array.Empty<HistorianEventDto>();
}
// ===== Write Alarm Events =====
[MessagePackObject]
public sealed class WriteAlarmEventsRequest
{
/// <summary>Gets or sets the alarm events to write.</summary>
[Key(0)] public AlarmHistorianEventDto[] Events { get; set; } = Array.Empty<AlarmHistorianEventDto>();
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(1)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class WriteAlarmEventsReply
{
/// <summary>Gets or sets the correlation identifier.</summary>
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the request succeeded.</summary>
[Key(1)] public bool Success { get; set; }
/// <summary>Gets or sets the error message if the request failed.</summary>
[Key(2)] public string? Error { get; set; }
/// <summary>Per-event success flag, parallel to <see cref="WriteAlarmEventsRequest.Events"/>.</summary>
[Key(3)] public bool[] PerEventOk { get; set; } = Array.Empty<bool>();
/// <summary>Per-event status parallel to the request's Events: 0=Ack, 1=Retry, 2=Permanent.
/// Empty ⇒ an older sidecar that only sent <see cref="PerEventOk"/>; the client falls back to it.</summary>
[Key(4)] public byte[] PerEventStatus { get; set; } = Array.Empty<byte>();
}
@@ -1,78 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
/// <see cref="ReadFrameAsync"/> from multiple threads against the same instance. Mirror of
/// Driver.Galaxy.Shared.FrameReader; sidecar carries its own copy so the deletion of
/// Galaxy.Shared in PR 7.2 doesn't reach the sidecar.
/// </summary>
public sealed class FrameReader : IDisposable
{
private readonly Stream _stream;
private readonly bool _leaveOpen;
/// <summary>Initializes a new instance of the <see cref="FrameReader"/> class.</summary>
/// <param name="stream">The stream to read frames from.</param>
/// <param name="leaveOpen">Whether to leave the stream open when disposing.</param>
public FrameReader(Stream stream, bool leaveOpen = false)
{
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
_leaveOpen = leaveOpen;
}
/// <summary>Reads the next frame asynchronously from the stream.</summary>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A tuple of message kind and body, or null if EOF is encountered cleanly.</returns>
public async Task<(MessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
{
var lengthPrefix = new byte[Framing.LengthPrefixSize];
if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
return null; // clean EOF on frame boundary
var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
if (length < 0 || length > Framing.MaxFrameBodyBytes)
throw new InvalidDataException($"Sidecar IPC frame length {length} out of range.");
var kindByte = _stream.ReadByte();
if (kindByte < 0) throw new EndOfStreamException("EOF after length prefix, before kind byte.");
var body = new byte[length];
if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
throw new EndOfStreamException("EOF mid-frame.");
return ((MessageKind)(byte)kindByte, body);
}
/// <summary>Deserializes the message body to the specified type.</summary>
/// <typeparam name="T">The type to deserialize to.</typeparam>
/// <param name="body">The serialized message body.</param>
public static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
private async Task<bool> ReadExactAsync(byte[] buffer, CancellationToken ct)
{
var offset = 0;
while (offset < buffer.Length)
{
var read = await _stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false);
if (read == 0)
{
if (offset == 0) return false;
throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
}
offset += read;
}
return true;
}
/// <summary>Disposes the frame reader and optionally closes the underlying stream.</summary>
public void Dispose()
{
if (!_leaveOpen) _stream.Dispose();
}
}
@@ -1,66 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
/// <see cref="SemaphoreSlim"/> so concurrent producers (heartbeat + reply paths) get
/// serialized writes. Mirror of Driver.Galaxy.Shared.FrameWriter; sidecar carries its
/// own copy.
/// </summary>
public sealed class FrameWriter : IDisposable
{
private readonly Stream _stream;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly bool _leaveOpen;
/// <summary>Initializes a new instance of the FrameWriter.</summary>
/// <param name="stream">The stream to write frames to.</param>
/// <param name="leaveOpen">Whether to leave the stream open when disposed.</param>
public FrameWriter(Stream stream, bool leaveOpen = false)
{
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
_leaveOpen = leaveOpen;
}
/// <summary>Writes a frame with the specified message kind and serialized message.</summary>
/// <typeparam name="T">The type of message being written.</typeparam>
/// <param name="kind">The message kind identifier.</param>
/// <param name="message">The message to serialize and write.</param>
/// <param name="ct">The cancellation token.</param>
public async Task WriteAsync<T>(MessageKind kind, T message, CancellationToken ct)
{
var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
if (body.Length > Framing.MaxFrameBodyBytes)
throw new InvalidOperationException(
$"Sidecar IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
var lengthPrefix = new byte[Framing.LengthPrefixSize];
// Big-endian — easy to read in hex dumps.
lengthPrefix[0] = (byte)((body.Length >> 24) & 0xFF);
lengthPrefix[1] = (byte)((body.Length >> 16) & 0xFF);
lengthPrefix[2] = (byte)((body.Length >> 8) & 0xFF);
lengthPrefix[3] = (byte)( body.Length & 0xFF);
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, ct).ConfigureAwait(false);
_stream.WriteByte((byte)kind);
await _stream.WriteAsync(body, 0, body.Length, ct).ConfigureAwait(false);
await _stream.FlushAsync(ct).ConfigureAwait(false);
}
finally { _gate.Release(); }
}
/// <summary>Disposes the frame writer and releases resources.</summary>
public void Dispose()
{
_gate.Dispose();
if (!_leaveOpen) _stream.Dispose();
}
}
@@ -1,48 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Length-prefixed framing constants for the Wonderware historian sidecar TCP protocol.
/// Each frame on the wire is:
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
/// Length is the body size only; the kind byte is not part of the prefixed length.
/// </summary>
/// <remarks>
/// Mirrors the Galaxy.Shared framing exactly so the same FrameReader/FrameWriter pattern
/// works on both sides. The sidecar's protocol is independent — both the .NET 4.8 server
/// side and the .NET 10 client (PR 3.4) carry their own copies of these constants and
/// stay in sync via the round-trip test matrix.
/// </remarks>
public static class Framing
{
public const int LengthPrefixSize = 4;
public const int KindByteSize = 1;
/// <summary>16 MiB cap protects the receiver from a hostile or buggy peer.</summary>
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
}
/// <summary>
/// Wire identifier for each historian sidecar message. Values are stable — never reorder;
/// append new contracts at the end. The .NET 10 client and the .NET 4.8 sidecar must
/// agree on every value here.
/// </summary>
public enum MessageKind : byte
{
Hello = 0x01,
HelloAck = 0x02,
ReadRawRequest = 0x10,
ReadRawReply = 0x11,
ReadProcessedRequest = 0x12,
ReadProcessedReply = 0x13,
ReadAtTimeRequest = 0x14,
ReadAtTimeReply = 0x15,
ReadEventsRequest = 0x16,
ReadEventsReply = 0x17,
WriteAlarmEventsRequest = 0x20,
WriteAlarmEventsReply = 0x21,
}
@@ -1,41 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// First frame of every connection. Advertises the sidecar protocol version and the
/// per-process shared secret the supervisor passed at spawn time.
/// </summary>
[MessagePackObject]
public sealed class Hello
{
public const int CurrentMajor = 1;
public const int CurrentMinor = 0;
/// <summary>Gets or sets the protocol major version.</summary>
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
/// <summary>Gets or sets the protocol minor version.</summary>
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
/// <summary>Gets or sets the peer name.</summary>
[Key(2)] public string PeerName { get; set; } = string.Empty;
/// <summary>Per-process shared secret — verified against the value the supervisor passed at spawn time.</summary>
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
}
/// <summary>Response to a Hello handshake message.</summary>
[MessagePackObject]
public sealed class HelloAck
{
/// <summary>Gets or sets the protocol major version.</summary>
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
/// <summary>Gets or sets the protocol minor version.</summary>
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
/// <summary>Gets or sets a value indicating whether the handshake was accepted.</summary>
[Key(2)] public bool Accepted { get; set; }
/// <summary>Gets or sets the rejection reason if Accepted is false.</summary>
[Key(3)] public string? RejectReason { get; set; }
/// <summary>Gets or sets the host name of the server.</summary>
[Key(4)] public string HostName { get; set; } = string.Empty;
}
@@ -1,334 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Sidecar-side dispatcher. Each post-Hello frame routes by <see cref="MessageKind"/> to
/// the right historian operation and the result frame is written back through the same
/// pipe. Per-call exceptions are caught and surfaced as <c>Success=false, Error=...</c>
/// replies so a single bad request doesn't kill the connection.
/// </summary>
public sealed class HistorianFrameHandler : IFrameHandler
{
// WriteAlarmEventsReply.PerEventStatus byte semantics: 0=Ack, 1=Retry, 2=Permanent.
private const byte StatusAck = 0;
private const byte StatusRetry = 1;
private const byte StatusPermanent = 2;
private readonly IHistorianDataSource _historian;
private readonly IAlarmEventWriter? _alarmWriter;
private readonly ILogger _logger;
/// <summary>Initializes a new instance of the HistorianFrameHandler class.</summary>
/// <param name="historian">The historian data source to query.</param>
/// <param name="logger">The logger instance.</param>
/// <param name="alarmWriter">Optional alarm event writer for writebacks.</param>
public HistorianFrameHandler(
IHistorianDataSource historian,
ILogger logger,
IAlarmEventWriter? alarmWriter = null)
{
_historian = historian ?? throw new ArgumentNullException(nameof(historian));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_alarmWriter = alarmWriter;
}
/// <summary>Handles an incoming frame by dispatching to the appropriate historian operation.</summary>
/// <param name="kind">The frame message kind.</param>
/// <param name="body">The frame body bytes.</param>
/// <param name="writer">The frame writer for sending responses.</param>
/// <param name="ct">Cancellation token.</param>
public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
=> kind switch
{
MessageKind.ReadRawRequest => HandleReadRawAsync(body, writer, ct),
MessageKind.ReadProcessedRequest => HandleReadProcessedAsync(body, writer, ct),
MessageKind.ReadAtTimeRequest => HandleReadAtTimeAsync(body, writer, ct),
MessageKind.ReadEventsRequest => HandleReadEventsAsync(body, writer, ct),
MessageKind.WriteAlarmEventsRequest => HandleWriteAlarmEventsAsync(body, writer, ct),
_ => UnknownAsync(kind),
};
private Task UnknownAsync(MessageKind kind)
{
_logger.Warning("Sidecar received unsupported frame kind {Kind}; dropping", kind);
return Task.CompletedTask;
}
private async Task HandleReadRawAsync(byte[] body, FrameWriter writer, CancellationToken ct)
{
var req = MessagePackSerializer.Deserialize<ReadRawRequest>(body);
var reply = new ReadRawReply { CorrelationId = req.CorrelationId };
try
{
var samples = await _historian.ReadRawAsync(
req.TagName,
new DateTime(req.StartUtcTicks, DateTimeKind.Utc),
new DateTime(req.EndUtcTicks, DateTimeKind.Utc),
req.MaxValues,
ct).ConfigureAwait(false);
reply.Success = true;
reply.Samples = ToWire(samples);
}
catch (Exception ex)
{
_logger.Warning(ex, "Sidecar ReadRaw failed for {Tag}", req.TagName);
reply.Success = false;
reply.Error = ex.Message;
}
await writer.WriteAsync(MessageKind.ReadRawReply, reply, ct).ConfigureAwait(false);
}
private async Task HandleReadProcessedAsync(byte[] body, FrameWriter writer, CancellationToken ct)
{
var req = MessagePackSerializer.Deserialize<ReadProcessedRequest>(body);
var reply = new ReadProcessedReply { CorrelationId = req.CorrelationId };
try
{
var buckets = await _historian.ReadAggregateAsync(
req.TagName,
new DateTime(req.StartUtcTicks, DateTimeKind.Utc),
new DateTime(req.EndUtcTicks, DateTimeKind.Utc),
req.IntervalMs,
req.AggregateColumn,
ct).ConfigureAwait(false);
reply.Success = true;
reply.Buckets = ToWire(buckets);
}
catch (Exception ex)
{
_logger.Warning(ex, "Sidecar ReadProcessed failed for {Tag}", req.TagName);
reply.Success = false;
reply.Error = ex.Message;
}
await writer.WriteAsync(MessageKind.ReadProcessedReply, reply, ct).ConfigureAwait(false);
}
private async Task HandleReadAtTimeAsync(byte[] body, FrameWriter writer, CancellationToken ct)
{
var req = MessagePackSerializer.Deserialize<ReadAtTimeRequest>(body);
var reply = new ReadAtTimeReply { CorrelationId = req.CorrelationId };
try
{
var timestamps = new DateTime[req.TimestampsUtcTicks.Length];
for (var i = 0; i < timestamps.Length; i++)
timestamps[i] = new DateTime(req.TimestampsUtcTicks[i], DateTimeKind.Utc);
var samples = await _historian.ReadAtTimeAsync(req.TagName, timestamps, ct).ConfigureAwait(false);
reply.Success = true;
reply.Samples = ToWire(samples);
}
catch (Exception ex)
{
_logger.Warning(ex, "Sidecar ReadAtTime failed for {Tag}", req.TagName);
reply.Success = false;
reply.Error = ex.Message;
}
await writer.WriteAsync(MessageKind.ReadAtTimeReply, reply, ct).ConfigureAwait(false);
}
private async Task HandleReadEventsAsync(byte[] body, FrameWriter writer, CancellationToken ct)
{
var req = MessagePackSerializer.Deserialize<ReadEventsRequest>(body);
var reply = new ReadEventsReply { CorrelationId = req.CorrelationId };
try
{
var events = await _historian.ReadEventsAsync(
req.SourceName,
new DateTime(req.StartUtcTicks, DateTimeKind.Utc),
new DateTime(req.EndUtcTicks, DateTimeKind.Utc),
req.MaxEvents,
ct).ConfigureAwait(false);
reply.Success = true;
reply.Events = ToWire(events);
}
catch (Exception ex)
{
_logger.Warning(ex, "Sidecar ReadEvents failed for source {Source}", req.SourceName);
reply.Success = false;
reply.Error = ex.Message;
}
await writer.WriteAsync(MessageKind.ReadEventsReply, reply, ct).ConfigureAwait(false);
}
private async Task HandleWriteAlarmEventsAsync(byte[] body, FrameWriter writer, CancellationToken ct)
{
var req = MessagePackSerializer.Deserialize<WriteAlarmEventsRequest>(body);
// MessagePack deserializes an absent or explicit-nil array as null, not Array.Empty.
// Normalise here so every path below can safely dereference .Length without an NRE.
req.Events ??= Array.Empty<AlarmHistorianEventDto>();
var reply = new WriteAlarmEventsReply { CorrelationId = req.CorrelationId };
if (_alarmWriter is null)
{
reply.Success = false;
reply.Error = "Sidecar not configured with an alarm-event writer.";
reply.PerEventOk = new bool[req.Events.Length];
reply.PerEventStatus = AllStatus(req.Events.Length, StatusRetry);
await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct).ConfigureAwait(false);
return;
}
try
{
// Classify each event before touching the writer: structurally-malformed
// (poison) events can never be persisted, so mark them Permanent and exclude
// them from the writer batch. Only the well-formed remainder is handed to the
// writer, whose bool[] result is mapped back onto the original indices.
var status = new byte[req.Events.Length];
var writable = new List<AlarmHistorianEventDto>(req.Events.Length);
var originalIndex = new List<int>(req.Events.Length);
for (var i = 0; i < req.Events.Length; i++)
{
if (IsStructurallyMalformed(req.Events[i]))
{
status[i] = StatusPermanent;
}
else
{
originalIndex.Add(i);
writable.Add(req.Events[i]);
}
}
// Aligned 1:1 to `writable`; empty when every event was poison (writer skipped).
var perEvent = writable.Count == 0
? Array.Empty<bool>()
: await _alarmWriter.WriteAsync(writable.ToArray(), ct).ConfigureAwait(false);
for (var i = 0; i < originalIndex.Count; i++)
{
var ok = i < perEvent.Length && perEvent[i];
status[originalIndex[i]] = ok ? StatusAck : StatusRetry;
}
reply.PerEventStatus = status;
reply.PerEventOk = StatusToOk(status);
reply.Success = true;
// Whole-batch Success stays true even when some events failed — per-event
// PerEventStatus slots carry the granular result (Ack / Retry / Permanent);
// the SQLite drain worker acks 0, retries 1, and dead-letters 2. PerEventOk
// is kept populated for rolling-deploy back-compat with an older client.
}
catch (Exception ex)
{
_logger.Warning(ex, "Sidecar WriteAlarmEvents failed");
reply.Success = false;
reply.Error = ex.Message;
reply.PerEventOk = new bool[req.Events.Length];
reply.PerEventStatus = AllStatus(req.Events.Length, StatusRetry);
}
await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct).ConfigureAwait(false);
}
/// <summary>
/// Classifies an alarm event as structurally malformed (poison): an event the historian
/// event store can never persist regardless of retries. Such events are marked Permanent
/// so the store-and-forward sink dead-letters them immediately instead of looping to the
/// retry cap. A blank source name or alarm type, or a non-positive event timestamp, are
/// the structural invariants the historian write requires.
/// </summary>
/// <param name="e">The candidate alarm event.</param>
/// <returns><c>true</c> when the event is structurally malformed; otherwise <c>false</c>.</returns>
internal static bool IsStructurallyMalformed(AlarmHistorianEventDto e) =>
e is null
|| string.IsNullOrWhiteSpace(e.SourceName)
|| string.IsNullOrWhiteSpace(e.AlarmType)
|| e.EventTimeUtcTicks <= 0;
private static byte[] AllStatus(int length, byte value)
{
var status = new byte[length];
for (var i = 0; i < length; i++) status[i] = value;
return status;
}
private static bool[] StatusToOk(byte[] status)
{
var ok = new bool[status.Length];
for (var i = 0; i < status.Length; i++) ok[i] = status[i] == StatusAck;
return ok;
}
private static HistorianSampleDto[] ToWire(List<HistorianSample> samples)
{
var dtos = new HistorianSampleDto[samples.Count];
for (var i = 0; i < samples.Count; i++)
{
var s = samples[i];
dtos[i] = new HistorianSampleDto
{
ValueBytes = s.Value is null ? null : MessagePackSerializer.Serialize(s.Value),
Quality = s.Quality,
TimestampUtcTicks = s.TimestampUtc.Ticks,
};
}
return dtos;
}
private static HistorianAggregateSampleDto[] ToWire(List<HistorianAggregateSample> samples)
{
var dtos = new HistorianAggregateSampleDto[samples.Count];
for (var i = 0; i < samples.Count; i++)
{
dtos[i] = new HistorianAggregateSampleDto
{
Value = samples[i].Value,
TimestampUtcTicks = samples[i].TimestampUtc.Ticks,
};
}
return dtos;
}
private static HistorianEventDto[] ToWire(List<Backend.HistorianEventDto> events)
{
var dtos = new HistorianEventDto[events.Count];
for (var i = 0; i < events.Count; i++)
{
var e = events[i];
dtos[i] = new HistorianEventDto
{
EventId = e.Id.ToString(),
Source = e.Source,
EventTimeUtcTicks = e.EventTime.Ticks,
ReceivedTimeUtcTicks = e.ReceivedTime.Ticks,
DisplayText = e.DisplayText,
Severity = e.Severity,
};
}
return dtos;
}
}
/// <summary>
/// Strategy for persisting alarm events into the Wonderware Alarm &amp; Events log. PR 3.W
/// supplies a real implementation that drives the aahClient SDK; PR 3.3 ships the
/// contract + a default null implementation so the sidecar can boot without one.
/// </summary>
public interface IAlarmEventWriter
{
/// <summary>
/// Writes a batch of alarm events. Returns one boolean per input event indicating
/// persisted vs. retry-please. The SQLite store-and-forward sink retries failed
/// slots on the next drain tick.
/// </summary>
/// <param name="events">Alarm events to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<bool[]> WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken);
}
@@ -1,20 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Strategy for handling each post-Hello frame the sidecar's <see cref="TcpFrameServer"/>
/// reads. Implementations deserialize the body per the <see cref="MessageKind"/>, dispatch
/// to the historian, and write the corresponding reply through the supplied
/// <see cref="FrameWriter"/>.
/// </summary>
public interface IFrameHandler
{
/// <summary>Handles a frame from the sidecar frame server.</summary>
/// <param name="kind">The type of message being handled.</param>
/// <param name="body">The serialized message body.</param>
/// <param name="writer">The frame writer to send responses.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
}
@@ -1,196 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Accepts one TCP client at a time, optionally over TLS, verifies the shared-secret
/// Hello, then dispatches frames to <see cref="IFrameHandler"/>. Authentication is the
/// shared secret carried in the Hello frame, optionally over a TLS-protected channel.
/// </summary>
public sealed class TcpFrameServer : IDisposable
{
private readonly IPAddress _bind;
private readonly int _port;
private readonly string _sharedSecret;
private readonly X509Certificate2? _tlsCert; // null = plaintext
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts = new();
private TcpListener? _listener;
/// <summary>Initializes a new instance of the <see cref="TcpFrameServer"/> class.</summary>
/// <param name="bind">The IP address to bind the listener to.</param>
/// <param name="port">The TCP port to bind (0 lets the OS pick a free port).</param>
/// <param name="sharedSecret">The shared secret the client's Hello must match.</param>
/// <param name="tlsCert">The server certificate for TLS; <c>null</c> for plaintext.</param>
/// <param name="logger">The logger for diagnostic messages.</param>
public TcpFrameServer(IPAddress bind, int port, string sharedSecret, X509Certificate2? tlsCert, ILogger logger)
{
_bind = bind ?? throw new ArgumentNullException(nameof(bind));
_port = port;
_sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
_tlsCert = tlsCert;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>The port the listener actually bound (useful when constructed with port 0 in tests).</summary>
public int BoundPort => ((IPEndPoint)_listener!.LocalEndpoint).Port;
private void EnsureListening()
{
if (_listener is not null) return;
// Assign _listener ONLY after Start() succeeds. If Start() throws (e.g. the port is in
// a Windows excluded/reserved range → WSAEACCES "access forbidden", or already in use),
// _listener must stay null so the next RunAsync iteration retries the full create+Start.
// Assigning before Start() leaves a non-null-but-unstarted listener that the
// `if (_listener is not null) return` guard would never re-Start, turning a one-time
// bind error into a permanent misleading "Not listening" crash loop.
var listener = new TcpListener(_bind, _port);
listener.Start();
_listener = listener;
}
/// <summary>
/// Accepts one connection, performs the Hello handshake, then dispatches frames to
/// <paramref name="handler"/> until EOF or cancel. Returns when the client disconnects.
/// </summary>
/// <param name="handler">The frame handler to process frames.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
EnsureListening();
// net48 has no AcceptTcpClientAsync(CancellationToken); Stop() unblocks a pending accept.
using var reg = linked.Token.Register(() => { try { _listener!.Stop(); } catch { /* ignore */ } });
TcpClient client;
try { client = await _listener!.AcceptTcpClientAsync().ConfigureAwait(false); }
catch (ObjectDisposedException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); }
catch (InvalidOperationException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); }
catch (SocketException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); }
using (client)
{
// net48's NetworkStream.ReadAsync ignores the CancellationToken, so cancelling the
// token alone cannot unblock the frame loop when it's parked reading an idle client —
// only closing the socket does. Register the cancel to Close() the active client so
// RunAsync actually unwinds on shutdown (mirrors the listener.Stop() above that
// unblocks a parked AcceptTcpClientAsync). Without this, RunAsync().GetAwaiter() in
// Program.Main never returns on Ctrl-C/service-stop while a connection is open.
using var clientReg = linked.Token.Register(() => { try { client.Close(); } catch { /* ignore */ } });
client.NoDelay = true;
Stream stream = client.GetStream();
SslStream? ssl = null;
try
{
if (_tlsCert is not null)
{
ssl = new SslStream(stream, leaveInnerStreamOpen: false);
await ssl.AuthenticateAsServerAsync(_tlsCert, clientCertificateRequired: false,
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false).ConfigureAwait(false);
stream = ssl;
}
using var reader = new FrameReader(stream, leaveOpen: true);
using var writer = new FrameWriter(stream, leaveOpen: true);
var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
if (first is null || first.Value.Kind != MessageKind.Hello)
{
_logger.Warning("Sidecar TCP first frame was not Hello; dropping");
return;
}
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
{
await writer.WriteAsync(MessageKind.HelloAck,
new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, linked.Token).ConfigureAwait(false);
_logger.Warning("Sidecar TCP Hello rejected: shared-secret-mismatch");
return;
}
if (hello.ProtocolMajor != Hello.CurrentMajor)
{
await writer.WriteAsync(MessageKind.HelloAck,
new HelloAck { Accepted = false, RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}" },
linked.Token).ConfigureAwait(false);
_logger.Warning("Sidecar TCP Hello rejected: major mismatch peer={Peer} server={Server}", hello.ProtocolMajor, Hello.CurrentMajor);
return;
}
await writer.WriteAsync(MessageKind.HelloAck,
new HelloAck { Accepted = true, HostName = Environment.MachineName }, linked.Token).ConfigureAwait(false);
while (!linked.Token.IsCancellationRequested)
{
var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
if (frame is null) break;
await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
}
}
catch (Exception) when (linked.Token.IsCancellationRequested)
{
// The clientReg cancel callback closed the socket mid-read/handshake (net48 read
// doesn't observe the token); surface it as cancellation so RunAsync's
// OperationCanceledException path unwinds cleanly instead of logging a connection
// failure and counting it toward MaxConsecutiveFailures.
throw new OperationCanceledException(linked.Token);
}
finally { ssl?.Dispose(); }
}
}
// ---- exponential backoff / give-up policy between accepted connections ----
private static readonly TimeSpan[] BackoffSteps =
{
TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8),
};
/// <summary>
/// Maximum consecutive failures before the server gives up and lets the process exit
/// so the supervisor (NSSM / SCM) can restart the sidecar cleanly.
/// </summary>
private const int MaxConsecutiveFailures = 20;
/// <summary>
/// Runs the server continuously, handling one connection at a time. When a connection
/// ends (clean or error), waits with exponential backoff before accepting the next.
/// If <see cref="MaxConsecutiveFailures"/> consecutive failures occur the method
/// throws so the supervisor can restart the sidecar.
/// </summary>
/// <param name="handler">The frame handler to process frames.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
{
var consecutiveFailures = 0;
while (!ct.IsCancellationRequested)
{
try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); consecutiveFailures = 0; }
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
consecutiveFailures++;
if (consecutiveFailures >= MaxConsecutiveFailures)
{
_logger.Fatal(ex, "Sidecar TCP connection loop failed {Count} consecutive times — giving up so supervisor can restart", consecutiveFailures);
throw;
}
var delay = BackoffSteps[Math.Min(consecutiveFailures - 1, BackoffSteps.Length - 1)];
_logger.Error(ex, "Sidecar TCP connection loop error (consecutive failure {Count}/{Max}) — retrying in {Delay}", consecutiveFailures, MaxConsecutiveFailures, delay);
try { await Task.Delay(delay, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
}
}
}
/// <summary>Disposes the server, stops the listener, and cancels any pending operations.</summary>
public void Dispose() { _cts.Cancel(); try { _listener?.Stop(); } catch { /* ignore */ } _cts.Dispose(); }
}
@@ -1,178 +0,0 @@
using System;
using System.Net;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
/// <summary>
/// Entry point for the Wonderware Historian sidecar. Reads the shared secret, TCP
/// bind/port, optional TLS settings, and historian connection config from environment
/// (the supervisor passes them at spawn time per <c>driver-stability.md</c>). Hosts a
/// TCP server (optionally over TLS) dispatching the five sidecar contracts (PR 3.3) to
/// the Wonderware Historian SDK.
/// </summary>
public static class Program
{
/// <summary>Entry point for the Wonderware Historian sidecar process.</summary>
/// <param name="args">Command-line arguments (unused).</param>
/// <returns>0 on success, 2 on fatal error.</returns>
public static int Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
@"%ProgramData%\OtOpcUa\historian-wonderware-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)),
rollingInterval: RollingInterval.Day)
.CreateLogger();
try
{
var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SECRET")
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_SECRET not set — supervisor must pass the per-process secret at spawn time");
var tcpPort = TryParseInt("OTOPCUA_HISTORIAN_TCP_PORT", 32569);
var bindRaw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_BIND");
var bind = string.IsNullOrWhiteSpace(bindRaw) ? IPAddress.Any : IPAddress.Parse(bindRaw);
var tlsEnabled = string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_ENABLED"), "true", StringComparison.OrdinalIgnoreCase);
X509Certificate2? tlsCert = tlsEnabled ? LoadTlsCert() : null;
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
// Sidecar can boot in "tcp idle" mode (no real Wonderware Historian SDK
// initialization) for smoke + IPC tests. Production sets ENABLED=true so the
// SDK opens its connection up front.
var historianEnabled = string.Equals(
Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ENABLED"),
"true", StringComparison.OrdinalIgnoreCase);
if (!historianEnabled)
{
Log.Information("Wonderware historian sidecar starting in tcp idle mode (SDK disabled) (OTOPCUA_HISTORIAN_ENABLED!=true) — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null);
cts.Token.WaitHandle.WaitOne();
Log.Information("Wonderware historian sidecar stopping cleanly");
return 0;
}
using var historian = BuildHistorian();
var alarmWriter = BuildAlarmWriter();
var handler = new HistorianFrameHandler(historian, Log.Logger, alarmWriter);
using var server = new TcpFrameServer(bind, tcpPort, sharedSecret, tlsCert, Log.Logger);
Log.Information("Wonderware historian sidecar serving — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null);
try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); }
catch (OperationCanceledException) { /* clean shutdown via Ctrl-C */ }
Log.Information("Wonderware historian sidecar stopped cleanly");
return 0;
}
catch (Exception ex)
{
Log.Fatal(ex, "Wonderware historian sidecar fatal");
return 2;
}
finally { Log.CloseAndFlush(); }
}
/// <summary>
/// Builds the Wonderware Historian data source from environment variables. Mirrors
/// the env-var contract that <c>Driver.Galaxy.Host</c> used in v1; PR 3.W reaffirms
/// this contract in install scripts.
/// </summary>
private static HistorianDataSource BuildHistorian()
{
var cfg = new HistorianConfiguration
{
Enabled = true,
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase),
UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"),
Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"),
CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30),
MaxValuesPerRead = TryParseInt("OTOPCUA_HISTORIAN_MAX_VALUES", 10000),
FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60),
};
var servers = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVERS");
if (!string.IsNullOrWhiteSpace(servers))
cfg.ServerNames = new System.Collections.Generic.List<string>(
servers.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
Log.Information("Sidecar Historian config — {NodeCount} node(s), port={Port}",
cfg.ServerNames.Count > 0 ? cfg.ServerNames.Count : 1, cfg.Port);
return new HistorianDataSource(cfg);
}
private static int TryParseInt(string envName, int defaultValue)
{
var raw = Environment.GetEnvironmentVariable(envName);
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
}
/// <summary>
/// Loads the TLS server certificate when TLS is enabled. The reference is either a
/// <c>.pfx</c> file path (decrypted with the optional password env var) or, if not a
/// file, a thumbprint resolved from the <c>LocalMachine\My</c> store.
/// </summary>
private static X509Certificate2 LoadTlsCert()
{
var certRef = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_CERT")
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_TLS_CERT not set but TLS enabled — supply a .pfx path or a LocalMachine\\My store thumbprint");
var pwd = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_CERT_PASSWORD");
if (System.IO.File.Exists(certRef))
return new X509Certificate2(certRef, pwd, X509KeyStorageFlags.MachineKeySet);
// else treat as a thumbprint in LocalMachine\My
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
var found = store.Certificates.Find(X509FindType.FindByThumbprint, certRef.Replace(" ", ""), validOnly: false);
if (found.Count == 0) throw new InvalidOperationException($"OTOPCUA_HISTORIAN_TLS_CERT thumbprint '{certRef}' not found in LocalMachine\\My and is not a file path");
return found[0];
}
/// <summary>
/// Constructs the alarm-event writer when the alarm-write toggle is on, otherwise
/// returns <c>null</c> so <see cref="HistorianFrameHandler"/> falls back to the
/// "not configured" reply for any incoming <c>WriteAlarmEvents</c> frame.
/// Default is <c>true</c> when <c>OTOPCUA_HISTORIAN_ENABLED=true</c>; explicitly
/// set <c>OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false</c> to keep a read-only
/// deployment that still loads the SDK for reads.
/// </summary>
internal static IAlarmEventWriter? BuildAlarmWriter()
{
var raw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED");
var enabled = string.IsNullOrWhiteSpace(raw)
? true
: !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase);
if (!enabled)
{
Log.Information("Alarm-event writer disabled (OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false); historian sidecar will reject WriteAlarmEvents frames.");
return null;
}
var cfg = BuildAlarmWriterConfig();
var backend = new SdkAlarmHistorianWriteBackend(cfg);
Log.Information("Alarm-event writer enabled — backend=SdkAlarmHistorianWriteBackend server={Server}", cfg.ServerName);
return new AahClientManagedAlarmEventWriter(backend);
}
private static HistorianConfiguration BuildAlarmWriterConfig()
{
return new HistorianConfiguration
{
Enabled = true,
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase),
UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"),
Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"),
CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30),
FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60),
};
}
}
@@ -1,65 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<!-- x64 — AVEVA Historian 2020 ships an x64 build of aahClientManaged + the native
aahClient.dll under the Historian install's x64\ subfolder. The other three
SDK assemblies (Historian.CBE / DPAPI / ArchestrA.CloudHistorian.Contract) are
pure-managed AnyCPU and load fine in either bitness. The earlier x86 default
was inherited from v1's Galaxy.Host bitness (MXAccess COM, retired in PR 7.2)
and didn't reflect any constraint of the Historian SDK itself. -->
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware</RootNamespace>
<AssemblyName>OtOpcUa.Driver.Historian.Wonderware</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack"/>
<PackageReference Include="System.Memory"/>
<PackageReference Include="System.Threading.Tasks.Extensions"/>
<PackageReference Include="System.Data.SqlClient"/>
<PackageReference Include="Serilog"/>
<PackageReference Include="Serilog.Sinks.File"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests"/>
</ItemGroup>
<ItemGroup>
<!-- Wonderware Historian SDK — consumed by Backend/ for HistoryReadAsync.
Lifted from Driver.Galaxy.Host in PR 3.2 so the sidecar owns the SDK. -->
<Reference Include="aahClientManaged">
<HintPath>..\..\..\lib\aahClientManaged.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="aahClientCommon">
<HintPath>..\..\..\lib\aahClientCommon.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- Historian SDK native and satellite DLLs — staged beside the host exe so the
aahClientManaged wrapper can P/Invoke into them without an AssemblyResolve hook. -->
<None Include="..\..\..\lib\aahClient.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\..\lib\Historian.CBE.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\..\lib\Historian.DPAPI.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\..\lib\ArchestrA.CloudHistorian.Contract.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -60,7 +60,6 @@ else
["Focas"] = typeof(FocasDriverPage),
["OpcUaClient"] = typeof(OpcUaClientDriverPage),
["GalaxyMxGateway"] = typeof(GalaxyDriverPage),
["Historian.Wonderware"] = typeof(HistorianWonderwareDriverPage),
};
protected override async Task OnInitializedAsync()
@@ -45,6 +45,5 @@
new DriverTypeEntry("Focas", "focas", "[FOC]", "Fanuc CNC via FOCAS library."),
new DriverTypeEntry("OpcUaClient", "opcuaclient", "[OPC]", "Upstream OPC UA server pull."),
new DriverTypeEntry("Galaxy", "galaxy", "[Gx]", "AVEVA System Platform (Wonderware) via mxaccessgw."),
new DriverTypeEntry("Historian.Wonderware", "historianwonderware","[Hx]", "Wonderware Historian replay/cyclic reads."),
};
}
@@ -1,367 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/historianwonderware"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New Wonderware Historian driver" : "Edit Wonderware Historian driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading&hellip;</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="historianwonderwareDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.Historian.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Historian Wonderware address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<HistorianWonderwareAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Sidecar host</label>
<InputText @bind-Value="_form.Historian.Host" class="form-control form-control-sm mono"
placeholder="localhost" />
<div class="form-text">DNS name or IP the historian sidecar's TCP listener is reachable at.</div>
</div>
<div class="col-md-2">
<label class="form-label">Sidecar port</label>
<InputNumber @bind-Value="_form.Historian.Port" class="form-control form-control-sm mono" />
<div class="form-text">Must match the sidecar's <code>OTOPCUA_HISTORIAN_TCP_PORT</code>.</div>
</div>
<div class="col-md-4">
<label class="form-label">Shared secret</label>
<InputText @bind-Value="_form.Historian.SharedSecret" type="password" class="form-control form-control-sm" autocomplete="new-password" />
<div class="form-text">Per-process secret verified in the Hello frame — must match the sidecar's configured secret.</div>
</div>
<div class="col-md-3">
<label class="form-label">Peer name (diagnostic)</label>
<InputText @bind-Value="_form.Historian.PeerName" class="form-control form-control-sm"
placeholder="OtOpcUa" />
<div class="form-text">Sent in Hello for sidecar logging. Default: OtOpcUa.</div>
</div>
<div class="col-md-3">
<label class="form-label">TLS</label>
<div class="form-check mt-1">
<InputCheckbox @bind-Value="_form.Historian.UseTls" class="form-check-input" id="historianUseTls" />
<label class="form-check-label" for="historianUseTls">Use TLS</label>
</div>
<div class="form-text">Wrap the sidecar TCP stream in TLS before the Hello handshake.</div>
</div>
<div class="col-md-5">
<label class="form-label">Server cert thumbprint (TLS pin)</label>
<InputText @bind-Value="_form.Historian.ServerCertThumbprint" class="form-control form-control-sm mono" />
<div class="form-text">SHA-1 thumbprint to pin; blank = validate CA chain.</div>
</div>
</div>
</div>
</section>
@* Timing *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Timing</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Connect timeout (s, blank = default 10 s)</label>
<InputNumber @bind-Value="_form.Historian.ConnectTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Cap on TCP connect + Hello round-trip. Null = 10 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Call timeout (s, blank = default 30 s)</label>
<InputNumber @bind-Value="_form.Historian.CallTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Cap on a single read/write once connected. Null = 30 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Effective connect timeout (s)</label>
<input class="form-control form-control-sm" readonly
value="@(_form.Historian.ConnectTimeoutSeconds?.ToString() ?? "10 (default)")" />
</div>
<div class="col-md-3">
<label class="form-label">Effective call timeout (s)</label>
<input class="form-control form-control-sm" readonly
value="@(_form.Historian.CallTimeoutSeconds?.ToString() ?? "30 (default)")" />
</div>
</div>
</div>
</section>
@* Diagnostics *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Diagnostics</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.Historian.ProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "Historian.Wonderware";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private List<Namespace> _namespaces = new();
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.OrderBy(n => n.NamespaceId)
.ToListAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "",
Enabled = true,
};
_form = new FormModel();
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? CreateDefaultOptions();
_form = new FormModel();
_form.Historian = WonderwareHistorianClientFormModel.FromRecord(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private static WonderwareHistorianClientOptions CreateDefaultOptions() =>
new(Host: "localhost", Port: 32569, SharedSecret: "") { UseTls = false, ServerCertThumbprint = null };
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var opts = _form.Historian.ToRecord();
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
{
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
else
{
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.NamespaceId = _identityModel.NamespaceId;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.Historian.ToRecord(), _jsonOpts);
private static WonderwareHistorianClientOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _jsonOpts); }
catch { return null; }
}
public sealed class FormModel
{
public WonderwareHistorianClientFormModel Historian { get; set; } = new();
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>
/// Mutable mirror of <see cref="WonderwareHistorianClientOptions"/> (positional record).
/// <c>ConnectTimeoutSeconds</c> and <c>CallTimeoutSeconds</c> are nullable int — null
/// round-trips to a null TimeSpan?, which the record resolves to its compiled default.
/// </summary>
public sealed class WonderwareHistorianClientFormModel
{
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 32569;
public string SharedSecret { get; set; } = "";
public string PeerName { get; set; } = "OtOpcUa";
public int? ConnectTimeoutSeconds { get; set; }
public int? CallTimeoutSeconds { get; set; }
public int ProbeTimeoutSeconds { get; set; } = 15;
public bool UseTls { get; set; }
public string? ServerCertThumbprint { get; set; }
public static WonderwareHistorianClientFormModel FromRecord(WonderwareHistorianClientOptions r) => new()
{
Host = r.Host,
Port = r.Port,
SharedSecret = r.SharedSecret,
PeerName = r.PeerName,
ConnectTimeoutSeconds = r.ConnectTimeout.HasValue ? (int)r.ConnectTimeout.Value.TotalSeconds : null,
CallTimeoutSeconds = r.CallTimeout.HasValue ? (int)r.CallTimeout.Value.TotalSeconds : null,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
UseTls = r.UseTls,
ServerCertThumbprint = r.ServerCertThumbprint,
};
public WonderwareHistorianClientOptions ToRecord() => new(
Host: Host,
Port: Port,
SharedSecret: SharedSecret,
PeerName: PeerName,
ConnectTimeout: ConnectTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(ConnectTimeoutSeconds.Value) : null,
CallTimeout: CallTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(CallTimeoutSeconds.Value) : null)
{
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
UseTls = UseTls,
ServerCertThumbprint = ServerCertThumbprint,
};
}
}
@@ -36,7 +36,6 @@
<option value="Focas">Focas</option>
<option value="OpcUaClient">OpcUaClient</option>
<option value="GalaxyMxGateway">Galaxy</option>
<option value="Historian.Wonderware">Historian.Wonderware</option>
</InputSelect>
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
</div>
@@ -1,15 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts a Wonderware Historian tag name + retrieval mode
/// + interval into the canonical address query string (e.g. MyTag?mode=Cyclic&amp;interval=60).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class HistorianWonderwareAddressBuilder
{
public static string Build(string tagName, string mode, int interval)
// Percent-encode the tag name so a name carrying query-reserved characters (? & # =) can't
// corrupt the produced query string (AdminUI-005). Mode is a fixed enum-style token, so it
// needs no encoding.
=> $"{Uri.EscapeDataString(tagName)}?mode={mode}&interval={interval}";
}
@@ -1,52 +0,0 @@
@* Static Wonderware Historian address builder: tag name + retrieval mode + interval
→ MyTag?mode=Cyclic&interval=60 *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Tag name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="SysTimeHour"
@bind="_tagName" @bind:after="OnChangedAsync" />
</div>
<div class="col-md-3">
<label class="form-label">Retrieval mode</label>
<select class="form-select form-select-sm" @bind="_mode" @bind:after="OnChangedAsync">
<option value="Last">Last</option>
<option value="Cyclic">Cyclic</option>
<option value="Delta">Delta</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Interval (seconds)</label>
<input type="number" class="form-control form-control-sm" min="1"
@bind="_interval" @bind:after="OnChangedAsync" />
<div class="form-text">Polling/retrieval interval.</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _tagName = "";
private string _mode = "Cyclic";
private int _interval = 60;
private string _built = "";
protected override void OnInitialized()
{
_built = string.IsNullOrWhiteSpace(_tagName) ? "" : HistorianWonderwareAddressBuilder.Build(_tagName, _mode, _interval);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = string.IsNullOrWhiteSpace(_tagName) ? "" : HistorianWonderwareAddressBuilder.Build(_tagName, _mode, _interval);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -1,32 +0,0 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
<div class="row g-2">
<div class="col-md-12"><label class="form-label">Historian tagname (FullName)</label>
<input type="text" class="form-control form-control-sm mono" value="@_m.FullName"
placeholder="Reactor1.Temperature"
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
<div class="form-text">The AVEVA Historian tagname the driver reads against.</div></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
private HistorianWonderwareTagConfigModel _m = new();
private string? _lastConfigJson;
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = HistorianWonderwareTagConfigModel.FromJson(ConfigJson);
}
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -1,49 +0,0 @@
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for a Wonderware (AVEVA) Historian equipment tag's TagConfig JSON. The
/// tag binds to a historian tag by its full reference (<c>FullName</c> — the historian tagname/source
/// the driver reads against). Preserves unrecognised JSON keys across a load→save.</summary>
/// <remarks>
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
/// (<c>AddressSpaceComposer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
/// </remarks>
public sealed class HistorianWonderwareTagConfigModel
{
/// <summary>Historian tagname/source the tag binds to (the driver-side full reference). Required.</summary>
public string FullName { get; set; } = "";
private JsonObject _bag = new();
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The tag's TagConfig JSON (null/blank/malformed ⇒ defaults).</param>
public static HistorianWonderwareTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new HistorianWonderwareTagConfigModel
{
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); any history keys merged
/// by the TagModal (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as
/// preserved unknown keys.</summary>
public string ToJson()
{
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(FullName) ? "A historian tagname (FullName) is required." : null;
}
@@ -17,7 +17,6 @@ public static class TagConfigEditorMap
["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
["OpcUaClient"] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
["Historian.Wonderware"] = typeof(Components.Shared.Uns.TagEditors.HistorianWonderwareTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
@@ -19,7 +19,6 @@ public static class TagConfigValidator
["TwinCat"] = j => TwinCATTagConfigModel.FromJson(j).Validate(),
["Focas"] = j => FocasTagConfigModel.FromJson(j).Validate(),
["OpcUaClient"] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
["Historian.Wonderware"] = j => HistorianWonderwareTagConfigModel.FromJson(j).Validate(),
};
/// <summary>
@@ -36,7 +36,6 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
</ItemGroup>
@@ -15,7 +15,6 @@ using TwinCATProbe = Driver.TwinCAT.TwinCATDriverProbe;
using FocasProbe = Driver.FOCAS.FocasDriverProbe;
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
using HistorianProbe = Driver.Historian.Wonderware.Client.WonderwareHistorianDriverProbe;
/// <summary>
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
@@ -84,7 +83,6 @@ public static class DriverFactoryBootstrap
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, HistorianProbe>());
return services;
}
+2 -3
View File
@@ -99,9 +99,8 @@ if (hasDriver)
// overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins)
// with a SqliteStoreAndForwardSink draining to the gateway SendEvent writer. The alarm-write path
// targets the SAME single gateway as the read path, so its connection (endpoint/key/TLS) is sourced
// from the ServerHistorian section — NOT the legacy Wonderware-shaped AlarmHistorian host/port.
// AlarmHistorianOptions still supplies the Enabled gate + the SQLite store-and-forward knobs
// (consumed inside AddAlarmHistorian), so its Wonderware connection fields are intentionally unused.
// from the ServerHistorian section. AlarmHistorianOptions supplies only the Enabled gate + the
// SQLite store-and-forward knobs (consumed inside AddAlarmHistorian) — it carries no connection fields.
// Runtime owns the gating + Sqlite construction; the Host supplies the concrete gateway downstream
// via the driver factory (which owns the package-client adapter). The writer builds its OWN gateway
// channel — a second channel to the same sidecar: sharing one channel with the read path would force
@@ -54,15 +54,14 @@
called from DriverFactoryBootstrap on driver-role nodes; the F7 seam (IDriverFactory)
then exposes the registry to DriverHostActor. Galaxy is net10 because it talks gRPC to
the out-of-process mxaccessgw worker — the COM-bound net48 piece is over there.
Historian.Wonderware (the net48 COM-bridge driver) is intentionally excluded; the
net10 .Client gRPC wrapper is what production binds when the historian role is needed. -->
The historian read/write backend is the Historian.Gateway driver (gRPC to HistorianGateway);
the retired Wonderware historian sidecar projects are no longer referenced. -->
<ItemGroup>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
@@ -233,13 +233,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
/// configured roles. Only the v1 in-process types stay Windows-only:
/// <list type="bullet">
/// <item><c>"Galaxy"</c> — legacy MXAccess COM proxy (retired in PR 7.2; gated for any
/// leftover DriverInstance rows that still reference the old type name).</item>
/// <item><c>"Historian.Wonderware"</c> — Wonderware Historian sidecar over Windows-only
/// named pipes.</item>
/// </list>
/// configured roles. Only the legacy v1 in-process <c>"Galaxy"</c> type stays Windows-only:
/// the legacy MXAccess COM proxy (retired in PR 7.2; gated for any leftover DriverInstance
/// rows that still reference the old type name).
/// The v2 <c>"GalaxyMxGateway"</c> driver talks gRPC to an external mxaccessgw process,
/// so it runs on any platform .NET 10 supports — Linux containers included. Not stubbed.
/// </summary>
@@ -247,7 +243,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="roles">Operational roles configured for this instance.</param>
public static bool ShouldStub(string driverType, IEnumerable<string> roles)
{
var isWindowsOnly = driverType is "Galaxy" or "Historian.Wonderware";
var isWindowsOnly = driverType is "Galaxy";
if (!OperatingSystem.IsWindows() && isWindowsOnly) return true;
if (roles.Contains("dev") && isWindowsOnly) return true;
return false;
@@ -8,8 +8,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// Binds the <c>AlarmHistorian</c> configuration section that gates the durable
/// store-and-forward alarm sink. When <see cref="Enabled"/> is <c>true</c>,
/// <c>AddAlarmHistorian</c> registers a <c>SqliteStoreAndForwardSink</c> (draining to the
/// Wonderware TCP writer supplied by the Host) in place of the
/// <c>NullAlarmHistorianSink</c> default; otherwise the Null default survives.
/// gateway alarm writer supplied by the Host) in place of the
/// <c>NullAlarmHistorianSink</c> default; otherwise the Null default survives. This section
/// supplies only the <see cref="Enabled"/> gate and the SQLite store-and-forward knobs — the
/// downstream connection (endpoint/key/TLS) is sourced from the <c>ServerHistorian</c> section.
/// </summary>
public sealed class AlarmHistorianOptions
{
@@ -25,21 +27,6 @@ public sealed class AlarmHistorianOptions
/// <summary>Filesystem path to the local SQLite store-and-forward queue database.</summary>
public string DatabasePath { get; init; } = "alarm-historian.db";
/// <summary>TCP hostname or IP address the Wonderware historian sidecar listens on.</summary>
public string Host { get; init; } = "localhost";
/// <summary>TCP port the Wonderware historian sidecar listens on.</summary>
public int Port { get; init; } = 32569;
/// <summary>When <c>true</c>, the client connects over TLS.</summary>
public bool UseTls { get; init; }
/// <summary>Expected TLS server certificate thumbprint (hex, no spaces). Null or empty disables pinning.</summary>
public string? ServerCertThumbprint { get; init; }
/// <summary>Per-process shared secret the sidecar verifies in the Hello frame.</summary>
public string SharedSecret { get; init; } = "";
/// <summary>Maximum number of queued rows the drain worker forwards in a single batch.</summary>
public int BatchSize { get; init; } = 100;
@@ -64,8 +51,6 @@ public sealed class AlarmHistorianOptions
{
var warnings = new List<string>();
if (!Enabled) return warnings;
if (string.IsNullOrWhiteSpace(SharedSecret))
warnings.Add("AlarmHistorian:SharedSecret is empty while the historian is enabled — the Wonderware sidecar Hello frame will carry an empty secret.");
if (!Path.IsPathRooted(DatabasePath))
warnings.Add($"AlarmHistorian:DatabasePath '{DatabasePath}' is relative — it resolves against the process working directory (e.g. System32 for a Windows service). Set an absolute path.");
if (DrainIntervalSeconds <= 0)
@@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
/// <summary>
/// Registers shared runtime services. Currently binds <see cref="IAlarmHistorianSink"/>
/// to <see cref="NullAlarmHistorianSink"/> as the default; production deployments
/// override this with <c>SqliteStoreAndForwardSink</c> wrapping <c>WonderwareHistorianClient</c>.
/// override this with <c>SqliteStoreAndForwardSink</c> wrapping the HistorianGateway alarm writer.
/// Call this BEFORE <c>AddAkka</c>.
/// </summary>
/// <param name="services">The service collection to register with.</param>
@@ -63,14 +63,14 @@ public static class ServiceCollectionExtensions
/// <c>Enabled=true</c>, registers a <see cref="SqliteStoreAndForwardSink"/> (draining via the
/// <paramref name="writerFactory"/>-supplied writer) as the <see cref="IAlarmHistorianSink"/>,
/// overriding the <see cref="NullAlarmHistorianSink"/> default. Otherwise a no-op (Null stays).
/// The writer is injected so the durable downstream (Wonderware named-pipe client) can be supplied
/// by the Host, which is the only project that references it.
/// The writer is injected so the durable downstream (the HistorianGateway alarm writer) can be
/// supplied by the Host, which is the only project that references it.
/// </summary>
/// <param name="services">The service collection to register with.</param>
/// <param name="configuration">The configuration carrying the <c>AlarmHistorian</c> section.</param>
/// <param name="writerFactory">
/// Factory the Host supplies to build the concrete <see cref="IAlarmHistorianWriter"/>
/// (the Wonderware named-pipe client) from the bound options + the resolving provider.
/// (the HistorianGateway alarm writer) from the bound options + the resolving provider.
/// </param>
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
public static IServiceCollection AddAlarmHistorian(