PR 4.2 — IReadable abstraction + StatusCodeMap + MxValueDecoder

Read path scaffold + the byte→uint quality mapping table that the parity
matrix (PR 5.x) pins. PR 4.4 supplies the production GW-backed reader; this
PR ships the abstraction and the supporting infrastructure so 4.4 just
plugs the implementation in.

Files:
- Runtime/StatusCodeMap.cs — explicit OPC DA quality byte → OPC UA
  StatusCode uint mapping. Extends the legacy Galaxy.Host
  HistorianQualityMapper with named constants (Good / GoodLocalOverride,
  Uncertain + 4 substatuses, Bad + 7 substatuses, BadInternalError) and an
  MxStatusProxy → uint helper that honors success flag → detail byte →
  detected_by transport-error fallback. Unknown bytes fall back to category
  bucket with a once-per-session diagnostic log so field captures can
  extend the table.
- Runtime/MxValueDecoder.cs — gateway MxValue → boxed CLR value for the
  seven Galaxy data types (Boolean, Int32, Int64, Float32, Float64, String,
  DateTime) plus their array variants. Honors MxValue.IsNull and
  RawValue passthrough.
- Runtime/IGalaxyDataReader.cs — driver-side seam for one-shot reads. PR
  4.4 ships the production wrapper around MxGatewaySession.SubscribeBulk +
  StreamEvents + UnsubscribeBulk; this PR exposes the contract so
  GalaxyDriver.ReadAsync wires through it.
- Runtime/GalaxyMxSession.cs — wrapper around MxGatewaySession that owns
  the Register handle. ConnectAsync opens session + Register; AttachForTests
  lets tests bypass real gw construction. PR 4.3/4.4/4.5 add write,
  subscribe, and reconnect surfaces.

GalaxyDriver:
- Implements IReadable. ReadAsync routes through the injected
  IGalaxyDataReader (test seam) when present; production path throws
  NotSupportedException pointing at PR 4.4 — protects deployments running
  this PR from silent wrong reads while signaling that the legacy-host
  backend (Galaxy:Backend=legacy-host) handles reads in the meantime.
- Internal ctor extended with optional dataReader parameter (default null,
  preserves PR 4.0/4.1 callers).

Tests: 42 new — exhaustive byte→uint table for StatusCodeMap (15 known
codes + category-bucket fallback for unknowns + MxStatusProxy precedence
rules + OPC UA top-byte invariants), every MxValue oneof case for the
decoder (bool/int32/int64/float/double/string/timestamp/3 array variants/
raw bytes/null), GalaxyDriver IReadable wiring (route-through, empty-
request, no-reader-throws, post-dispose-throws, status-code preservation).
62 Galaxy tests total pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-29 15:15:42 -04:00
parent ecba5cedf9
commit 85bdf0d58b
8 changed files with 651 additions and 5 deletions

View File

