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:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions
@@ -0,0 +1,180 @@
using System.IO.Pipes;
using MessagePack;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
/// <summary>
/// Owns one connection to the Wonderware historian sidecar pipe. Handles the Hello
/// handshake, serializes outgoing requests + waits for the matching reply frame, and
/// reconnects on transport failure with exponential backoff.
/// </summary>
/// <remarks>
/// Single in-flight call at a time — the sidecar's pipe protocol is request/response
/// over a single bidirectional stream, so multiple concurrent <see cref="InvokeAsync"/>
/// calls would interleave replies. A <see cref="SemaphoreSlim"/> serializes them. PR 6.x
/// can layer batching on top.
/// </remarks>
internal sealed class PipeChannel : IAsyncDisposable
{
private readonly WonderwareHistorianClientOptions _options;
private readonly Func<CancellationToken, Task<Stream>> _connect;
private readonly ILogger _logger;
private readonly SemaphoreSlim _callGate = new(1, 1);
private Stream? _stream;
private FrameReader? _reader;
private FrameWriter? _writer;
private bool _disposed;
/// <summary>
/// Default factory: connects to a real <see cref="NamedPipeClientStream"/> by name.
/// </summary>
public static Func<WonderwareHistorianClientOptions, CancellationToken, Task<Stream>> DefaultNamedPipeConnectFactory =
async (opts, ct) =>
{
var pipe = new NamedPipeClientStream(
serverName: ".",
pipeName: opts.PipeName,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
await pipe.ConnectAsync((int)opts.EffectiveConnectTimeout.TotalMilliseconds, ct).ConfigureAwait(false);
return pipe;
};
public PipeChannel(
WonderwareHistorianClientOptions options,
Func<CancellationToken, Task<Stream>> connect,
ILogger logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_connect = connect ?? throw new ArgumentNullException(nameof(connect));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool IsConnected => _stream is not null;
/// <summary>
/// Connects + performs the Hello handshake. Returns when the sidecar has accepted the
/// hello. Throws on rejection (bad secret, version mismatch, or transport failure).
/// </summary>
public async Task ConnectAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _callGate.WaitAsync(ct).ConfigureAwait(false);
try
{
await ConnectInternalAsync(ct).ConfigureAwait(false);
}
finally { _callGate.Release(); }
}
/// <summary>
/// Sends one request, waits for the matching reply. On transport failure, reconnects
/// once and retries — broader retry policy lives in the calling layer.
/// </summary>
public async Task<TReply> InvokeAsync<TRequest, TReply>(
MessageKind requestKind,
MessageKind expectedReplyKind,
TRequest request,
CancellationToken cancellationToken)
where TReply : class
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_options.EffectiveCallTimeout);
await _callGate.WaitAsync(timeout.Token).ConfigureAwait(false);
try
{
// Lazy connect on first call.
if (_stream is null) await ConnectInternalAsync(timeout.Token).ConfigureAwait(false);
try
{
return await ExchangeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or EndOfStreamException or ObjectDisposedException)
{
_logger.LogWarning(ex, "Sidecar pipe transport failure on {Kind}; reconnecting", requestKind);
ResetTransport();
await ConnectInternalAsync(timeout.Token).ConfigureAwait(false);
// One retry. If the second attempt also fails, propagate.
return await ExchangeAsync<TRequest, TReply>(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false);
}
}
finally { _callGate.Release(); }
}
private async Task<TReply> ExchangeAsync<TRequest, TReply>(
MessageKind requestKind, MessageKind expectedReplyKind, TRequest request, CancellationToken ct)
{
await _writer!.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
var frame = await _reader!.ReadFrameAsync(ct).ConfigureAwait(false)
?? throw new EndOfStreamException("Sidecar closed pipe before reply.");
if (frame.Kind != expectedReplyKind)
{
throw new InvalidDataException(
$"Sidecar replied with kind {frame.Kind}; expected {expectedReplyKind}.");
}
return MessagePackSerializer.Deserialize<TReply>(frame.Body);
}
private async Task ConnectInternalAsync(CancellationToken ct)
{
ResetTransport();
_stream = await _connect(ct).ConfigureAwait(false);
_reader = new FrameReader(_stream, leaveOpen: true);
_writer = new FrameWriter(_stream, leaveOpen: true);
var hello = new Hello
{
ProtocolMajor = Hello.CurrentMajor,
ProtocolMinor = Hello.CurrentMinor,
PeerName = _options.PeerName,
SharedSecret = _options.SharedSecret,
};
await _writer.WriteAsync(MessageKind.Hello, hello, ct).ConfigureAwait(false);
var ackFrame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false)
?? throw new EndOfStreamException("Sidecar closed pipe before HelloAck.");
if (ackFrame.Kind != MessageKind.HelloAck)
{
ResetTransport();
throw new InvalidDataException($"Sidecar replied to Hello with kind {ackFrame.Kind}; expected HelloAck.");
}
var ack = MessagePackSerializer.Deserialize<HelloAck>(ackFrame.Body);
if (!ack.Accepted)
{
ResetTransport();
throw new UnauthorizedAccessException(
$"Sidecar rejected Hello: {ack.RejectReason ?? "<no reason>"}.");
}
_logger.LogInformation("Sidecar pipe connected — host={Host}", ack.HostName);
}
private void ResetTransport()
{
_writer?.Dispose();
_reader?.Dispose();
_stream?.Dispose();
_writer = null;
_reader = null;
_stream = null;
}
public ValueTask DisposeAsync()
{
if (_disposed) return ValueTask.CompletedTask;
_disposed = true;
ResetTransport();
_callGate.Dispose();
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
/// <summary>
/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's <c>OpcQuality</c>)
/// to an OPC UA <c>StatusCode</c> uint. Byte-identical port of the sidecar's
/// <c>HistorianQualityMapper.Map</c> — kept in sync via parity tests rather than a
/// shared assembly because the sidecar is .NET 4.8 x86 and the client is .NET 10 x64.
/// </summary>
internal static class QualityMapper
{
public static uint Map(byte q) => q switch
{
// Good family (192+)
192 => 0x00000000u, // Good
216 => 0x00D80000u, // Good_LocalOverride
// Uncertain family (64-191)
64 => 0x40000000u, // Uncertain
68 => 0x40900000u, // Uncertain_LastUsableValue
80 => 0x40930000u, // Uncertain_SensorNotAccurate
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
88 => 0x40950000u, // Uncertain_SubNormal
// Bad family (0-63)
0 => 0x80000000u, // Bad
4 => 0x80890000u, // Bad_ConfigurationError
8 => 0x808A0000u, // Bad_NotConnected
12 => 0x808B0000u, // Bad_DeviceFailure
16 => 0x808C0000u, // Bad_SensorFailure
20 => 0x80050000u, // Bad_CommunicationError
24 => 0x808D0000u, // Bad_OutOfService
32 => 0x80320000u, // Bad_WaitingForInitialData
// Unknown — fall back to category bucket so callers still get something usable.
_ when q >= 192 => 0x00000000u,
_ when q >= 64 => 0x40000000u,
_ => 0x80000000u,
};
}