feat(historian): GatewayHistorianProvider; status→DA quality reverse mapping
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHistorianBackendProvider"/> for the HistorianGateway sidecar. Numeric-only
|
||||
/// (<c>Double</c>/<c>Int64</c>/<c>Boolean</c>) and read/alarm/provisioning only — no HistoryUpdate.
|
||||
/// </summary>
|
||||
public sealed class GatewayHistorianProvider : IHistorianBackendProvider
|
||||
{
|
||||
private static readonly IReadOnlySet<HistorianValueKind> Kinds = new HashSet<HistorianValueKind>
|
||||
{
|
||||
HistorianValueKind.Double,
|
||||
HistorianValueKind.Int64,
|
||||
HistorianValueKind.Boolean,
|
||||
};
|
||||
|
||||
private readonly ServerHistorianOptions _options;
|
||||
|
||||
/// <summary>Creates the provider over the bound <c>ServerHistorian</c> options.</summary>
|
||||
/// <param name="options">The gateway connection options.</param>
|
||||
public GatewayHistorianProvider(ServerHistorianOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Gateway";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlySet<HistorianValueKind> SupportedKinds => Kinds;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IHistorianDataSource CreateDataSource(IServiceProvider sp) => GatewayHistorian.CreateDataSource(_options, sp);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IHistorianValueWriter CreateValueWriter(IServiceProvider sp)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sp);
|
||||
var loggerFactory = sp.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
|
||||
var logger = sp.GetService<ILogger<GatewayHistorianValueWriter>>()
|
||||
?? NullLogger<GatewayHistorianValueWriter>.Instance;
|
||||
return new GatewayHistorianValueWriter(HistorianGatewayClientAdapter.Create(_options, loggerFactory), logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAlarmHistorianWriter CreateAlarmWriter(IServiceProvider sp) => GatewayHistorian.CreateAlarmWriter(_options, sp);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IHistorianProvisioning CreateProvisioning(IServiceProvider sp) => GatewayHistorian.CreateProvisioner(_options, sp);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IHistorianDataUpdater? CreateUpdater(IServiceProvider sp) => null;
|
||||
}
|
||||
+32
@@ -43,4 +43,36 @@ internal static class GatewayQualityMapper
|
||||
_ when q >= 64 => HistorianStatusCodes.Uncertain,
|
||||
_ => HistorianStatusCodes.Bad,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Reverse of <see cref="Map"/>: maps an OPC UA StatusCode to the OPC DA quality byte the gateway
|
||||
/// expects. Every explicit <see cref="Map"/> entry round-trips; an unknown status falls back to its
|
||||
/// severity bucket (Good 192, Uncertain 64, Bad 0).
|
||||
/// </summary>
|
||||
/// <param name="statusCode">The OPC UA StatusCode.</param>
|
||||
/// <returns>The OPC DA quality byte.</returns>
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+17
-5
@@ -38,8 +38,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
|
||||
/// </remarks>
|
||||
public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncDisposable
|
||||
{
|
||||
private const uint LegacyGoodQuality = 192;
|
||||
|
||||
private readonly IHistorianGatewayClient _client;
|
||||
private readonly ILogger<GatewayHistorianValueWriter> _logger;
|
||||
|
||||
@@ -70,12 +68,20 @@ public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncD
|
||||
var liveValues = new List<HistorianLiveValue>(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 <remarks>.
|
||||
Quality = LegacyGoodQuality,
|
||||
// server-managed, silently discarded). Populated deliberately from the value's
|
||||
// status; Good maps to 192 as before. See the class <remarks>.
|
||||
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;
|
||||
}
|
||||
|
||||
+38
@@ -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<GatewayHistorianDataSource>(p.CreateDataSource(sp));
|
||||
var writer = Assert.IsType<GatewayHistorianValueWriter>(p.CreateValueWriter(sp));
|
||||
Assert.IsType<GatewayAlarmHistorianWriter>(p.CreateAlarmWriter(sp));
|
||||
Assert.IsType<GatewayTagProvisioner>(p.CreateProvisioning(sp));
|
||||
await writer.DisposeAsync();
|
||||
}
|
||||
}
|
||||
+22
@@ -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));
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user