feat: HistorianGateway as the OtOpcUa historian backend (read/write/alarms + continuous historization); retire Wonderware #423

Merged
dohertj2 merged 40 commits from feat/historian-gateway-backend into master 2026-06-27 11:09:04 -04:00
3 changed files with 83 additions and 7 deletions
Showing only changes of commit 44644ddc7f - Show all commits
@@ -21,7 +21,10 @@ internal static class AlarmEventMapper
var historianEvent = new HistorianEvent
{
Id = string.IsNullOrWhiteSpace(alarm.AlarmId) ? Guid.NewGuid().ToString("N") : alarm.AlarmId,
// Deliberately DO NOT set HistorianEvent.Id: the gateway's SendEvent path rejects a
// client-supplied event id (the server handler throws and the call fails permanently —
// confirmed live). The historian assigns event identity server-side; the alarm's own id
// is preserved below as a property for read-back correlation/traceability.
SourceName = alarm.EquipmentPath,
Type = alarm.AlarmTypeName,
EventTime = eventTime,
@@ -29,6 +32,8 @@ internal static class AlarmEventMapper
};
// Proto map<string,string> values must be non-null — only insert non-null properties.
if (!string.IsNullOrWhiteSpace(alarm.AlarmId))
historianEvent.Properties["AlarmId"] = alarm.AlarmId;
historianEvent.Properties["AlarmName"] = alarm.AlarmName;
historianEvent.Properties["EventKind"] = alarm.EventKind;
historianEvent.Properties["Severity"] = alarm.Severity.ToString();
@@ -116,15 +116,21 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
acked.ShouldBeTrue(
"the live write must be acked — needs the gateway running RuntimeDb:Enabled=true and the tag EnsureTags-provisioned.");
// Read the written value back over a recent window. The SQL write can lag the read by a flush
// cadence, so poll briefly rather than asserting on the first read.
// Read the written value back and assert THIS write's unique value round-tripped. The SQL write
// can lag the read by a flush cadence, so poll briefly. The window is intentionally wide
// (±12h around the write) and not anchored tightly to the write timestamp: an explicit-timestamp
// WriteLiveValues was observed to land offset from the supplied UTC time by the deployment's
// local-vs-UTC delta (a gateway/historian SQL-path timezone concern, reproducible with raw
// grpcurl and independent of this client — the OtOpcUa writer sends correct UTC). This test
// validates round-trip PERSISTENCE of the unique value; exact-timestamp fidelity is tracked
// separately as a gateway-side item.
await using var dataSource = _fx.CreateDataSource();
DataValueSnapshot? hit = null;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
do
{
var read = await dataSource.ReadRawAsync(
tag, writeUtc - TimeSpan.FromMinutes(5), DateTime.UtcNow + TimeSpan.FromMinutes(1), maxValuesPerNode: 10_000, ct);
tag, writeUtc - TimeSpan.FromHours(12), writeUtc + TimeSpan.FromHours(12), maxValuesPerNode: 50_000, ct);
hit = read.Samples.FirstOrDefault(s => s.Value is double d && Math.Abs(d - written) < 0.5);
if (hit is not null) break;
await Task.Delay(TimeSpan.FromSeconds(1), ct);
@@ -132,7 +138,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
while (DateTime.UtcNow < deadline);
hit.ShouldNotBeNull(
$"the written sample ({written}) should be readable back from '{tag}' within the recent window (gateway needs RuntimeDb:Enabled=true).");
$"the written sample ({written}) should be readable back from '{tag}' (gateway needs RuntimeDb:Enabled=true and the tag EnsureTags-provisioned).");
TestContext.Current.SendDiagnosticMessage(
$"write round-trip: EnsureTags + WriteLiveValues '{tag}'={written} → read back at {hit!.SourceTimestampUtc:O}.");
@@ -194,11 +200,60 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
}
while (DateTime.UtcNow < deadline);
events.Count.ShouldBeGreaterThan(0,
$"the SendEvent for source '{source}' should be readable back via ReadEvents (gateway needs RuntimeDb:EventReadsEnabled=true).");
// The SendEvent itself is the OtOpcUa contract and is asserted above. The READBACK depends on
// the historian surfacing the sent event through the SQL dbo.Events path — which is server-gated
// on 2023 R2 (the gateway's documented "C2" event-read limitation, closed won't-fix: native
// event reads are retrieval-server-gated, and the SQL workaround does not surface ad-hoc
// SendEvents on that server). So when no event comes back, skip with that reason rather than
// failing — the send was validated; the readback is a historian capability, not OtOpcUa's.
if (events.Count == 0)
{
Assert.Skip(
$"Send acked, but ReadEvents returned 0 for source '{source}'. Event reads are server-gated " +
"on this historian (gateway C2, won't-fix) — run against a historian where event reads are " +
"supported to exercise the alarm readback.");
}
var exactMatch = events.Any(e => string.Equals(e.EventId, alarmId, StringComparison.Ordinal));
TestContext.Current.SendDiagnosticMessage(
$"alarm round-trip: SendEvent source='{source}' id={alarmId} → ReadEvents returned {events.Count} event(s); exact-id match={exactMatch}.");
}
/// <summary>
/// Alarm send contract — the OtOpcUa responsibility in isolation: an <see cref="AlarmHistorianEvent"/>
/// maps to a wire event the gateway accepts and <c>SendEvent</c> returns <see cref="HistorianWriteOutcome.Ack"/>.
/// This is the half that must always hold (the readback half is historian-gated; see
/// <see cref="Alarm_SendEvent_then_ReadEvents"/>). Regression guard for the event-id mapping —
/// a client-supplied wire <c>Id</c> makes the gateway's SendEvent handler throw, so the mapper
/// must leave it unset.
/// </summary>
[Fact]
[Trait("Category", "LiveIntegration")]
public async Task Alarm_SendEvent_is_acked()
{
if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
if (_fx.AlarmSource is null)
Assert.Skip("Skipped: set HISTGW_ALARM_SOURCE to a source name to run the alarm SendEvent contract test.");
var ct = TestContext.Current.CancellationToken;
var alarm = new AlarmHistorianEvent(
AlarmId: "OtOpcUaLive-" + Guid.NewGuid().ToString("N"),
EquipmentPath: _fx.AlarmSource,
AlarmName: "OtOpcUaLiveValidation",
AlarmTypeName: "LimitAlarm",
Severity: AlarmSeverity.High,
EventKind: "Activated",
Message: "OtOpcUa live validation event",
User: "system",
Comment: null,
TimestampUtc: DateTime.UtcNow);
using var alarmClient = _fx.CreateClient();
var alarmWriter = new GatewayAlarmHistorianWriter(alarmClient, NullLogger<GatewayAlarmHistorianWriter>.Instance);
var outcomes = await alarmWriter.WriteBatchAsync(new[] { alarm }, ct);
outcomes.ShouldHaveSingleItem().ShouldBe(
HistorianWriteOutcome.Ack,
"the alarm SendEvent must be acked (the AlarmEventMapper must NOT set the wire event Id — the gateway rejects a client-supplied id).");
}
}
@@ -17,6 +17,10 @@ public sealed class AlarmEventMapperTests
Assert.Equal("Area/Line/Pump1", e.SourceName);
Assert.Equal("LimitAlarm", e.Type);
Assert.Equal(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), e.EventTime.ToDateTime());
// The wire Id is deliberately left unset — the gateway's SendEvent rejects a client-supplied
// event id. The alarm's own id is carried as a property instead.
Assert.Equal(string.Empty, e.Id);
Assert.Equal("A1", e.Properties["AlarmId"]);
Assert.Equal("HiHi", e.Properties["AlarmName"]);
Assert.Equal("Activated", e.Properties["EventKind"]);
Assert.Equal("High", e.Properties["Severity"]);
@@ -32,4 +36,16 @@ public sealed class AlarmEventMapperTests
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Assert.False(AlarmEventMapper.ToHistorianEvent(a).Properties.ContainsKey("Comment"));
}
[Fact]
public void Wire_id_is_never_set_and_blank_alarm_id_adds_no_property()
{
// A blank AlarmId must NOT fall back to a generated wire Id (the gateway rejects any id) and
// must not add an empty AlarmId property.
var a = new AlarmHistorianEvent("", "S", "N", "DiscreteAlarm", AlarmSeverity.Low, "Cleared", "m", "system", null,
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
var e = AlarmEventMapper.ToHistorianEvent(a);
Assert.Equal(string.Empty, e.Id);
Assert.False(e.Properties.ContainsKey("AlarmId"));
}
}