diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs
index 986f82ab..b160f04f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// as and returns. The method never throws and never
/// logs tag values, hostnames, or credentials.
///
-public sealed class GatewayTagProvisioner : IHistorianProvisioning
+public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDisposable
{
///
/// Storage rate (ms) stamped on every provisioned analog tag. The gateway's EnsureTags path
@@ -104,4 +104,13 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count);
}
}
+
+ ///
+ /// Disposes the underlying gateway client (and its gRPC channel). The DI container owns this
+ /// provisioner as a singleton (AddHistorianProvisioning), so this fires once at host
+ /// shutdown — closing the channel gracefully, matching the read ()
+ /// and alarm-write () paths' dispose ownership.
+ ///
+ /// A task that represents the asynchronous operation.
+ public ValueTask DisposeAsync() => _client.DisposeAsync();
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AlarmEventMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AlarmEventMapper.cs
index a0112dcd..bdda3fc5 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AlarmEventMapper.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AlarmEventMapper.cs
@@ -1,5 +1,6 @@
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
@@ -10,6 +11,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
///
internal static class AlarmEventMapper
{
+ ///
+ /// Maps to the numeric OPC UA Part 9 priority (1-1000) carried in the
+ /// wire event's Severity property, per the
+ /// contract ("Mapped to Historian's numeric priority on the sink side"). The read side
+ /// () int.TryParses this same property, so it
+ /// must stay a numeric string — the enum's .ToString() name (e.g. "High") is NOT
+ /// parseable and used to silently collapse every read-back severity to the minimum (1). The
+ /// values mirror the existing native-alarm convention
+ /// (ZB.MOM.WW.OtOpcUa.Runtime.Drivers.NativeAlarmProjector.MapSeverity).
+ ///
+ private static string ToNumericSeverity(AlarmSeverity severity) => severity switch
+ {
+ AlarmSeverity.Low => "200",
+ AlarmSeverity.Medium => "500",
+ AlarmSeverity.High => "700",
+ AlarmSeverity.Critical => "900",
+ _ => "500",
+ };
+
/// Maps an alarm historian event to a gateway event.
/// The driver-agnostic alarm event.
/// The gateway wire event ready for SendEvent.
@@ -36,7 +56,7 @@ internal static class AlarmEventMapper
historianEvent.Properties["AlarmId"] = alarm.AlarmId;
historianEvent.Properties["AlarmName"] = alarm.AlarmName;
historianEvent.Properties["EventKind"] = alarm.EventKind;
- historianEvent.Properties["Severity"] = alarm.Severity.ToString();
+ historianEvent.Properties["Severity"] = ToNumericSeverity(alarm.Severity);
historianEvent.Properties["User"] = alarm.User;
historianEvent.Properties["Message"] = alarm.Message;
if (alarm.Comment is not null)
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs
index b03a9af9..798a0dca 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs
@@ -149,6 +149,27 @@ public sealed class GatewayTagProvisionerTests
Assert.Equal(1, result.Failed); // non-blocking: no throw
}
+ ///
+ /// Driver.Historian.Gateway-002: unlike its siblings (GatewayHistorianDataSource,
+ /// GatewayAlarmHistorianWriter), had no dispose path at
+ /// all — the gRPC channel GatewayHistorian.CreateProvisioner opens was never closed. The
+ /// provisioner is registered as a process-lifetime DI singleton
+ /// (AddHistorianProvisioning), and the built-in container disposes any singleton instance
+ /// it creates that implements / — regardless
+ /// of the registered service-interface type — so implementing it here is sufficient for a clean
+ /// shutdown with no other wiring change.
+ ///
+ [Fact]
+ public async Task DisposeAsync_disposes_underlying_client()
+ {
+ var fake = new FakeHistorianGatewayClient();
+ var p = Provisioner(fake);
+
+ await ((IAsyncDisposable)p).DisposeAsync();
+
+ Assert.Equal(1, fake.DisposeCallCount);
+ }
+
[Fact]
public async Task Ensured_count_reflects_successful_results()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AlarmEventMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AlarmEventMapperTests.cs
index f26784ec..e0b489b1 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AlarmEventMapperTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AlarmEventMapperTests.cs
@@ -23,12 +23,41 @@ public sealed class AlarmEventMapperTests
Assert.Equal("A1", e.Properties["AlarmId"]);
Assert.Equal("HiHi", e.Properties["AlarmName"]);
Assert.Equal("Activated", e.Properties["EventKind"]);
- Assert.Equal("High", e.Properties["Severity"]);
+ // Severity is the AlarmHistorianEvent.Severity XML doc contract: "Mapped to Historian's
+ // numeric priority on the sink side" — NOT the .NET enum name. EventMapper.ParseSeverity on
+ // the read side int.TryParse()s this same property, so a non-numeric string (the old bug:
+ // alarm.Severity.ToString() == "High") silently always read back as the minimum severity (1).
+ Assert.Equal("700", e.Properties["Severity"]);
Assert.Equal("operator1", e.Properties["User"]);
Assert.Equal("ack note", e.Properties["Comment"]);
Assert.Equal("Temp high", e.Properties["Message"]);
}
+ ///
+ /// Driver.Historian.Gateway-001 regression: the write side () and
+ /// the read side () must agree on the wire representation of severity —
+ /// a numeric OPC UA Part 9 priority string, matching the convention already used elsewhere in the
+ /// codebase for the same enum (NativeAlarmProjector.MapSeverity: Low=200/Medium=500/
+ /// High=700/Critical=900). Round-tripping through both mappers must reproduce the numeric severity,
+ /// not collapse to the minimum every time.
+ ///
+ [Theory]
+ [InlineData(AlarmSeverity.Low, (ushort)200)]
+ [InlineData(AlarmSeverity.Medium, (ushort)500)]
+ [InlineData(AlarmSeverity.High, (ushort)700)]
+ [InlineData(AlarmSeverity.Critical, (ushort)900)]
+ public void Severity_round_trips_through_EventMapper_as_numeric_priority(AlarmSeverity severity, ushort expected)
+ {
+ var a = new AlarmHistorianEvent("A1", "Area/Line/Pump1", "HiHi", "LimitAlarm",
+ severity, "Activated", "m", "operator1", null,
+ new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+
+ var wire = AlarmEventMapper.ToHistorianEvent(a);
+ var readBack = EventMapper.ToHistoricalEvent(wire);
+
+ Assert.Equal(expected, readBack.Severity);
+ }
+
[Fact]
public void Null_comment_is_omitted_not_null()
{