chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+180
@@ -0,0 +1,180 @@
|
||||
using System.IO.Pipes;
|
||||
using MessagePack;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Owns one connection to the Wonderware historian sidecar pipe. Handles the Hello
|
||||
/// handshake, serializes outgoing requests + waits for the matching reply frame, and
|
||||
/// reconnects on transport failure with exponential backoff.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Single in-flight call at a time — the sidecar's pipe protocol is request/response
|
||||
/// over a single bidirectional stream, so multiple concurrent <see cref="InvokeAsync"/>
|
||||
/// calls would interleave replies. A <see cref="SemaphoreSlim"/> serializes them. PR 6.x
|
||||
/// can layer batching on top.
|
||||
/// </remarks>
|
||||
internal sealed class PipeChannel : IAsyncDisposable
|
||||
{
|
||||
private readonly WonderwareHistorianClientOptions _options;
|
||||
private readonly Func<CancellationToken, Task<Stream>> _connect;
|
||||
private readonly ILogger _logger;
|
||||
private readonly SemaphoreSlim _callGate = new(1, 1);
|
||||
|
||||
private Stream? _stream;
|
||||
private FrameReader? _reader;
|
||||
private FrameWriter? _writer;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Default factory: connects to a real <see cref="NamedPipeClientStream"/> by name.
|
||||
/// </summary>
|
||||
public static Func<WonderwareHistorianClientOptions, CancellationToken, Task<Stream>> DefaultNamedPipeConnectFactory =
|
||||
async (opts, ct) =>
|
||||
{
|
||||
var pipe = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: opts.PipeName,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
|
||||
await pipe.ConnectAsync((int)opts.EffectiveConnectTimeout.TotalMilliseconds, ct).ConfigureAwait(false);
|
||||
return pipe;
|
||||
};
|
||||
|
||||
public PipeChannel(
|
||||
WonderwareHistorianClientOptions options,
|
||||
Func<CancellationToken, Task<Stream>> connect,
|
||||
ILogger logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_connect = connect ?? throw new ArgumentNullException(nameof(connect));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool IsConnected => _stream is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Connects + performs the Hello handshake. Returns when the sidecar has accepted the
|
||||
/// hello. Throws on rejection (bad secret, version mismatch, or transport failure).
|
||||
/// </summary>
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
await _callGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ConnectInternalAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally { _callGate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends one request, waits for the matching reply. On transport failure, reconnects
|
||||
/// once and retries — broader retry policy lives in the calling layer.
|
||||
/// </summary>
|
||||
public async Task<TReply> InvokeAsync<TRequest, TReply>(
|
||||
MessageKind requestKind,
|
||||
MessageKind expectedReplyKind,
|
||||
TRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
where TReply : class
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(_options.EffectiveCallTimeout);
|
||||
|
||||
await _callGate.WaitAsync(timeout.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Lazy connect on first call.
|
||||
if (_stream is null) await ConnectInternalAsync(timeout.Token).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
return await ExchangeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or EndOfStreamException or ObjectDisposedException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Sidecar pipe transport failure on {Kind}; reconnecting", requestKind);
|
||||
ResetTransport();
|
||||
await ConnectInternalAsync(timeout.Token).ConfigureAwait(false);
|
||||
// One retry. If the second attempt also fails, propagate.
|
||||
return await ExchangeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally { _callGate.Release(); }
|
||||
}
|
||||
|
||||
private async Task<TReply> ExchangeAsync<TRequest, TReply>(
|
||||
MessageKind requestKind, MessageKind expectedReplyKind, TRequest request, CancellationToken ct)
|
||||
{
|
||||
await _writer!.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
|
||||
var frame = await _reader!.ReadFrameAsync(ct).ConfigureAwait(false)
|
||||
?? throw new EndOfStreamException("Sidecar closed pipe before reply.");
|
||||
if (frame.Kind != expectedReplyKind)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Sidecar replied with kind {frame.Kind}; expected {expectedReplyKind}.");
|
||||
}
|
||||
return MessagePackSerializer.Deserialize<TReply>(frame.Body);
|
||||
}
|
||||
|
||||
private async Task ConnectInternalAsync(CancellationToken ct)
|
||||
{
|
||||
ResetTransport();
|
||||
|
||||
_stream = await _connect(ct).ConfigureAwait(false);
|
||||
_reader = new FrameReader(_stream, leaveOpen: true);
|
||||
_writer = new FrameWriter(_stream, leaveOpen: true);
|
||||
|
||||
var hello = new Hello
|
||||
{
|
||||
ProtocolMajor = Hello.CurrentMajor,
|
||||
ProtocolMinor = Hello.CurrentMinor,
|
||||
PeerName = _options.PeerName,
|
||||
SharedSecret = _options.SharedSecret,
|
||||
};
|
||||
await _writer.WriteAsync(MessageKind.Hello, hello, ct).ConfigureAwait(false);
|
||||
|
||||
var ackFrame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false)
|
||||
?? throw new EndOfStreamException("Sidecar closed pipe before HelloAck.");
|
||||
if (ackFrame.Kind != MessageKind.HelloAck)
|
||||
{
|
||||
ResetTransport();
|
||||
throw new InvalidDataException($"Sidecar replied to Hello with kind {ackFrame.Kind}; expected HelloAck.");
|
||||
}
|
||||
|
||||
var ack = MessagePackSerializer.Deserialize<HelloAck>(ackFrame.Body);
|
||||
if (!ack.Accepted)
|
||||
{
|
||||
ResetTransport();
|
||||
throw new UnauthorizedAccessException(
|
||||
$"Sidecar rejected Hello: {ack.RejectReason ?? "<no reason>"}.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Sidecar pipe connected — host={Host}", ack.HostName);
|
||||
}
|
||||
|
||||
private void ResetTransport()
|
||||
{
|
||||
_writer?.Dispose();
|
||||
_reader?.Dispose();
|
||||
_stream?.Dispose();
|
||||
_writer = null;
|
||||
_reader = null;
|
||||
_stream = null;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return ValueTask.CompletedTask;
|
||||
_disposed = true;
|
||||
ResetTransport();
|
||||
_callGate.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's <c>OpcQuality</c>)
|
||||
/// to an OPC UA <c>StatusCode</c> uint. Byte-identical port of the sidecar's
|
||||
/// <c>HistorianQualityMapper.Map</c> — kept in sync via parity tests rather than a
|
||||
/// shared assembly because the sidecar is .NET 4.8 x86 and the client is .NET 10 x64.
|
||||
/// </summary>
|
||||
internal static class QualityMapper
|
||||
{
|
||||
public static uint Map(byte q) => q switch
|
||||
{
|
||||
// Good family (192+)
|
||||
192 => 0x00000000u, // Good
|
||||
216 => 0x00D80000u, // Good_LocalOverride
|
||||
|
||||
// Uncertain family (64-191)
|
||||
64 => 0x40000000u, // Uncertain
|
||||
68 => 0x40900000u, // Uncertain_LastUsableValue
|
||||
80 => 0x40930000u, // Uncertain_SensorNotAccurate
|
||||
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
|
||||
88 => 0x40950000u, // Uncertain_SubNormal
|
||||
|
||||
// Bad family (0-63)
|
||||
0 => 0x80000000u, // Bad
|
||||
4 => 0x80890000u, // Bad_ConfigurationError
|
||||
8 => 0x808A0000u, // Bad_NotConnected
|
||||
12 => 0x808B0000u, // Bad_DeviceFailure
|
||||
16 => 0x808C0000u, // Bad_SensorFailure
|
||||
20 => 0x80050000u, // Bad_CommunicationError
|
||||
24 => 0x808D0000u, // Bad_OutOfService
|
||||
32 => 0x80320000u, // Bad_WaitingForInitialData
|
||||
|
||||
// Unknown — fall back to category bucket so callers still get something usable.
|
||||
_ when q >= 192 => 0x00000000u,
|
||||
_ when q >= 64 => 0x40000000u,
|
||||
_ => 0x80000000u,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user