Merge ph-T4 (pluggable historian)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 12:57:52 -04:00
6 changed files with 198 additions and 5 deletions
@@ -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;
}
@@ -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,
},
};
}
@@ -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;
}