diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistorianValue.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistorianValue.cs
new file mode 100644
index 00000000..dcf94ee0
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistorianValue.cs
@@ -0,0 +1,96 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+/// The value kinds a historian backend can store. Persisted as a byte (SQL ValueKind, outbox v2) — never renumber.
+public enum HistorianValueKind : byte { Empty = 0, Double = 1, Int64 = 2, Boolean = 3, String = 4, DateTime = 5 }
+
+/// A typed historized value. Number carries Double (and the double view of Int64/Boolean);
+/// Integer carries Int64, Boolean (1/0) and DateTime (UTC ticks); Text carries String.
+/// Which payload field(s) are meaningful.
+/// Double payload (also the double view of Int64/Boolean).
+/// Int64 payload, Boolean (1/0) or DateTime UTC ticks.
+/// String payload.
+public readonly record struct HistorianValue(HistorianValueKind Kind, double Number, long Integer, string? Text)
+{
+ /// No value (e.g. a Bad/Uncertain sample carrying null).
+ public static HistorianValue Empty => default;
+
+ /// Creates a Double value.
+ /// The value.
+ /// The typed value.
+ public static HistorianValue FromDouble(double v) => new(HistorianValueKind.Double, v, 0, null);
+
+ /// Creates an Int64 value.
+ /// The value.
+ /// The typed value.
+ public static HistorianValue FromInt64(long v) => new(HistorianValueKind.Int64, v, v, null);
+
+ /// Creates a Boolean value (Integer and Number are 1/0).
+ /// The value.
+ /// The typed value.
+ public static HistorianValue FromBoolean(bool v) => new(HistorianValueKind.Boolean, v ? 1d : 0d, v ? 1 : 0, null);
+
+ /// Creates a String value.
+ /// The value; must not be null.
+ /// The typed value.
+ public static HistorianValue FromString(string v) =>
+ new(HistorianValueKind.String, 0d, 0, v ?? throw new ArgumentNullException(nameof(v)));
+
+ /// Creates a DateTime value stored as UTC ticks (Local is converted, Unspecified is taken as UTC).
+ /// The value.
+ /// The typed value.
+ public static HistorianValue FromDateTime(DateTime v) =>
+ new(HistorianValueKind.DateTime, 0d, (v.Kind == DateTimeKind.Local ? v.ToUniversalTime() : DateTime.SpecifyKind(v, DateTimeKind.Utc)).Ticks, null);
+
+ /// Coerces a CLR value into a typed historian value.
+ /// The CLR value (null maps to ).
+ /// The typed value, or when not historizable.
+ /// false for arrays, references and any other non-historizable type.
+ public static bool TryFromObject(object? value, out HistorianValue result)
+ {
+ switch (value)
+ {
+ case null: result = Empty; return true;
+ case double d: result = FromDouble(d); return true;
+ case float f: result = FromDouble(f); return true;
+ case decimal m: result = FromDouble((double)m); return true;
+ case sbyte sb: result = FromInt64(sb); return true;
+ case byte b: result = FromInt64(b); return true;
+ case short s: result = FromInt64(s); return true;
+ case ushort us: result = FromInt64(us); return true;
+ case int i: result = FromInt64(i); return true;
+ case uint ui: result = FromInt64(ui); return true;
+ case long l: result = FromInt64(l); return true;
+ case ulong ul: result = ul <= long.MaxValue ? FromInt64((long)ul) : FromDouble(ul); return true;
+ case bool bo: result = FromBoolean(bo); return true;
+ case string str: result = FromString(str); return true;
+ case DateTime dt: result = FromDateTime(dt); return true;
+ default: result = Empty; return false; // arrays, references, structures => not historizable
+ }
+ }
+
+ /// The double view of the value.
+ /// The double view; 0 when unavailable.
+ /// true for Double, Int64 and Boolean kinds.
+ public bool TryGetDouble(out double value)
+ {
+ switch (Kind)
+ {
+ case HistorianValueKind.Double:
+ case HistorianValueKind.Int64:
+ case HistorianValueKind.Boolean: value = Number; return true;
+ default: value = 0d; return false;
+ }
+ }
+
+ /// Converts back to a CLR value (DateTime is returned as ).
+ /// The boxed CLR value, or null for .
+ public object? ToObject() => Kind switch
+ {
+ HistorianValueKind.Double => Number,
+ HistorianValueKind.Int64 => Integer,
+ HistorianValueKind.Boolean => Integer != 0,
+ HistorianValueKind.String => Text,
+ HistorianValueKind.DateTime => new DateTime(Integer, DateTimeKind.Utc),
+ _ => null,
+ };
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistorizationOutboxEntry.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistorizationOutboxEntry.cs
index f360cedd..27685781 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistorizationOutboxEntry.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistorizationOutboxEntry.cs
@@ -2,17 +2,16 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
///
/// One durable record buffered by the continuous-historization outbox before it is written to
-/// the historian. Carries the minimal payload the SQL analog live-value write path can ingest:
-/// a numeric value, a quality code, and a UTC timestamp keyed by tag.
+/// the historian: a typed value, an OPC UA status code, and a UTC timestamp keyed by tag.
///
/// Stable identifier used to ack (remove) the entry once written. Unique per append.
/// Fully-qualified historian tag name the value is recorded against.
-/// The coerced numeric sample value (the SQL write path is numeric-only).
-/// OPC-UA-derived quality code (e.g. 192 = Good) carried through to the historian.
+/// The typed sample value.
+/// OPC UA status code of the sample (0 = Good).
/// UTC source timestamp of the sample.
public sealed record HistorizationOutboxEntry(
Guid Id,
string Tag,
- double NumericValue,
- ushort Quality,
+ HistorianValue Value,
+ uint StatusCode,
DateTime TimestampUtc);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs
new file mode 100644
index 00000000..b1968c74
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs
@@ -0,0 +1,49 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+/// OPC UA Part 11 PerformUpdateType minus Remove.
+public enum HistoryUpdateMode
+{
+ /// Insert new samples; an existing sample at the timestamp is refused.
+ Insert,
+
+ /// Replace existing samples; a missing sample is refused.
+ Replace,
+
+ /// Insert or replace (upsert).
+ Update,
+}
+
+///
+/// Optional historian capability behind OPC UA HistoryUpdate. A backend that cannot modify history
+/// supplies no updater (the server then reports HistoryUpdate as unsupported).
+///
+public interface IHistorianDataUpdater
+{
+ /// Inserts, replaces or upserts samples for one tag.
+ /// Historian tag name.
+ /// Update mode.
+ /// Samples to apply.
+ /// Cancellation token.
+ ///
+ /// One status code per value, in order: Insert gives GoodEntryInserted or BadEntryExists;
+ /// Replace gives GoodEntryReplaced or BadNoEntryExists; Update gives
+ /// GoodEntryInserted or GoodEntryReplaced; a null timestamp gives BadInvalidTimestamp.
+ ///
+ Task> UpdateDataAsync(string tag, HistoryUpdateMode mode,
+ IReadOnlyList values, CancellationToken ct);
+
+ /// Deletes raw samples in a time range.
+ /// Historian tag name.
+ /// Range start (UTC).
+ /// Range end (UTC).
+ /// Cancellation token.
+ /// Good, or BadNoData when the range held nothing.
+ Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct);
+
+ /// Deletes the samples at exact timestamps.
+ /// Historian tag name.
+ /// Timestamps to delete (UTC).
+ /// Cancellation token.
+ /// One status code per time, in order: Good or BadNoEntryExists.
+ Task> DeleteAtTimeAsync(string tag, IReadOnlyList timesUtc, CancellationToken ct);
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianValueWriter.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianValueWriter.cs
index 97e11895..50dd3ed5 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianValueWriter.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianValueWriter.cs
@@ -2,16 +2,16 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
///
/// One numeric sample the continuous-historization recorder drains to the historian's SQL
-/// live-value write path. Carries the minimal payload that path can ingest: an optional UTC
-/// timestamp, the coerced numeric value, and an OPC-DA quality byte.
+/// live-value write path: an optional UTC timestamp, the typed value, and the OPC UA status code
+/// the sample was recorded with.
///
///
/// UTC source timestamp of the sample, or null to defer to the historian's server-stamped
/// current-time path (the SQL writer uses SYSDATETIME() when the timestamp is absent).
///
-/// The coerced numeric value (the SQL analog write path is numeric-only).
-/// OPC-DA-derived quality code carried to the historian (192 = Good).
-public readonly record struct HistorizationValue(DateTime? TimestampUtc, double Value, ushort Quality);
+/// The typed sample value.
+/// OPC UA status code of the sample (0 = Good).
+public readonly record struct HistorizationValue(DateTime? TimestampUtc, HistorianValue Value, uint StatusCode);
///
/// Seam over the historian's live-value write path used by the continuous-historization recorder.
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorizationOutbox.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorizationOutbox.cs
index 9b48653b..4ff4d43d 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorizationOutbox.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorizationOutbox.cs
@@ -13,6 +13,13 @@ public interface IHistorizationOutbox : IDisposable
/// Lifetime count of entries dropped because an append would have exceeded capacity.
long DroppedCount { get; }
+ ///
+ /// Lifetime count of persisted records that could not be decoded and were skipped (never
+ /// thrown, so a corrupt record cannot jam the drain). Defaults to 0 for implementations that
+ /// cannot encounter unreadable records.
+ ///
+ long UnreadableCount => 0;
+
///
/// Appends to the tail of the durable buffer.
///
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 bee0ca6d..82a5a747 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,6 +38,8 @@ 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,10 +72,10 @@ public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncD
{
var live = new HistorianLiveValue
{
- NumericValue = value.Value,
+ NumericValue = value.Value.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 = value.Quality,
+ Quality = LegacyGoodQuality,
};
if (value.TimestampUtc is { } timestampUtc)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/HistorizationOutboxEntrySerializer.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/HistorizationOutboxEntrySerializer.cs
index 48a4171b..60fce25f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/HistorizationOutboxEntrySerializer.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/HistorizationOutboxEntrySerializer.cs
@@ -17,6 +17,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
///
internal static class HistorizationOutboxEntrySerializer
{
+ private const ushort LegacyGoodQuality = 192;
+
/// Serializes to a fixed-layout little-endian byte array.
/// The outbox entry to serialize.
/// The fixed-layout little-endian byte array.
@@ -33,9 +35,9 @@ internal static class HistorizationOutboxEntrySerializer
Encoding.UTF8.GetBytes(entry.Tag, span.Slice(20, tagLen));
int p = 20 + tagLen;
- BinaryPrimitives.WriteDoubleLittleEndian(span.Slice(p, 8), entry.NumericValue);
+ BinaryPrimitives.WriteDoubleLittleEndian(span.Slice(p, 8), entry.Value.Number);
p += 8;
- BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(p, 2), entry.Quality);
+ BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(p, 2), LegacyGoodQuality);
p += 2;
BinaryPrimitives.WriteInt64LittleEndian(span.Slice(p, 8), entry.TimestampUtc.ToBinary());
@@ -54,10 +56,11 @@ internal static class HistorizationOutboxEntrySerializer
int p = 20 + tagLen;
double value = BinaryPrimitives.ReadDoubleLittleEndian(span.Slice(p, 8));
p += 8;
- ushort quality = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(p, 2));
+ // Legacy layout carries a DA quality that was always 192 (Good); the typed entry
+ // carries status 0 instead, so the field is skipped.
p += 2;
long timestamp = BinaryPrimitives.ReadInt64LittleEndian(span.Slice(p, 8));
- return new HistorizationOutboxEntry(id, tag, value, quality, DateTime.FromBinary(timestamp));
+ return new HistorizationOutboxEntry(id, tag, HistorianValue.FromDouble(value), 0u, DateTime.FromBinary(timestamp));
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs
index ecd77f80..0626b2b4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs
@@ -524,7 +524,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
+ "resuming value capture.");
}
- if (!TryCoerceToDouble(msg.Value, out double numeric))
+ if (!(HistorianValue.TryFromObject(msg.Value, out HistorianValue historianValue)
+ && historianValue.TryGetDouble(out double numeric)))
{
// The SQL analog live-value path is numeric-only — drop non-coercible values (string/null/
// reference) and meter the drop. No value content is logged.
@@ -548,8 +549,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
entries.Add(new HistorizationOutboxEntry(
Guid.NewGuid(),
historianName,
- numeric,
- GoodQuality,
+ HistorianValue.FromDouble(numeric),
+ 0u, // Good
timestampUtc));
}
}
@@ -689,7 +690,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
.Select(g => (
Key: g.Key,
Values: (IReadOnlyList)g
- .Select(e => new HistorizationValue(e.TimestampUtc, e.NumericValue, e.Quality))
+ .Select(e => new HistorizationValue(e.TimestampUtc, e.Value, e.StatusCode))
.ToList()))
.ToList();
@@ -879,24 +880,4 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
}
private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
-
- private static bool TryCoerceToDouble(object? value, out double result)
- {
- switch (value)
- {
- case double d: result = d; return true;
- case float f: result = f; return true;
- case int i: result = i; return true;
- case long l: result = l; return true;
- case short s: result = s; return true;
- case ushort us: result = us; return true;
- case uint ui: result = ui; return true;
- case ulong ul: result = ul; return true;
- case byte b: result = b; return true;
- case sbyte sb: result = sb; return true;
- case decimal dec: result = (double)dec; return true;
- case bool bo: result = bo ? 1d : 0d; return true; // Boolean historizes as Int1 (1/0).
- default: result = 0d; return false; // string / null / DateTime / reference -> dropped.
- }
- }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/IHistorianBackendProvider.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/IHistorianBackendProvider.cs
new file mode 100644
index 00000000..3762ff77
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/IHistorianBackendProvider.cs
@@ -0,0 +1,42 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
+
+///
+/// Builds the historian seams (read, live-value write, alarm write, provisioning, optional update)
+/// for one historian backend. The Host selects a provider by .
+///
+public interface IHistorianBackendProvider
+{
+ /// Backend name, e.g. "Gateway" or "SqlServer".
+ string Name { get; }
+
+ /// The value kinds this backend can store; the recorder drops other kinds at capture.
+ IReadOnlySet SupportedKinds { get; }
+
+ /// Creates the HistoryRead data source.
+ /// Service provider.
+ /// The data source.
+ IHistorianDataSource CreateDataSource(IServiceProvider sp);
+
+ /// Creates the continuous-historization live-value writer.
+ /// Service provider.
+ /// The value writer.
+ IHistorianValueWriter CreateValueWriter(IServiceProvider sp);
+
+ /// Creates the alarm-history writer.
+ /// Service provider.
+ /// The alarm writer.
+ IAlarmHistorianWriter CreateAlarmWriter(IServiceProvider sp);
+
+ /// Creates the tag auto-provisioning hook.
+ /// Service provider.
+ /// The provisioning hook.
+ IHistorianProvisioning CreateProvisioning(IServiceProvider sp);
+
+ /// Creates the HistoryUpdate capability.
+ /// Service provider.
+ /// The updater, or null when the backend does not support HistoryUpdate.
+ IHistorianDataUpdater? CreateUpdater(IServiceProvider sp);
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianValueTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianValueTests.cs
new file mode 100644
index 00000000..3c1fe44b
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianValueTests.cs
@@ -0,0 +1,114 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
+
+public sealed class HistorianValueTests
+{
+ [Theory]
+ [InlineData(1.5d, HistorianValueKind.Double)]
+ [InlineData(1.5f, HistorianValueKind.Double)]
+ [InlineData(7, HistorianValueKind.Int64)]
+ [InlineData((short)7, HistorianValueKind.Int64)]
+ [InlineData((byte)7, HistorianValueKind.Int64)]
+ [InlineData(7u, HistorianValueKind.Int64)]
+ [InlineData(7L, HistorianValueKind.Int64)]
+ [InlineData(true, HistorianValueKind.Boolean)]
+ [InlineData("x", HistorianValueKind.String)]
+ public void Maps_clr_values_to_kinds(object input, HistorianValueKind expected)
+ {
+ HistorianValue.TryFromObject(input, out var v).ShouldBeTrue();
+ v.Kind.ShouldBe(expected);
+ }
+
+ [Fact]
+ public void Decimal_maps_to_double()
+ {
+ HistorianValue.TryFromObject(2.5m, out var v).ShouldBeTrue();
+ v.Kind.ShouldBe(HistorianValueKind.Double);
+ v.Number.ShouldBe(2.5);
+ }
+
+ [Fact]
+ public void Ulong_above_long_max_maps_to_double()
+ {
+ HistorianValue.TryFromObject(ulong.MaxValue, out var v).ShouldBeTrue();
+ v.Kind.ShouldBe(HistorianValueKind.Double);
+ v.Number.ShouldBe((double)ulong.MaxValue);
+ }
+
+ [Fact]
+ public void Ulong_within_long_range_maps_to_int64()
+ {
+ HistorianValue.TryFromObject((ulong)5, out var v).ShouldBeTrue();
+ v.Kind.ShouldBe(HistorianValueKind.Int64);
+ v.Integer.ShouldBe(5);
+ HistorianValue.TryFromObject((ulong)long.MaxValue, out var edge).ShouldBeTrue();
+ edge.Kind.ShouldBe(HistorianValueKind.Int64);
+ }
+
+ [Fact]
+ public void Null_maps_to_empty()
+ {
+ HistorianValue.TryFromObject(null, out var v).ShouldBeTrue();
+ v.Kind.ShouldBe(HistorianValueKind.Empty);
+ v.ToObject().ShouldBeNull();
+ }
+
+ [Fact]
+ public void Arrays_and_unknown_objects_are_not_historizable()
+ {
+ HistorianValue.TryFromObject(new[] { 1, 2 }, out _).ShouldBeFalse();
+ HistorianValue.TryFromObject(new object(), out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Local_datetime_is_stored_as_utc_ticks()
+ {
+ var local = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Local);
+ HistorianValue.TryFromObject(local, out var v).ShouldBeTrue();
+ v.Kind.ShouldBe(HistorianValueKind.DateTime);
+ v.Integer.ShouldBe(local.ToUniversalTime().Ticks);
+ var back = (DateTime)v.ToObject()!;
+ back.Kind.ShouldBe(DateTimeKind.Utc);
+ back.Ticks.ShouldBe(local.ToUniversalTime().Ticks);
+ }
+
+ [Fact]
+ public void Boolean_carries_integer_and_number()
+ {
+ var v = HistorianValue.FromBoolean(true);
+ v.Integer.ShouldBe(1);
+ v.Number.ShouldBe(1d);
+ v.TryGetDouble(out var d).ShouldBeTrue();
+ d.ShouldBe(1d);
+ HistorianValue.FromBoolean(false).Integer.ShouldBe(0);
+ }
+
+ [Fact]
+ public void TryGetDouble_is_true_only_for_double_int64_boolean()
+ {
+ HistorianValue.FromDouble(1.5).TryGetDouble(out _).ShouldBeTrue();
+ HistorianValue.FromInt64(3).TryGetDouble(out var i).ShouldBeTrue();
+ i.ShouldBe(3d);
+ HistorianValue.FromBoolean(true).TryGetDouble(out _).ShouldBeTrue();
+ HistorianValue.FromString("a").TryGetDouble(out _).ShouldBeFalse();
+ HistorianValue.FromDateTime(DateTime.UtcNow).TryGetDouble(out _).ShouldBeFalse();
+ HistorianValue.Empty.TryGetDouble(out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ToObject_round_trips_each_kind()
+ {
+ HistorianValue.FromDouble(1.5).ToObject().ShouldBe(1.5);
+ HistorianValue.FromInt64(9).ToObject().ShouldBe(9L);
+ HistorianValue.FromBoolean(true).ToObject().ShouldBe(true);
+ HistorianValue.FromBoolean(false).ToObject().ShouldBe(false);
+ HistorianValue.FromString("a").ToObject().ShouldBe("a");
+ var utc = new DateTime(2026, 5, 6, 7, 8, 9, DateTimeKind.Utc);
+ var dt = (DateTime)HistorianValue.FromDateTime(utc).ToObject()!;
+ dt.ShouldBe(utc);
+ dt.Kind.ShouldBe(DateTimeKind.Utc);
+ HistorianValue.Empty.ToObject().ShouldBeNull();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs
index 009f8dfd..f8b1cd2a 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs
@@ -87,7 +87,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
// A value that is exactly representable in float32 (integer < 2^24) so the analog
// store/read round-trip is not muddied by single-precision rounding. The millisecond
// component keeps consecutive runs from colliding on the same value.
- const ushort goodQuality = 192; // OPC-DA "Good" floor.
+ const uint goodStatus = 0u; // OPC UA Good.
var writeUtc = DateTime.UtcNow;
double written = 1_000_000 + writeUtc.Millisecond;
@@ -112,7 +112,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
// WriteLiveValues through the real recorder writer (SQL live-write path).
var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger.Instance);
var acked = await valueWriter.WriteLiveValuesAsync(
- tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct);
+ tag, new[] { new HistorizationValue(writeUtc, HistorianValue.FromDouble(written), goodStatus) }, ct);
acked.ShouldBeTrue(
"the live write must be acked — needs the gateway running RuntimeDb:Enabled=true and the tag EnsureTags-provisioned.");
@@ -286,12 +286,12 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
provision.Failed.ShouldBe(0);
// WriteLiveValues a Boolean-as-1.0 and read it back (SQL live path can lag a flush cadence → poll).
- const ushort goodQuality = 192; // OPC-DA "Good" floor.
+ const uint goodStatus = 0u; // OPC UA Good.
var writeUtc = DateTime.UtcNow;
const double written = 1.0;
var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger.Instance);
var acked = await valueWriter.WriteLiveValuesAsync(
- tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct);
+ tag, new[] { new HistorizationValue(writeUtc, HistorianValue.FromDouble(written), goodStatus) }, ct);
acked.ShouldBeTrue("the Boolean live write must be acked (gateway RuntimeDb:Enabled=true + EnsureTags-provisioned tag).");
await using var dataSource = _fx.CreateDataSource();
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/FasterLogHistorizationOutboxTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/FasterLogHistorizationOutboxTests.cs
index 95e39ba6..03c5a9c5 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/FasterLogHistorizationOutboxTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/FasterLogHistorizationOutboxTests.cs
@@ -25,7 +25,7 @@ public sealed class FasterLogHistorizationOutboxTests : IDisposable
}
private static HistorizationOutboxEntry E(string tag, double v) =>
- new(Guid.NewGuid(), tag, v, 192, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+ new(Guid.NewGuid(), tag, HistorianValue.FromDouble(v), 0u, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
[Fact]
public async Task Append_then_peek_returns_fifo()
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 3367cfa2..92271419 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
@@ -23,7 +23,7 @@ public sealed class GatewayHistorianValueWriterTests
public async Task Success_ack_returns_true()
{
var fake = new FakeHistorianGatewayClient { WriteLiveValuesResult = new WriteAck { Success = true } };
- var values = new[] { new HistorizationValue(DateTime.UtcNow, 42.0, 192) };
+ var values = new[] { new HistorizationValue(DateTime.UtcNow, HistorianValue.FromDouble(42.0), 0u) };
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -40,7 +40,7 @@ public sealed class GatewayHistorianValueWriterTests
{
WriteLiveValuesResult = new WriteAck { Success = false, Queued = true },
};
- var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) };
+ var values = new[] { new HistorizationValue(DateTime.UtcNow, HistorianValue.FromDouble(1.0), 0u) };
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -54,7 +54,7 @@ public sealed class GatewayHistorianValueWriterTests
{
WriteLiveValuesResult = new WriteAck { Success = false, Queued = false },
};
- var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) };
+ var values = new[] { new HistorizationValue(DateTime.UtcNow, HistorianValue.FromDouble(1.0), 0u) };
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -66,7 +66,7 @@ public sealed class GatewayHistorianValueWriterTests
{
// NEVER throw out of the writer — the recorder's drain loop expects a plain bool.
var fake = new FakeHistorianGatewayClient { WriteLiveValuesThrows = new InvalidOperationException("boom") };
- var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) };
+ var values = new[] { new HistorizationValue(DateTime.UtcNow, HistorianValue.FromDouble(1.0), 0u) };
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -92,8 +92,8 @@ public sealed class GatewayHistorianValueWriterTests
var stamped = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var values = new[]
{
- new HistorizationValue(stamped, 1.0, 192),
- new HistorizationValue(null, 2.0, 192),
+ new HistorizationValue(stamped, HistorianValue.FromDouble(1.0), 0u),
+ new HistorizationValue(null, HistorianValue.FromDouble(2.0), 0u),
};
await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -108,7 +108,7 @@ public sealed class GatewayHistorianValueWriterTests
public async Task Quality_and_numeric_value_are_forwarded_unchanged()
{
var fake = new FakeHistorianGatewayClient();
- var values = new[] { new HistorizationValue(null, 123.5, 192) };
+ var values = new[] { new HistorizationValue(null, HistorianValue.FromDouble(123.5), 0u) };
await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -121,7 +121,7 @@ public sealed class GatewayHistorianValueWriterTests
public async Task Null_tag_throws()
{
var fake = new FakeHistorianGatewayClient();
- var values = new[] { new HistorizationValue(null, 1.0, 192) };
+ var values = new[] { new HistorizationValue(null, HistorianValue.FromDouble(1.0), 0u) };
await Assert.ThrowsAsync(() =>
Writer(fake).WriteLiveValuesAsync(null!, values, TestContext.Current.CancellationToken));
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/HistorizationOutboxEntrySerializerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/HistorizationOutboxEntrySerializerTests.cs
index 0efdc6ae..ad513f57 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/HistorizationOutboxEntrySerializerTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/HistorizationOutboxEntrySerializerTests.cs
@@ -18,7 +18,7 @@ public sealed class HistorizationOutboxEntrySerializerTests
public void Round_trips_all_fields()
{
var entry = new HistorizationOutboxEntry(
- Guid.NewGuid(), "Pump1.Temp", 123.456, 192,
+ Guid.NewGuid(), "Pump1.Temp", HistorianValue.FromDouble(123.456), 0u,
new DateTime(2026, 1, 1, 12, 34, 56, DateTimeKind.Utc));
var bytes = HistorizationOutboxEntrySerializer.Serialize(entry);
@@ -26,8 +26,8 @@ public sealed class HistorizationOutboxEntrySerializerTests
Assert.Equal(entry.Id, round.Id);
Assert.Equal(entry.Tag, round.Tag);
- Assert.Equal(entry.NumericValue, round.NumericValue);
- Assert.Equal(entry.Quality, round.Quality);
+ Assert.Equal(entry.Value, round.Value);
+ Assert.Equal(entry.StatusCode, round.StatusCode);
Assert.Equal(entry.TimestampUtc, round.TimestampUtc);
Assert.Equal(entry.TimestampUtc.Kind, round.TimestampUtc.Kind); // ToBinary/FromBinary round-trips Kind
}
@@ -39,19 +39,19 @@ public sealed class HistorizationOutboxEntrySerializerTests
// byte count diverge (e.g. Ω is 2 bytes, 日 is 3) is the one case that would desync the layout
// if GetByteCount/GetBytes ever disagreed with what Deserialize slices out.
var entry = new HistorizationOutboxEntry(
- Guid.NewGuid(), "Pumpe_Ω_日本語_Temp", -1.5, 0, DateTime.UtcNow);
+ Guid.NewGuid(), "Pumpe_Ω_日本語_Temp", HistorianValue.FromDouble(-1.5), 0u, DateTime.UtcNow);
var round = HistorizationOutboxEntrySerializer.Deserialize(
HistorizationOutboxEntrySerializer.Serialize(entry));
Assert.Equal(entry.Tag, round.Tag);
- Assert.Equal(entry.NumericValue, round.NumericValue);
+ Assert.Equal(entry.Value, round.Value);
}
[Fact]
public void Round_trips_an_empty_tag()
{
- var entry = new HistorizationOutboxEntry(Guid.NewGuid(), string.Empty, 0.0, 0, DateTime.UtcNow);
+ var entry = new HistorizationOutboxEntry(Guid.NewGuid(), string.Empty, HistorianValue.FromDouble(0.0), 0u, DateTime.UtcNow);
var round = HistorizationOutboxEntrySerializer.Deserialize(
HistorizationOutboxEntrySerializer.Serialize(entry));
@@ -60,14 +60,13 @@ public sealed class HistorizationOutboxEntrySerializerTests
}
[Fact]
- public void Round_trips_boundary_quality_and_negative_value()
+ public void Round_trips_boundary_negative_value()
{
- var entry = new HistorizationOutboxEntry(Guid.NewGuid(), "T", double.MinValue, ushort.MaxValue, DateTime.UtcNow);
+ var entry = new HistorizationOutboxEntry(Guid.NewGuid(), "T", HistorianValue.FromDouble(double.MinValue), 0u, DateTime.UtcNow);
var round = HistorizationOutboxEntrySerializer.Deserialize(
HistorizationOutboxEntrySerializer.Serialize(entry));
- Assert.Equal(ushort.MaxValue, round.Quality);
- Assert.Equal(double.MinValue, round.NumericValue);
+ Assert.Equal(double.MinValue, round.Value.Number);
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs
index 1d817c46..f5c0479d 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs
@@ -1467,7 +1467,7 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
_calls++;
foreach (HistorizationValue v in values)
{
- _written.Add(new WrittenValue(tag, v.Value, v.Quality, v.TimestampUtc));
+ _written.Add(new WrittenValue(tag, v.Value.Number, (ushort)v.StatusCode, v.TimestampUtc));
}
// A throwing tag overrides every ack rule. Thrown synchronously from inside the awaited