@@ -4,6 +4,7 @@ using MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
@@ -22,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
/// <see cref="GalaxyDriverFactoryExtensions"/> registers under driver-type name
/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing.
/// </remarks>
public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposable
{
private readonly string _driverInstanceId;
private readonly GalaxyDriverOptions _options;
@@ -35,6 +36,13 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
private IGalaxyHierarchySource? _hierarchySource;
private GalaxyRepositoryClient? _ownedRepositoryClient;
// PR 4.2 — IGalaxyDataReader is the test seam for IReadable. PR 4.4 supplies the
// production implementation that wraps GalaxyMxSession's SubscribeBulk + StreamEvents
// pump; until then ReadAsync throws NotSupportedException when the reader is null
// (legacy-host backend handles reads in production via DriverNodeManager's
// capability-routing).
private readonly IGalaxyDataReader? _dataReader;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed;
@@ -42,19 +50,20 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
string driverInstanceId,
GalaxyDriverOptions options,
ILogger<GalaxyDriver>? logger = null)
: this(driverInstanceId, options, hierarchySource: null, logger)
: this(driverInstanceId, options, hierarchySource: null, dataReader: null, logger)
{
}
/// <summary>
/// Test-visible ctor — inject a custom <see cref="IGalaxyHierarchySource"/> so
/// <see cref="DiscoverAsync"/> can be exercised against canned hierarchies without
/// building a real gRPC channel.
/// Test-visible ctor — inject custom seams so <see cref="DiscoverAsync"/> +
/// <see cref="ReadAsync"/> can be exercised against canned data without building
/// real gRPC channels.
/// </summary>
internal GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
IGalaxyHierarchySource? hierarchySource,
IGalaxyDataReader? dataReader = null,
ILogger<GalaxyDriver>? logger = null)
{
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
@@ -63,6 +72,7 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
_hierarchySource = hierarchySource;
_dataReader = dataReader;
}
/// <inheritdoc />
@@ -130,6 +140,31 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
await discoverer.DiscoverAsync(builder, cancellationToken).ConfigureAwait(false);
}
// ===== IReadable (PR 4.2 — abstraction; PR 4.4 supplies production reader) =====
/// <inheritdoc />
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(fullReferences);
if (fullReferences.Count == 0) return Task.FromResult<IReadOnlyList<DataValueSnapshot>>([]);
if (_dataReader is null)
{
// The production GW-backed reader builds on the StreamEvents pump that PR 4.4
// ships; until then a real gateway-driver instance can't fulfill reads.
// Tests that need to exercise IReadable inject a fake reader via the internal
// ctor; production deployments running on this PR should keep the
// legacy-host backend selected via the Galaxy:Backend flag (PR 4.W).
throw new NotSupportedException(
"GalaxyDriver.ReadAsync requires the StreamEvents-backed reader from PR 4.4. " +
"Until that lands, route reads through the legacy-host backend (Galaxy:Backend=legacy-host).");
}
return _dataReader.ReadAsync(fullReferences, cancellationToken);
}
/// <summary>
/// Lazily builds the default <see cref="IGalaxyHierarchySource"/> from
/// <c>_options.Gateway</c>. Owned <see cref="GalaxyRepositoryClient"/> is disposed in

View File

@@ -0,0 +1,102 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Driver-side wrapper around the gateway's <see cref="MxGatewaySession"/>. Owns the
/// MXAccess <c>Register</c> handle, caches the per-tag item handles AddItem returns,
/// and coordinates the read / write / subscribe call paths. PRs 4.2-4.5 fill this in
/// incrementally:
/// <list type="bullet">
/// <item>PR 4.2 (this PR) — skeleton + lifecycle wiring.</item>
/// <item>PR 4.3 — write path.</item>
/// <item>PR 4.4 — subscription registry + event pump + the production
/// <see cref="IGalaxyDataReader"/> implementation that drives the read path.</item>
/// <item>PR 4.5 — reconnect supervisor.</item>
/// </list>
/// </summary>
public sealed class GalaxyMxSession : IAsyncDisposable
{
private readonly GalaxyMxAccessOptions _options;
private readonly ILogger _logger;
// Owned gateway client + session — populated when ConnectAsync runs. Tests can leave
// them null and exercise the surface via injected IGalaxyDataReader fakes.
private MxGatewayClient? _ownedClient;
private MxGatewaySession? _session;
private int _serverHandle;
private bool _disposed;
public GalaxyMxSession(GalaxyMxAccessOptions options, ILogger? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger.Instance;
}
public bool IsConnected => _session is not null;
/// <summary>
/// Server-side handle returned by MXAccess <c>Register</c>. Zero before
/// <see cref="ConnectAsync"/> opens the session.
/// </summary>
public int ServerHandle => _serverHandle;
/// <summary>
/// Connect the underlying gateway client + open an MXAccess session + register the
/// configured client name. Idempotent — second calls are no-ops while
/// <see cref="IsConnected"/> is true.
/// </summary>
public async Task ConnectAsync(MxGatewayClientOptions clientOptions, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_session is not null) return;
_ownedClient = MxGatewayClient.Create(clientOptions);
_session = await _ownedClient.OpenSessionAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
_serverHandle = await _session.RegisterAsync(_options.ClientName, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"GalaxyMxSession connected — clientName={ClientName} serverHandle={Handle}",
_options.ClientName, _serverHandle);
}
/// <summary>
/// Test seam — attach a session opened externally (e.g. against an in-process gw
/// fake). Skips the gateway-client construction so tests can drive the session
/// surface without spinning a real gRPC channel. Caller retains client ownership.
/// </summary>
internal void AttachForTests(MxGatewaySession session, int serverHandle)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_session = session ?? throw new ArgumentNullException(nameof(session));
_serverHandle = serverHandle;
}
/// <summary>
/// Returns the underlying gateway session. Null until <see cref="ConnectAsync"/> or
/// <see cref="AttachForTests"/> runs. PR 4.3 / 4.4 use this to issue commands.
/// </summary>
public MxGatewaySession? Session => _session;
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (_session is not null)
{
try { await _session.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "GalaxyMxSession session dispose failed (best-effort)"); }
}
_session = null;
if (_ownedClient is not null)
{
try { await _ownedClient.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "GalaxyMxSession client dispose failed (best-effort)"); }
}
_ownedClient = null;
}
}

