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);
+ }
}