From 2470f82eea0384c454e720f704a1914615a695d1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 13 Nov 2026 02:01:07 -0500 Subject: [PATCH] fix(galaxy): Pending/Warning status rows map to Uncertain; detail 8001 to BadTypeMismatch (#872) Owner decision 2026-11-13 on #872 item 1. StatusCodeMap.FromMxStatus now maps a Pending or Warning row to Uncertain (0x40000000) for any detail, ahead of the detail vocabulary, keeping the once-per-(detail, category) warning. MXAccess sets success = -1 on Pending (accepted, in progress); neither category is a failure. Detail 8001 (automation-object ConfigurationError, write rejected for type conversion, live-captured on windev 2026-11-13) maps to BadTypeMismatch. 8018 stays BadUserAccessDenied via its SecurityError category, now pinned by a test. Data change: the value is published Uncertain. Write reply: the result is Uncertain, never Good, so the server (which commits only Good) reports the write not committed and reverts the node; a later MXAccess commit arrives as a data change. Host status is unchanged (probe decode treats Uncertain and Bad alike, quality < 192 -> Unknown); no alarm path calls the map. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- docs/drivers/Galaxy.md | 1 + .../Runtime/EventPump.cs | 3 +- .../Runtime/GatewayGalaxyDataWriter.cs | 8 +++ .../Runtime/StatusCodeMap.cs | 38 ++++++++++-- .../Runtime/EventPumpQualityTests.cs | 12 ++++ .../GatewayGalaxyDataWriterFailClosedTests.cs | 43 +++++++++++++ .../Runtime/StatusCodeMapTests.cs | 60 +++++++++++++++++++ 7 files changed, 158 insertions(+), 7 deletions(-) diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md index a5dbe9c85..63027e9d3 100644 --- a/docs/drivers/Galaxy.md +++ b/docs/drivers/Galaxy.md @@ -224,6 +224,7 @@ Every (re)open of `StreamAlarms` replays the gateway's active-alarm snapshot. `G - **A stream the gateway ends cleanly is treated as a fault.** A closed or evicted gateway session completes the `StreamEvents` RPC *without an error*, so nothing throws. `EventPump` reports that to the `ReconnectSupervisor` exactly as it reports a transport fault. Before this it exited silently: the supervisor was never told, `GetHealth()` kept reporting `Healthy`, and because the [comms-loss watchdog](../DriverLifecycle.md) requires a **non-Healthy** state to trip, it never fired either — the driver went permanently deaf with no signal anywhere. Nothing else in the system can observe this, so do not remove the notification on the grounds that "a clean end isn't an error". - **Replay gaps** (`galaxy.events.replay_gaps`): the gateway signals that events were evicted from its replay ring before we could read them. The driver responds by re-issuing `SubscribeBulk` for every tracked subscription — MXAccess delivers an initial value per item on subscribe, so re-subscribing *is* the re-snapshot the contract asks for. It does **not** restart the event pump (the stream carrying the sentinel is healthy) and it honours `Reconnect:ReplayOnSessionLost=false` by logging and standing down, on the grounds that an operator who delegated recovery to the gateway did not ask for a re-subscribe on a different trigger. ⚠️ This cannot currently fire: the sentinel is only emitted on a stream opened with `after_worker_sequence > 0`, and the driver opens with `0`, which the gateway treats as "fresh stream, not a resume". Enabling resume is a bigger change than it looks — the sequence is only meaningful **within one gateway session**, and the reconnect path recreates the session, so a remembered sequence replayed against a fresh session would set the server's live watermark *above* every event it is about to send, silencing the stream rather than recovering it. +- **MXAccess status rows → OPC UA status (`StatusCodeMap.FromMxStatus`, #866/#872)**: an `Ok` row is graded by the item's OPC DA quality. Any other row's `detail` is an MxStatusDetail code (never a quality byte) and maps through the mxaccessgw vocabulary table (e.g. 1007 → `BadNotWritable`, 1008 → `BadUserAccessDenied`, 13/1005 → `BadTypeMismatch`, and the live-captured automation-object code **8001** — write rejected for type conversion → `BadTypeMismatch`). Unmapped details fall back to the category (`SecurityError` → `BadUserAccessDenied`, so 8018 is too; `CommunicationError` → `BadCommunicationError`; `ConfigurationError` → `BadConfigurationError`; else `Bad`), logged once per (detail, category). `detected_by` never decides the code. **`Pending` and `Warning` rows map to `Uncertain` (`0x40000000`) for any detail**: MXAccess sets success = -1 on Pending (accepted, in progress), so they are not failures. On a data change the value is published Uncertain. On a write reply the result is Uncertain, not Good, so the server reports the write as not committed and reverts the node; a commit MXAccess completes later arrives as a normal data change. Neither category has been seen on a data-change or write-reply row yet (mxaccessgw `docs/LiveCaptures-2026-11-13.md` §3), so the first one logs a warning. - **Undecodable values** (`galaxy.events.undecodable`): a value kind this driver has no CLR representation for is published as **`BadTypeMismatch` with a null value**, and the first occurrence of each distinct kind is logged. It is *not* published as a null carrying the event's own quality: an MXAccess event can be Good-quality while the payload is one we cannot map, and a good null is indistinguishable from a genuine null reading — a client would consume a value it never received as a real one, with the failure visible only in a log. Non-zero here means the gateway is sending a wire type `MxValueDecoder` has no mapping for and those readings are being lost; the fix is a decoder mapping, not a quality change. - **Initial values can overtake the subscribe reply (#859)** (`galaxy.events.early_parked` / `early_replayed` / `early_overflow`): mxaccessgw gives no reply-before-event ordering — the worker frames the SubscribeBulk reply on a thread-pool continuation while the STA is already pumping the OnDataChange that Advise queued, and the reply (unary Invoke) and the event (StreamEvents) are separate HTTP/2 streams anyway. An event for an unregistered handle is therefore only "stale after unsubscribe" when no subscribe is in flight. Every SubscribeBulk call site (`SubscribeAsync`, the subscribe-once `ReadAsync` — which waits only for the initial value and would otherwise read `BadTimeout` — the reconnect/replay-gap `ResubscribeAllAsync` (one window per subscription, not across the whole replay), `PerPlatformProbeWatcher`) opens `SubscriptionRegistry.BeginPendingSubscribe()` before the RPC and closes it right after registering, never later; inside the window unknown-handle events are parked, promoted by the `Register`/`Rebind` that binds the handle — map writes and promotion are one critical section, so no later event can resolve in between — and replayed on the pump's dispatch thread before any later event for that handle. Held events (parked + promoted-but-unreplayed) are bounded at 100 000 together. Unclaimed parked events are trimmed by **window sequence**, not only when every window has closed (concurrent reads keep some window open almost permanently): each window gets a monotonically increasing sequence when it opens, each parked event is stamped with the newest sequence, and once every window that opened at or before that stamp has closed the event is discarded — outside any window the stale drop is unchanged. **Sessions (#861):** item handles are per gateway session and the next session reuses the numbers. On reconnect `ReopenAsync` calls `SubscriptionRegistry.StartNewSession()` both before and after the gateway session swap (`ReplayAsync` does it only when no reopen ran), and the replay re-subscribes only subscriptions still holding the previous session's handles (one registered after the swap is already live). The pump is always restarted, even with nothing to replay. `StartNewSession()` discards everything held, **invalidates the reverse map** (subscriptions are kept; new-session events park until the replay's per-subscription `Rebind` promotes them, instead of resolving to the tag that owned the number before), and bumps a session number. Registration and rebind are refused for a stale session, so a `SubscribeAsync` whose reply straddled the reconnect is **re-issued on the new session** (up to 3 attempts), a subscribe-once read returns `BadCommunicationError`, and dead old-session handle numbers are never sent to `UnsubscribeBulk` (a read rebound by the replay releases the handles the registry holds, not its original ones); an old session's pump can no longer resolve through the map. Reads listen on the driver, not on a pump instance, so a read survives the reconnect's pump restart. `PerPlatformProbeWatcher` records the session with each probe handle: only current-session handles are published (fenced `TryUpsert`), unsubscribed or purged, and a seed reply that straddled a reconnect is re-issued. Purges are fenced by the session captured before the unsubscribe RPC. **Interim (I-3):** a completed `UnsubscribeBulk` purges anything parked for its handles, so a trailing event of a removed item is not promoted onto a reused handle number within one session. It cannot catch an event still in flight after the unsubscribe, and it also drops a genuine early value for a number reused that fast (a timeout rather than another tag's value). The complete fix needs a worker sequence on the SubscribeBulk reply so the driver can tell pre-subscribe events from post-subscribe ones — an optional cross-repo change in mxaccessgw. - **Native-alarm reference forms (#895)**: the gateway alarm feed names an alarm `Galaxy!..`, with `SourceObjectReference = .`. An alarm tag may carry either form as its `attributeRef`: the plain `.` the AdminUI Galaxy picker authors, or the provider-qualified form. The driver matches a transition on the full reference, then the source reference, then the full reference with its `Provider!Area.` prefix stripped. If none of those match an authored `attributeRef`, the area-free names are matched against the provider-qualified ones with their area removed, so a hand-authored `Galaxy!..` still resolves after Galaxy moves the object to another area. A reference with an empty area (`Galaxy!.`) also matches, and so does the gateway's subtag provider, which sends only `` as the source reference. The driver remembers the latest full reference per tag, including transitions that arrive before an alarm subscription is registered, so that an ack dials what `AcknowledgeAlarm` accepts, since the gateway refuses a non-provider reference. A picker-form tag that has had no transition since the driver started still dials the bare `.`. When the same alarm is authored in both forms, the provider-form tag gets the transitions while the area matches, and a warning is logged. Before #895, a picker-authored alarm never went active (found in the G6 live gate, `docs/drivers/evidence/galaxy-g6-e2e-2026-11-12.md`). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/EventPump.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/EventPump.cs index 40dc34254..d9f39049d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/EventPump.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/EventPump.cs @@ -583,7 +583,8 @@ internal sealed class EventPump : IAsyncDisposable // the value. The gateway fills Statuses on every data change, so keying on "any statuses present" meant // Quality was never read and an off-scan / Bad / Uncertain attribute with an OK status row was // published Good (#866 review). Every gateway path (live OnDataChange, value-cache replay, session - // snapshot) carries Quality. + // snapshot) carries Quality. A Pending or Warning row (not IsSuccess, which is category == Ok) publishes + // the decoded value as Uncertain, not Bad (#872). var statusCode = ev.Statuses.Count > 0 && !ev.Statuses[0].IsSuccess() ? StatusCodeMap.FromMxStatus(ev.Statuses[0], _logger) : StatusCodeMap.FromQualityByte((byte)(ev.Quality & 0xFF), _logger); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs index 957459df6..cb84e1f30 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs @@ -411,6 +411,14 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter // MX-side status — the worker's WriteCompleteEvent rolls into the reply's // statuses array. Use the first row (single-write contract). + // A Pending or Warning row maps to Uncertain (#872), deliberately NOT Good: Pending means MXAccess + // accepted the write but has not confirmed the commit, and a Warning is not a clean commit either. The + // server commits only a Good write result (DriverInstanceActor.IsGoodStatus), so an Uncertain one is + // reported to the client as not committed and the node's optimistic value is reverted; if MXAccess + // does commit, the value arrives on the data-change path like any other change. This differs from the + // empty-statuses provisional Good below on purpose: there no verdict arrived at all, here MXAccess + // said "not confirmed". No live capture has shown either category on a write reply (mxaccessgw + // docs/LiveCaptures-2026-11-13.md §3); the once-per-pair warning in StatusCodeMap flags the first one. if (reply.Statuses.Count > 0) { var status = reply.Statuses[0]; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs index 52f11e472..44d9a1390 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs @@ -126,8 +126,8 @@ internal static class StatusCodeMap // byte. It used to be routed through the quality table whenever it was <= 0xFF, which mis-named // every low code: MX_E_PlatformCommunicationError (2) logged as "quality byte 0x02" and fell to // generic Bad (#866, a stopped platform's ScanState on VD03), MX_E_InvalidObjectId (8) surfaced as - // BadNotConnected, MX_E_Overflow (20) as BadCommunicationError. A failure row is also never Good or - // Uncertain, whatever its detail. + // BadNotConnected, MX_E_Overflow (20) as BadCommunicationError. A non-Ok row is never Good, whatever + // its detail; only a Pending or Warning row is Uncertain (FromCategory), every other category is Bad. // // Detail 0 routes the same way. It used to become BadCommunicationError whenever raw_detected_by != 0, on // the belief that a non-zero source meant a pre-MXAccess transport failure — but every detected_by value @@ -146,6 +146,19 @@ internal static class StatusCodeMap /// private static uint FromCategory(MxStatusProxy status, ILogger? logger) { + // Pending and Warning are not failures (#872, owner decision 2026-11-13): MXAccess sets success = -1 + // (VARIANT_TRUE) on a Pending row — accepted, still in progress — and a Warning is degraded-but-usable. + // Neither is confirmed Good, so both map to Uncertain for ANY detail, ahead of the detail vocabulary (a + // Warning row's detail qualifies the warning, it does not turn it into a failure). Live capture + // (mxaccessgw docs/LiveCaptures-2026-11-13.md §3) saw Pending only as the synchronous Suspend/Activate + // return with detail 0, and no Warning row at all — so this stays logged once per (detail, category) + // until a data-change or write-reply capture shows what these rows mean there. + if (status.Category is MxStatusCategory.Pending or MxStatusCategory.Warning) + { + LogCategoryLevelOnce(status, Uncertain, logger); + return Uncertain; + } + switch (status.Detail) { case 1: return BadTimeout; // MX_E_RequestTimedOut @@ -176,6 +189,12 @@ internal static class StatusCodeMap case 1017: return BadUserAccessDenied; // MX_E_VerifierNotHavingVerifyRights case 1010: // MX_E_ObjectInitializing (transient) case 1011: return BadOutOfService; // MX_E_EngineInitializing (transient) + // Automation-object-specific codes (8000+), live-captured on windev 2026-11-13 (mxaccessgw + // docs/LiveCaptures-2026-11-13.md §3). 8001 arrives as a ConfigurationError row when the object + // rejects a write for type conversion (a string written to a double). 8018 (SecurityError, a + // verified write naming the writer as verifier) has no case: its category already gives + // BadUserAccessDenied. + case 8001: return BadTypeMismatch; } // Everything else — resolving (11/12), repository/engine busy (7/25/26), version/command mismatches, @@ -188,14 +207,21 @@ internal static class StatusCodeMap MxStatusCategory.ConfigurationError => BadConfigurationError, _ => Bad, }; - // Once per (detail, category) per process: these rows arrive on the data-change path, so a stopped - // engine's tags would otherwise log on every update. + LogCategoryLevelOnce(status, code, logger); + return code; + } + + /// + /// Once per (detail, category) per process: these rows arrive on the data-change path, so a stopped + /// engine's tags would otherwise log on every update. + /// + private static void LogCategoryLevelOnce(MxStatusProxy status, uint code, ILogger? logger) + { if (logger is not null && UnmappedDetailsLogged.TryAdd((status.Detail, status.Category), 0)) logger.LogWarning( - "MxStatus failure row category {Category} detail {Detail} ({DiagnosticText}) mapped to 0x{Code:X8} at " + + "MxStatus non-Ok row category {Category} detail {Detail} ({DiagnosticText}) mapped to 0x{Code:X8} at " + "category level. Field capture welcome — extend StatusCodeMap.FromCategory with the detail code.", status.Category, status.Detail, status.DiagnosticText, code); - return code; } private static readonly System.Collections.Concurrent.ConcurrentDictionary<(int, MxStatusCategory), byte> diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/EventPumpQualityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/EventPumpQualityTests.cs index f11b46396..4767000bd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/EventPumpQualityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/EventPumpQualityTests.cs @@ -75,6 +75,18 @@ public sealed class EventPumpQualityTests public async Task An_ok_status_row_is_graded_by_the_item_quality(int quality, uint expected) => (await PublishedStatusAsync(quality, Ok)).ShouldBe(expected); + /// #872: a Pending or Warning status row on a data change publishes the value Uncertain, not Bad. + [Theory] + [InlineData(MxStatusCategory.Pending, -1, 0)] + [InlineData(MxStatusCategory.Pending, -1, 1007)] + [InlineData(MxStatusCategory.Warning, 0, 0)] + [InlineData(MxStatusCategory.Warning, 0, 2)] + public async Task A_pending_or_warning_status_row_publishes_uncertain(MxStatusCategory category, int success, int detail) + { + var row = new MxStatusProxy { Success = success, Category = category, Detail = detail }; + (await PublishedStatusAsync(192, row)).ShouldBe(StatusCodeMap.Uncertain); + } + [Fact] public async Task A_failure_status_row_wins_over_a_good_quality() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterFailClosedTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterFailClosedTests.cs index 83d41deab..ce0328aac 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterFailClosedTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterFailClosedTests.cs @@ -176,6 +176,49 @@ public sealed class GatewayGalaxyDataWriterFailClosedTests unconfirmed.Total.ShouldBe(0); // NOT empty-statuses ⇒ not metered unconfirmed } + /// + /// #872: a Pending or Warning row on the write reply is Uncertain — never Good (MXAccess did not confirm + /// the commit, so the server must not report the write committed) and never plain Bad. + /// + [Theory] + [InlineData(MxStatusCategory.Pending, -1, 0)] + [InlineData(MxStatusCategory.Pending, -1, 1007)] + [InlineData(MxStatusCategory.Warning, 0, 0)] + [InlineData(MxStatusCategory.Warning, 0, 1004)] + public async Task WriteOne_PendingOrWarningStatusRow_ReturnsUncertain(MxStatusCategory category, int success, int detail) + { + var reply = new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }; + reply.Statuses.Add(new MxStatusProxy { Success = success, Category = category, Detail = detail }); + var ops = new FakeMxWriteOps + { + InvokeResponder = _ => new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }, + RawWriteResponder = () => reply, + }; + var writer = NewWriter(); + + var result = await writer.WriteOneForTestAsync(ops, new WriteRequest("Tag.P", 3), SecurityClassification.FreeAccess, CancellationToken.None); + + result.StatusCode.ShouldBe(StatusCodeMap.Uncertain); + } + + /// #872: the live 8001 write refusal (string written to a double) returns BadTypeMismatch. + [Fact] + public async Task WriteOne_Detail8001Reply_ReturnsBadTypeMismatch() + { + var reply = new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }; + reply.Statuses.Add(new MxStatusProxy { Success = 0, Category = MxStatusCategory.ConfigurationError, Detail = 8001, RawDetectedBy = 5 }); + var ops = new FakeMxWriteOps + { + InvokeResponder = _ => new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }, + RawWriteResponder = () => reply, + }; + var writer = NewWriter(); + + var result = await writer.WriteOneForTestAsync(ops, new WriteRequest("Tag.T", "abc"), SecurityClassification.FreeAccess, CancellationToken.None); + + result.StatusCode.ShouldBe(StatusCodeMap.BadTypeMismatch); + } + // ---------------- fakes ---------------- /// Listens to a single instrument by name on the Galaxy driver meter and sums the values. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs index 9ed5b850c..b07816c4f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs @@ -281,6 +281,66 @@ public sealed class StatusCodeMapTests StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad); } + // ===== #872 item 1 (owner decision 2026-11-13): Pending / Warning rows are Uncertain, never Bad ===== + // MXAccess sets success = -1 (VARIANT_TRUE) on a Pending row: accepted, in progress. A Warning is + // degraded-but-usable. Neither is IsSuccess() (category == Ok), so both reach FromMxStatus, and both map to + // Uncertain for ANY detail — ahead of the detail vocabulary, so a vocabulary detail cannot turn them Bad. + + /// #872: a Pending or Warning row maps to Uncertain for detail 0 and for non-zero details, + /// including details the vocabulary would otherwise map to a specific Bad code. + /// Pending or Warning. + /// The raw success value (Pending carries -1 live). + /// The MxStatusDetail code. + [Theory] + [InlineData(MxStatusCategory.Pending, -1, 0)] // the live-captured shape (Suspend/Activate return) + [InlineData(MxStatusCategory.Pending, -1, 1)] // MX_E_RequestTimedOut would be BadTimeout + [InlineData(MxStatusCategory.Pending, 0, 1007)] // MX_E_NotWriteable would be BadNotWritable + [InlineData(MxStatusCategory.Pending, -1, 8001)] // would be BadTypeMismatch + [InlineData(MxStatusCategory.Pending, -1, 2001)] // unmapped detail + [InlineData(MxStatusCategory.Warning, 0, 0)] + [InlineData(MxStatusCategory.Warning, 1, 2)] // MX_E_PlatformCommunicationError would be BadCommunicationError + [InlineData(MxStatusCategory.Warning, 0, 1004)] // MX_E_DataOutOfRange would be BadOutOfRange + [InlineData(MxStatusCategory.Warning, 0, 2001)] // unmapped detail + public void FromMxStatus_PendingOrWarning_AnyDetail_IsUncertain(MxStatusCategory category, int success, int detail) + { + var s = new MxStatusProxy { Success = success, Category = category, Detail = detail, RawDetectedBy = 0 }; + StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Uncertain); + } + + /// #872: the once-per-(detail, category) warning is kept for Pending / Warning rows. + [Fact] + public void FromMxStatus_PendingRow_LogsOncePerDetailAndCategory() + { + var logger = new CapturingLogger(); + // Detail distinct from every other test so the process-wide once-set cannot already hold it. + var s = new MxStatusProxy { Success = -1, Category = MxStatusCategory.Pending, Detail = 87201 }; + for (var i = 0; i < 5; i++) StatusCodeMap.FromMxStatus(s, logger).ShouldBe(StatusCodeMap.Uncertain); + logger.Entries.Count.ShouldBe(1); + } + + /// + /// #872 live capture (windev 2026-11-13, mxaccessgw docs/LiveCaptures-2026-11-13.md §3): detail 8001 is + /// the automation object's ConfigurationError for a write rejected on type conversion (a string written to + /// a double). It maps to BadTypeMismatch, not the category's BadConfigurationError. + /// + [Fact] + public void FromMxStatus_Detail8001_AutomationObjectTypeConversion_IsTypeMismatch() + { + var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.ConfigurationError, Detail = 8001, RawDetectedBy = 5 }; + StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadTypeMismatch); + } + + /// + /// #872 live capture: detail 8018 (SecurityError, a verified write naming the writer as verifier) stays + /// BadUserAccessDenied via its category. + /// + [Fact] + public void FromMxStatus_Detail8018_VerifiedWriteSelfVerifier_IsUserAccessDenied() + { + var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SecurityError, Detail = 8018, RawDetectedBy = 5 }; + StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied); + } + /// Verifies that top two bits of status codes follow OPC UA convention. [Fact] public void TopByteCategoryBits_StayWithinOpcUaConvention()