diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianProvider.cs new file mode 100644 index 00000000..b30cc3b7 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianProvider.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; + +/// +/// for the HistorianGateway sidecar. Numeric-only +/// (Double/Int64/Boolean) and read/alarm/provisioning only — no HistoryUpdate. +/// +public sealed class GatewayHistorianProvider : IHistorianBackendProvider +{ + private static readonly IReadOnlySet Kinds = new HashSet + { + HistorianValueKind.Double, + HistorianValueKind.Int64, + HistorianValueKind.Boolean, + }; + + private readonly ServerHistorianOptions _options; + + /// Creates the provider over the bound ServerHistorian options. + /// The gateway connection options. + public GatewayHistorianProvider(ServerHistorianOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public string Name => "Gateway"; + + /// + public IReadOnlySet SupportedKinds => Kinds; + + /// + public IHistorianDataSource CreateDataSource(IServiceProvider sp) => GatewayHistorian.CreateDataSource(_options, sp); + + /// + public IHistorianValueWriter CreateValueWriter(IServiceProvider sp) + { + ArgumentNullException.ThrowIfNull(sp); + var loggerFactory = sp.GetService() ?? NullLoggerFactory.Instance; + var logger = sp.GetService>() + ?? NullLogger.Instance; + return new GatewayHistorianValueWriter(HistorianGatewayClientAdapter.Create(_options, loggerFactory), logger); + } + + /// + public IAlarmHistorianWriter CreateAlarmWriter(IServiceProvider sp) => GatewayHistorian.CreateAlarmWriter(_options, sp); + + /// + public IHistorianProvisioning CreateProvisioning(IServiceProvider sp) => GatewayHistorian.CreateProvisioner(_options, sp); + + /// + public IHistorianDataUpdater? CreateUpdater(IServiceProvider sp) => null; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs index 0ad47a17..1d876e62 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs @@ -43,4 +43,36 @@ internal static class GatewayQualityMapper _ when q >= 64 => HistorianStatusCodes.Uncertain, _ => HistorianStatusCodes.Bad, }; + + /// + /// Reverse of : maps an OPC UA StatusCode to the OPC DA quality byte the gateway + /// expects. Every explicit entry round-trips; an unknown status falls back to its + /// severity bucket (Good 192, Uncertain 64, Bad 0). + /// + /// The OPC UA StatusCode. + /// The OPC DA quality byte. + public static byte ToDaQuality(uint statusCode) => statusCode switch + { + HistorianStatusCodes.Good => 192, + HistorianStatusCodes.GoodLocalOverride => 216, + HistorianStatusCodes.Uncertain => 64, + HistorianStatusCodes.UncertainLastUsableValue => 68, + HistorianStatusCodes.UncertainSensorNotAccurate => 80, + HistorianStatusCodes.UncertainEngineeringUnitsExceeded => 84, + HistorianStatusCodes.UncertainSubNormal => 88, + HistorianStatusCodes.Bad => 0, + HistorianStatusCodes.BadConfigurationError => 4, + HistorianStatusCodes.BadNotConnected => 8, + HistorianStatusCodes.BadDeviceFailure => 12, + HistorianStatusCodes.BadSensorFailure => 16, + HistorianStatusCodes.BadCommunicationError => 20, + HistorianStatusCodes.BadOutOfService => 24, + HistorianStatusCodes.BadWaitingForInitialData => 32, + _ => (statusCode >> 30) switch + { + 0 => (byte)192, + 1 => (byte)64, + _ => (byte)0, + }, + }; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs index 82a5a747..df3328d6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs @@ -38,8 +38,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; /// public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncDisposable { - private const uint LegacyGoodQuality = 192; - private readonly IHistorianGatewayClient _client; private readonly ILogger _logger; @@ -70,12 +68,20 @@ public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncD var liveValues = new List(values.Count); foreach (HistorizationValue value in values) { + if (!value.Value.TryGetDouble(out double number)) + { + // The gateway live path is numeric-only; non-numeric kinds are skipped, never thrown. + _logger.LogDebug("Skipping non-numeric {Kind} value for live write.", value.Value.Kind); + continue; + } + var live = new HistorianLiveValue { - NumericValue = value.Value.Number, + NumericValue = number, // Documented-dead input on the WriteLiveValues SQL path (0.2.0 Contracts C-002 — - // server-managed, silently discarded). Populated deliberately; see the class . - Quality = LegacyGoodQuality, + // server-managed, silently discarded). Populated deliberately from the value's + // status; Good maps to 192 as before. See the class . + Quality = Mapping.GatewayQualityMapper.ToDaQuality(value.StatusCode), }; if (value.TimestampUtc is { } timestampUtc) @@ -88,6 +94,12 @@ public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncD liveValues.Add(live); } + if (liveValues.Count == 0) + { + // Every value was non-numeric: nothing to send, trivially drained. + return true; + } + WriteAck ack = await _client.WriteLiveValuesAsync(tag, liveValues, ct).ConfigureAwait(false); return ack.Success || ack.Queued; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianProviderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianProviderTests.cs new file mode 100644 index 00000000..cfe199b5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianProviderTests.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +public sealed class GatewayHistorianProviderTests +{ + private static GatewayHistorianProvider Provider() => + new(new ServerHistorianOptions { Endpoint = "http://localhost:5222", UseTls = false, ApiKey = "histgw_test_secret" }); + + private static IServiceProvider Sp() => new ServiceCollection().BuildServiceProvider(); + + [Fact] + public void Shape_is_gateway_numeric_without_updater() + { + var p = Provider(); + Assert.Equal("Gateway", p.Name); + Assert.Equal( + new[] { HistorianValueKind.Double, HistorianValueKind.Int64, HistorianValueKind.Boolean }.OrderBy(x => x), + p.SupportedKinds.OrderBy(x => x)); + Assert.Null(p.CreateUpdater(Sp())); + } + + [Fact] + public async Task Create_methods_build_gateway_seams() + { + var p = Provider(); + var sp = Sp(); + Assert.IsType(p.CreateDataSource(sp)); + var writer = Assert.IsType(p.CreateValueWriter(sp)); + Assert.IsType(p.CreateAlarmWriter(sp)); + Assert.IsType(p.CreateProvisioning(sp)); + await writer.DisposeAsync(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperReverseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperReverseTests.cs new file mode 100644 index 00000000..f46442ef --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperReverseTests.cs @@ -0,0 +1,22 @@ +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping; + +public sealed class GatewayQualityMapperReverseTests +{ + [Theory] + [InlineData((byte)192)] [InlineData((byte)216)] + [InlineData((byte)64)] [InlineData((byte)68)] [InlineData((byte)80)] [InlineData((byte)84)] [InlineData((byte)88)] + [InlineData((byte)0)] [InlineData((byte)4)] [InlineData((byte)8)] [InlineData((byte)12)] + [InlineData((byte)16)] [InlineData((byte)20)] [InlineData((byte)24)] [InlineData((byte)32)] + public void Round_trips_every_explicit_byte(byte q) + => Assert.Equal(q, GatewayQualityMapper.ToDaQuality(GatewayQualityMapper.Map(q))); + + [Theory] + [InlineData(0x00AB0000u, (byte)192)] + [InlineData(0x40AB0000u, (byte)64)] + [InlineData(0x80AB0000u, (byte)0)] + public void Unknown_status_falls_back_by_severity(uint status, byte expected) + => Assert.Equal(expected, GatewayQualityMapper.ToDaQuality(status)); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/GatewayHistorianValueWriterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/GatewayHistorianValueWriterTests.cs index 92271419..091fb40b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/GatewayHistorianValueWriterTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/GatewayHistorianValueWriterTests.cs @@ -146,4 +146,33 @@ public sealed class GatewayHistorianValueWriterTests Assert.Equal(1, fake.DisposeCallCount); } + + [Fact] + public async Task Bad_status_maps_to_da_quality() + { + var fake = new FakeHistorianGatewayClient(); + var values = new[] { new HistorizationValue(null, HistorianValue.FromDouble(1.0), 0x808A0000u) }; + + await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken); + + Assert.Equal(8u, fake.LastWriteLiveValues![0].Quality); + } + + [Fact] + public async Task Non_numeric_value_is_skipped_without_throwing() + { + var fake = new FakeHistorianGatewayClient(); + var values = new[] + { + new HistorizationValue(null, HistorianValue.FromString("x"), 0u), + new HistorizationValue(null, HistorianValue.FromDouble(42.5), 0u), + }; + + var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken); + + Assert.True(ok); + var sent = Assert.Single(fake.LastWriteLiveValues!); + Assert.Equal(42.5, sent.NumericValue); + Assert.Equal(192u, sent.Quality); + } }