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:
@@ -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
|
||||
|
||||
102
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs
Normal file
102
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
118
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs
Normal file
118
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs
Normal 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 >= 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user