M3 R3.2: HistorianHistoricalWriteProtocol — the "ON" AddStreamValues serializer (golden-validated)
Managed serializer for the historical-write "ON" buffer, byte-for-byte matching the live capture (WcfHistoricalWriteProtocolTests golden-tests the exact 56-byte native buffer). Layout: "ON"(0x4E4F) + count + lengths + 16B tag GUID + sample FILETIME + u16 quality(192) + 4B descriptor + received FILETIME + value. Value encoding (Float, captured): an 8-byte slot = u32(0) + float32(value) — the 4-byte float in the high dword, NOT a double. The 16B tag GUID is the per-tag GUID the SDK already parses as ParseTagInfoRecord's "typeId" (confirmed: it appears at offset 8 of GetTagInfosFromName's response = where typeId is read, and in EnsureTags' response + the "ON" buffer). Only the Float encoding is captured; other types rejected until captured. Next: gRPC orchestrator (write-enabled session -> EnsureTags -> resolve tag GUID -> AddStreamValues) + public AddHistoricalValuesAsync + live write/read-back. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Wcf;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Serializer for the M3 historical (non-streamed original / backfill) value write — the
|
||||||
|
/// <c>HistoryService.AddStreamValues</c> <c>values</c> buffer. Decoded byte-for-byte from a live
|
||||||
|
/// capture of the native 2023 R2 gRPC client driving
|
||||||
|
/// <c>HistorianAccess.AddNonStreamedValue → SendValues</c> against a sandbox tag (see
|
||||||
|
/// <c>docs/plans/revision-write-path.md</c> §"R3.1 CAPTURED"). The native non-streamed write does
|
||||||
|
/// NOT use the TransactionService <c>AddNonStreamValues</c> path the static decompile suggested — it
|
||||||
|
/// rides <c>HistoryService.AddStreamValues</c> with an "ON" storage-sample buffer, the analog sibling
|
||||||
|
/// of the AddS2 "OS" event buffer (<see cref="HistorianEventWriteProtocol"/>).
|
||||||
|
///
|
||||||
|
/// <code>
|
||||||
|
/// 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
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// Captured against a Float tag: the value occupies an 8-byte slot as <c>u32(0) + float32(value)</c>
|
||||||
|
/// (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.
|
||||||
|
/// </remarks>
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <c>AddStreamValues</c> <c>values</c> buffer for a single analog historical sample.
|
||||||
|
/// <paramref name="tagGuid"/> is the per-tag GUID (from the gRPC tag-info read), and
|
||||||
|
/// <paramref name="receivedTimeUtc"/> is the storage/received timestamp the orchestrator stamps.
|
||||||
|
/// </summary>
|
||||||
|
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<byte> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user