diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs new file mode 100644 index 0000000..a5c223d --- /dev/null +++ b/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs @@ -0,0 +1,98 @@ +using System.Buffers.Binary; + +namespace AVEVA.Historian.Client.Wcf; + +/// +/// Serializer for the M3 historical (non-streamed original / backfill) value write — the +/// HistoryService.AddStreamValues values buffer. Decoded byte-for-byte from a live +/// capture of the native 2023 R2 gRPC client driving +/// HistorianAccess.AddNonStreamedValue → SendValues against a sandbox tag (see +/// docs/plans/revision-write-path.md §"R3.1 CAPTURED"). The native non-streamed write does +/// NOT use the TransactionService AddNonStreamValues path the static decompile suggested — it +/// rides HistoryService.AddStreamValues with an "ON" storage-sample buffer, the analog sibling +/// of the AddS2 "OS" event buffer (). +/// +/// +/// values buffer (single analog sample): +/// 0x00 UInt16 0x4E4F // "ON" signature +/// 0x02 UInt16 sampleCount = 1 +/// 0x04 UInt32 10 + valueBlob.Length // total buffer length +/// 0x08 UInt16 valueBlob.Length +/// 0x0A valueBlob (46 bytes for one analog double): +/// +0x00 GUID tag GUID (the per-tag GUID = the value ParseTagInfoRecord reads as "typeId") +/// +0x10 Int64 sample FILETIME (UTC — the VTQ value timestamp) +/// +0x18 UInt16 OpcQuality = 192 (good) +/// +0x1A 4 bytes analog value descriptor (constant for the Float path: C0 10 01 00) +/// +0x1E Int64 received/version FILETIME (UTC) +/// +0x26 UInt32 0 // value high dword (zero for a 4-byte Float) +/// +0x2A Float32 value // the Float value sits in the high half of the 8-byte slot +/// +/// +/// Captured against a Float tag: the value occupies an 8-byte slot as u32(0) + float32(value) +/// (the 4-byte IEEE-754 float in the high dword, NOT an 8-byte double). Only the Float encoding is +/// captured; Double/Int/string tag types use a different descriptor + value width and are rejected +/// until captured. +/// +internal static class HistorianHistoricalWriteProtocol +{ + public const ushort BufferSignature = 0x4E4F; // "ON" + public const ushort OpcQualityGood = 192; + + // Captured constant for the analog double value path. The other observed quality/descriptor + // bytes in the AddS2 "OS" buffer are event-specific; here this 4-byte block sits between the + // quality and the received-time FILETIME and was constant across captured analog writes. + private static readonly byte[] AnalogDoubleDescriptor = [0xC0, 0x10, 0x01, 0x00]; + + private const int ValueBlobLength = 16 + 8 + 2 + 4 + 8 + 8; // 46 + + /// + /// Builds the AddStreamValues values buffer for a single analog historical sample. + /// is the per-tag GUID (from the gRPC tag-info read), and + /// is the storage/received timestamp the orchestrator stamps. + /// + public static byte[] SerializeAddStreamValuesBuffer( + Guid tagGuid, + DateTime sampleTimeUtc, + double value, + DateTime receivedTimeUtc, + ushort quality = OpcQualityGood) + { + byte[] valueBlob = SerializeValueBlob(tagGuid, sampleTimeUtc, value, receivedTimeUtc, quality); + + byte[] buffer = new byte[10 + valueBlob.Length]; + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), BufferSignature); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 1); // sampleCount + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), checked((uint)(10 + valueBlob.Length))); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(8, 2), checked((ushort)valueBlob.Length)); + valueBlob.CopyTo(buffer.AsSpan(10)); + return buffer; + } + + private static byte[] SerializeValueBlob( + Guid tagGuid, + DateTime sampleTimeUtc, + double value, + DateTime receivedTimeUtc, + ushort quality) + { + byte[] blob = new byte[ValueBlobLength]; + Span span = blob; + + tagGuid.ToByteArray().CopyTo(span[..16]); + BinaryPrimitives.WriteInt64LittleEndian(span.Slice(16, 8), ToFileTime(sampleTimeUtc)); + BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(24, 2), quality); + AnalogDoubleDescriptor.CopyTo(span.Slice(26, 4)); + BinaryPrimitives.WriteInt64LittleEndian(span.Slice(30, 8), ToFileTime(receivedTimeUtc)); + // The value sits in an 8-byte slot as u32(0) + float32(value): the captured Float tag stored + // a 4-byte IEEE-754 float in the high dword, not an 8-byte double. + BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(38, 4), 0); + BinaryPrimitives.WriteSingleLittleEndian(span.Slice(42, 4), (float)value); + return blob; + } + + private static long ToFileTime(DateTime value) + { + DateTime utc = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime(); + return utc.ToFileTimeUtc(); + } +} diff --git a/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs new file mode 100644 index 0000000..20ac4a4 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs @@ -0,0 +1,44 @@ +using AVEVA.Historian.Client.Wcf; + +namespace AVEVA.Historian.Client.Tests; + +public sealed class WcfHistoricalWriteProtocolTests +{ + // The exact 56-byte HistoryService.AddStreamValues "values" buffer captured live from the native + // 2023 R2 client writing a historical Float sample (124.5) to a sandbox tag — see + // docs/plans/revision-write-path.md §"R3.1 CAPTURED". + private const string CapturedBufferHex = + "4f4e0100380000002e00" + // "ON" + count(1) + totalLen(56) + payloadLen(46) + "d51d3107e9f8664793b155d3d1aef544" + // tag GUID 07311dd5-f8e9-4766-93b1-55d3d1aef544 + "70df38b1d101dd01" + // sample FILETIME + "c000" + // OpcQuality = 192 + "c0100100" + // analog double descriptor + "e64bcb74e201dd01" + // received/version FILETIME + "000000000000f942"; // double 124.5 + + [Fact] + public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer() + { + var tagGuid = new Guid("07311dd5-f8e9-4766-93b1-55d3d1aef544"); + DateTime sampleTime = DateTime.FromFileTimeUtc(0x01dd01d1b138df70); + DateTime receivedTime = DateTime.FromFileTimeUtc(0x01dd01e274cb4be6); + + byte[] actual = HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer( + tagGuid, sampleTime, value: 124.5, receivedTime, quality: 192); + + Assert.Equal(Convert.FromHexString(CapturedBufferHex), actual); + } + + [Fact] + public void SerializeAddStreamValuesBuffer_HeaderDeclaresLengths() + { + byte[] buffer = HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer( + Guid.NewGuid(), DateTime.UtcNow, value: 1.0, DateTime.UtcNow); + + Assert.Equal(56, buffer.Length); + Assert.Equal(0x4E4F, BitConverter.ToUInt16(buffer, 0)); // "ON" + Assert.Equal(1, BitConverter.ToUInt16(buffer, 2)); // sampleCount + Assert.Equal((uint)buffer.Length, BitConverter.ToUInt32(buffer, 4)); + Assert.Equal(buffer.Length - 10, BitConverter.ToUInt16(buffer, 8)); + } +}