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
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #866 review: the gateway fills <c>Statuses</c> 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.
|
||||
/// </summary>
|
||||
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<MxEvent> _events = Channel.CreateUnbounded<MxEvent>();
|
||||
|
||||
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
|
||||
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public async IAsyncEnumerable<MxEvent> 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<uint> 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<DataChangeEventArgs>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user