View File

@@ -0,0 +1,27 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Driver-side seam for one-shot reads. Production implementation (PR 4.4) wraps
/// <c>MxGatewaySession</c>'s SubscribeBulk + StreamEvents path to obtain values; tests
/// substitute a fake returning canned snapshots.
/// </summary>
/// <remarks>
/// The interface is deliberately minimal — no per-tag overload, no continuation
/// points. The driver-side <c>IReadable.ReadAsync</c> contract guarantees a value per
/// requested tag in input order, with status codes carrying the per-tag failure mode
/// (e.g. BadInternalError for transport failure on a single tag, BadOutOfService for
/// a tag the gateway didn't recognise).
/// </remarks>
public interface IGalaxyDataReader
{
/// <summary>
/// Read each <paramref name="fullReferences"/> entry once and return one
/// <see cref="DataValueSnapshot"/> per request entry, in input order.
/// Implementations MUST return the same length as the input — partial-tag
/// failures are encoded as Bad-quality snapshots, not omitted.
/// </summary>
Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,54 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Translates gateway-side <see cref="MxValue"/> instances into the boxed CLR objects
/// <c>DataValueSnapshot.Value</c> carries. Mirrors the seven Galaxy data types in
/// <c>DataTypeMap</c> (Boolean, Int32, Int64, Float32, Float64, String, DateTime), plus
/// the array variants exposed by <see cref="MxArray"/>. Unknown / awkward values fall
/// back to the <c>raw_value</c> bytes so a forward-compatible MXAccess deployment
/// doesn't lose data on the wire — the consumer can opt to deserialise the bytes.
/// </summary>
internal static class MxValueDecoder
{
public static object? Decode(MxValue? value)
{
if (value is null) return null;
if (value.IsNull) return null;
return value.KindCase switch
{
MxValue.KindOneofCase.BoolValue => value.BoolValue,
MxValue.KindOneofCase.Int32Value => value.Int32Value,
MxValue.KindOneofCase.Int64Value => value.Int64Value,
MxValue.KindOneofCase.FloatValue => value.FloatValue,
MxValue.KindOneofCase.DoubleValue => value.DoubleValue,
MxValue.KindOneofCase.StringValue => value.StringValue,
MxValue.KindOneofCase.TimestampValue => DecodeTimestamp(value.TimestampValue),
MxValue.KindOneofCase.ArrayValue => DecodeArray(value.ArrayValue),
MxValue.KindOneofCase.RawValue => value.RawValue.ToByteArray(),
_ => null,
};
}
private static DateTime? DecodeTimestamp(Timestamp? ts) => ts?.ToDateTime();
private static object? DecodeArray(MxArray? array)
{
if (array is null) return null;
return array.ValuesCase switch
{
MxArray.ValuesOneofCase.BoolValues => array.BoolValues.Values.ToArray(),
MxArray.ValuesOneofCase.Int32Values => array.Int32Values.Values.ToArray(),
MxArray.ValuesOneofCase.Int64Values => array.Int64Values.Values.ToArray(),
MxArray.ValuesOneofCase.FloatValues => array.FloatValues.Values.ToArray(),
MxArray.ValuesOneofCase.DoubleValues => array.DoubleValues.Values.ToArray(),
MxArray.ValuesOneofCase.StringValues => array.StringValues.Values.ToArray(),
MxArray.ValuesOneofCase.TimestampValues => array.TimestampValues.Values.Select(t => t.ToDateTime()).ToArray(),
_ => null,
};
}
}

View File

@@ -0,0 +1,118 @@
using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Maps the gateway's <see cref="MxStatusProxy"/> (raw MXAccess HRESULT + category bits)
/// to OPC UA <c>StatusCode</c> uints. Replaces the legacy
/// <c>MxAccessGalaxyBackend.ToWire</c> heuristic (Quality &gt;= 192 → Good, else Uncertain)
/// with an explicit table that preserves specific codes (BadNotConnected, OutOfService,
/// UncertainSubNormal, etc.) instead of collapsing to category buckets.
/// </summary>
/// <remarks>
/// OPC DA quality bytes are 16-bit values arranged as <c>[QQSSSSSSLLNNNN]</c>:
/// Q = quality category (Bad/Uncertain/Good = 0/1/3), S = substatus, L = limit, N = vendor.
/// This mapper consumes the LOW byte (where the Q+S bits live) — the same byte the legacy
/// Wonderware Historian SDK exposed as the raw quality byte. Category-only fallback paths
/// handle deployment versions of MXAccess that surface unfamiliar substatuses.
///
/// Unknown substatus values fall back to the matching category bucket (<c>Good</c>,
/// <c>Uncertain</c>, <c>Bad</c>) and emit a single diagnostic log line per session via
/// the supplied logger so field captures can extend the table.
/// </remarks>
internal static class StatusCodeMap
{
// OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good),
// 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte.
public const uint Good = 0x00000000u;
public const uint GoodLocalOverride = 0x00D80000u;
public const uint Uncertain = 0x40000000u;
public const uint UncertainLastUsableValue = 0x40A40000u;
public const uint UncertainSensorNotAccurate = 0x408D0000u;
public const uint UncertainEngineeringUnitsExceeded = 0x408E0000u;
public const uint UncertainSubNormal = 0x408F0000u;
public const uint Bad = 0x80000000u;
public const uint BadConfigurationError = 0x80890000u;
public const uint BadNotConnected = 0x808A0000u;
public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadSensorFailure = 0x808C0000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadOutOfService = 0x808D0000u;
public const uint BadWaitingForInitialData = 0x80320000u;
public const uint BadInternalError = 0x80020000u;
/// <summary>
/// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort,
/// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s
/// low byte) to the OPC UA StatusCode uint.
/// </summary>
public static uint FromQualityByte(byte q, ILogger? logger = null) => q switch
{
// Good family — top two bits 11b (192-255).
192 => Good,
216 => GoodLocalOverride,
// Uncertain family — top two bits 01b (64-127).
64 => Uncertain,
68 => UncertainLastUsableValue,
80 => UncertainSensorNotAccurate,
84 => UncertainEngineeringUnitsExceeded,
88 => UncertainSubNormal,
// Bad family — top two bits 00b (0-63).
0 => Bad,
4 => BadConfigurationError,
8 => BadNotConnected,
12 => BadDeviceFailure,
16 => BadSensorFailure,
20 => BadCommunicationError,
24 => BadOutOfService,
32 => BadWaitingForInitialData,
_ => Categorize(q, logger),
};
/// <summary>
/// Map a gateway-reported <see cref="MxStatusProxy"/> to OPC UA StatusCode. Honors
/// the success flag, then the detail byte (treated as a quality substatus), with a
/// transport-error fallback for status rows whose detected_by indicates the failure
/// happened before the MXAccess call ran.
/// </summary>
public static uint FromMxStatus(MxStatusProxy? status, ILogger? logger = null)
{
if (status is null) return Good;
if (status.Success != 0) return Good;
// Detail field carries the substatus when the worker translated MX-style codes;
// when zero, infer from category + detected_by.
var detail = (byte)(status.Detail & 0xFF);
if (detail != 0) return FromQualityByte(detail, logger);
// detected_by != Mxaccess (raw_detected_by != the MXAccess source enum) implies
// the failure happened pre-call (gateway, worker, transport) — surface as a
// communication error rather than a generic Bad.
if (status.RawDetectedBy != 0) return BadCommunicationError;
return Bad;
}
private static uint Categorize(byte q, ILogger? logger)
{
if (q >= 192) { Log(logger, q, "Good"); return Good; }
if (q >= 64) { Log(logger, q, "Uncertain"); return Uncertain; }
Log(logger, q, "Bad");
return Bad;
}
private static void Log(ILogger? logger, byte q, string bucket)
{
// Best-effort diagnostic so field captures can extend the table — once per bucket
// per session is plenty (the LogWarning level is rate-limited by Serilog filters
// in production).
logger?.LogWarning(
"Unrecognised MXAccess quality byte 0x{Q:X2} — falling back to {Bucket} category. " +
"Field capture welcome — extend StatusCodeMap.FromQualityByte.", q, bucket);
}
}