From 875b82175d4029538f4244fb44eee77f9a684d57 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 15:14:11 -0500 Subject: [PATCH] fix(galaxy): #866 grade OK-status data changes by the item's OPC DA quality The gateway fills Statuses on every data change (live OnDataChange, value-cache replay and session snapshot all carry Quality too), and EventPump used the status row whenever one was present, so Quality was never read: an off-scan, Bad or Uncertain attribute with an OK status row was published Good. A failure status row still wins; otherwise the quality byte grades the value (legacy parity: quality >= 192 Good). Live, read-only on VD03 (DEV): healthy ScanState and _TestArea attributes stay Good (0x00000000) via quality 192; the stopped JoePlatform/JoeAppEngine ScanState now publish BadCommunicationError (0x80050000) instead of generic Bad, with no "unrecognised quality byte" warning. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk --- .../Runtime/EventPump.cs | 8 +- .../Runtime/EventPumpQualityTests.cs | 84 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/EventPumpQualityTests.cs 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 cdb36a264..20a9a4597 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 @@ -2,6 +2,7 @@ using System.Diagnostics.Metrics; using System.Threading.Channels; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.MxGateway.Client; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -512,7 +513,12 @@ internal sealed class EventPump : IAsyncDisposable ServerTimestampUtc: DateTime.UtcNow); } - var statusCode = ev.Statuses.Count > 0 + // A failure status row wins (it names why the read failed); otherwise the item's OPC DA quality grades + // 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. + 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/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 new file mode 100644 index 000000000..f11b46396 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/EventPumpQualityTests.cs @@ -0,0 +1,84 @@ +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Google.Protobuf.WellKnownTypes; +using Shouldly; +using Xunit; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; + +/// +/// #866 review: the gateway fills Statuses on every data change, and the pump used the status row +/// whenever one was present, so the item's OPC DA quality was never read. An off-scan / Bad / Uncertain +/// attribute carrying an OK status row was published Good. A failure status row still wins. +/// +public sealed class EventPumpQualityTests +{ + private const int WaitMs = 10_000; + + private static MxStatusProxy Ok => new() { Success = 1, Category = MxStatusCategory.Ok }; + + private sealed class ChannelSubscriber : IGalaxySubscriber + { + private readonly Channel _events = Channel.CreateUnbounded(); + + public Task> SubscribeBulkAsync( + IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken) => Task.CompletedTask; + + public async IAsyncEnumerable StreamEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var ev in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + yield return ev; + } + + public ValueTask EmitAsync(int handle, int quality, MxStatusProxy status) + { + var ev = new MxEvent + { + Family = MxEventFamily.OnDataChange, + ItemHandle = handle, + Value = new MxValue { DoubleValue = 1 }, + Quality = quality, + SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow), + }; + ev.Statuses.Add(status); + return _events.Writer.WriteAsync(ev); + } + } + + private static async Task PublishedStatusAsync(int quality, MxStatusProxy status) + { + var subscriber = new ChannelSubscriber(); + var registry = new SubscriptionRegistry(); + registry.Register(1, [new TagBinding("Tank01.Level", 7)]); + var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "Quality"); + pump.OnDataChange += (_, e) => delivered.TrySetResult(e); + pump.Start(); + + await subscriber.EmitAsync(7, quality, status); + (await Task.WhenAny(delivered.Task, Task.Delay(WaitMs, TestContext.Current.CancellationToken))).ShouldBe(delivered.Task); + return (await delivered.Task).Snapshot.StatusCode; + } + + [Theory] + [InlineData(192, 0x00000000u)] // Good + [InlineData(24, 0x808D0000u)] // Bad OutOfService (off scan) + [InlineData(8, 0x808A0000u)] // Bad NotConnected + [InlineData(68, 0x40900000u)] // Uncertain LastUsableValue + public async Task An_ok_status_row_is_graded_by_the_item_quality(int quality, uint expected) + => (await PublishedStatusAsync(quality, Ok)).ShouldBe(expected); + + [Fact] + public async Task A_failure_status_row_wins_over_a_good_quality() + { + var failure = new MxStatusProxy { Success = 0, Category = MxStatusCategory.CommunicationError, Detail = 2 }; + (await PublishedStatusAsync(192, failure)).ShouldBe(StatusCodeMap.BadCommunicationError); + } +}