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