f1e23a3a02
Roadmap Milestone 2 (event sending). Capture disproved the assumption that event delivery uses the non-WCF storage-engine pipe (which would block it like revision writes): a native AddStreamedValue(HistorianEvent) leaves over WCF as AddS2 (IHistoryServiceContract2.AddStreamValues2). CM_EVENT is a built-in registered tag, so the 129 TagNotFoundInCache gate that blocks AddS2 for user tags does not apply. - R2.1: NativeTraceHarness "event-send" scenario + Capture-EventSend.ps1; two captures diffed to separate constant framing from value-dependent fields. - R2.2: HistorianEventWriteProtocol serializes the AddS2 pBuf (storage sample buffer wrapping the event VTQ) — golden-byte tested. Decoded "OS" sig + length fields + CM_EVENT tag id + EventTime/ReceivedTime FILETIMEs + Opc 192 + 0x118D descriptor + event Id + Namespace + EventType + version 5 + typed property bag. - R2.3/R2.4: HistorianWcfEventOrchestrator.SendEventAsync (Open2 event-mode 0x501 -> reuse CM_EVENT RTag2/EnsT2 -> AddStreamValues2) + HistorianClient.SendEventAsync. - R2.5: gated live test; server accepts the AddS2 (success, empty error buffer). Server requires delivered byte[].Length == declared packet length (uint32@0x04); the native relies on the MDAS encoder adding a pad byte, so the SDK emits an explicit trailing 0x00 (else AddS2 rejects with "CValuStream buffer size vs packet length mismatch"). Original events only (RevisionVersion=0) with string properties; other property types + revision/update/delete throw ProtocolEvidenceMissingException. Caveat (documented): accepted events are not persisted on the local dev box; the native client behaves identically (event ingestion pipeline inactive) — not an SDK gap. 212 unit tests pass; 16/16 event tests pass live. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
196 lines
9.0 KiB
C#
196 lines
9.0 KiB
C#
using System.Buffers.Binary;
|
||
using System.Text;
|
||
using AVEVA.Historian.Client.Models;
|
||
|
||
namespace AVEVA.Historian.Client.Wcf;
|
||
|
||
/// <remarks>
|
||
/// Serializer for the <c>AddS2</c> (<c>IHistoryServiceContract2.AddStreamValues2</c>)
|
||
/// <c>pBuf</c> that carries a single <see cref="HistorianEvent"/> to the built-in
|
||
/// <c>CM_EVENT</c> tag. This is the inverse of <see cref="HistorianEventRowProtocol"/> and the
|
||
/// managed equivalent of native <c>HistorianEvent.PackToVtq</c> →
|
||
/// <c>CCommonArchestraEventValue::PackToVtq</c>.
|
||
///
|
||
/// Wire shape decoded byte-for-byte from two captured native event sends
|
||
/// (instrument-wcf-writemessage; <c>User.Write</c> and <c>Alarm.Set</c>, diffed to separate
|
||
/// constant framing from value-dependent fields):
|
||
///
|
||
/// <code>
|
||
/// pBuf (storage sample buffer):
|
||
/// 0x00 UInt16 0x534F // "OS" signature
|
||
/// 0x02 UInt16 sampleCount = 1
|
||
/// 0x04 UInt32 valueBlob.Length + 11
|
||
/// 0x08 UInt16 valueBlob.Length + 1
|
||
/// 0x0A valueBlob:
|
||
/// +0x00 GUID CM_EVENT tag id (353b8145-5df0-4d46-a253-871aef49b321)
|
||
/// +0x10 Int64 EventTime FILETIME (UTC, ms-truncated — the VTQ timestamp)
|
||
/// +0x18 UInt16 OpcQuality = 192
|
||
/// +0x1A UInt16 192
|
||
/// +0x1C UInt16 0x118D // opaque CCommonArchestraEventValue descriptor (constant)
|
||
/// +0x1E GUID event Id
|
||
/// +0x2E Int64 ReceivedTime FILETIME (UTC, ms — unique/monotonic on the native path)
|
||
/// +0x36 compact-ASCII string // Namespace
|
||
/// +.... compact-ASCII string // EventType (e.g. "Alarm.Set")
|
||
/// +.... UInt16 eventStructVersion = 5
|
||
/// +.... UInt16 propertyCount
|
||
/// +.... propertyCount × Property {
|
||
/// compact-ASCII string // property name
|
||
/// UInt8 typeMarker, UInt8 length, UInt8 status(=0), length×value bytes
|
||
/// 0x43 → UTF-16 string: UInt16 charCount + charCount×UInt16
|
||
/// }
|
||
/// </code>
|
||
///
|
||
/// Compact ASCII string: <c>0x09 LEN 0x00 LEN×ASCII bytes</c> (same as
|
||
/// <see cref="HistorianEventRowProtocol"/> and CTagMetadata strings).
|
||
///
|
||
/// Only string-valued properties are emitted here — the only property value type observed on
|
||
/// the event-send wire. Non-string property values throw
|
||
/// <see cref="ProtocolEvidenceMissingException"/> until captured (the read parser decodes more
|
||
/// types, but the write framing for them is unverified). Likewise revision sends
|
||
/// (Update/Delete/RevisionVersion ≠ 0) are not yet captured and are rejected by the caller.
|
||
/// </remarks>
|
||
internal static class HistorianEventWriteProtocol
|
||
{
|
||
public const ushort BufferSignature = 0x534F; // "OS"
|
||
public const ushort EventStructVersion = 5;
|
||
public const ushort OpcQualityGood = 192;
|
||
private const ushort EventValueDescriptor = 0x118D;
|
||
|
||
/// <summary>Built-in CM_EVENT tag id every streamed event targets.</summary>
|
||
public static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321");
|
||
|
||
private const byte CompactStringMarker = 0x09;
|
||
private const byte ValueTypeUtf16String = 0x43;
|
||
|
||
/// <summary>
|
||
/// Builds the <c>AddS2</c> <c>pBuf</c> for a single event. <paramref name="receivedTimeUtc"/>
|
||
/// is the storage/received timestamp the native path generates uniquely; the orchestrator
|
||
/// passes a current time.
|
||
/// </summary>
|
||
public static byte[] SerializeAddStreamValuesBuffer(HistorianEvent evt, DateTime receivedTimeUtc)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(evt);
|
||
byte[] valueBlob = SerializeEventValueBlob(evt, receivedTimeUtc);
|
||
|
||
// The server's CValuStream check requires the delivered byte[] length to equal the
|
||
// declared packet length (UInt32 @0x04 = valueBlob.Length + 11). The native client's
|
||
// content is only valueBlob.Length + 10 bytes and relies on the MDAS encoder appending
|
||
// one trailing byte on the wire (its value is non-deterministic padding — 0x9F/0x00 in
|
||
// captures — and is beyond the parsed event content). Over the SDK's WCF byte[] path no
|
||
// such byte is added, so we emit it explicitly to satisfy the length check.
|
||
byte[] buffer = new byte[8 + 2 + valueBlob.Length + 1];
|
||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), BufferSignature);
|
||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 1); // sampleCount
|
||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), checked((uint)(valueBlob.Length + 11)));
|
||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(8, 2), checked((ushort)(valueBlob.Length + 1)));
|
||
valueBlob.CopyTo(buffer.AsSpan(10));
|
||
// buffer[^1] left as 0x00 trailing pad.
|
||
return buffer;
|
||
}
|
||
|
||
private static byte[] SerializeEventValueBlob(HistorianEvent evt, DateTime receivedTimeUtc)
|
||
{
|
||
using MemoryStream stream = new();
|
||
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||
|
||
writer.Write(CmEventTagId.ToByteArray());
|
||
// EventTime is the VTQ value timestamp; the native path floors it to whole
|
||
// milliseconds (the event store's resolution).
|
||
writer.Write(ToMillisecondFileTime(evt.EventTimeUtc));
|
||
writer.Write(OpcQualityGood);
|
||
writer.Write(OpcQualityGood);
|
||
writer.Write(EventValueDescriptor);
|
||
writer.Write(evt.Id.ToByteArray());
|
||
// ReceivedTime keeps full 100ns FILETIME precision (a unique/monotonic time on the
|
||
// native path), so it is NOT truncated to milliseconds.
|
||
writer.Write(ToFileTime(receivedTimeUtc));
|
||
WriteCompactAsciiString(writer, evt.Namespace ?? string.Empty);
|
||
WriteCompactAsciiString(writer, evt.Type ?? string.Empty);
|
||
writer.Write(EventStructVersion);
|
||
|
||
IReadOnlyList<KeyValuePair<string, object?>> properties = OrderedProperties(evt.Properties);
|
||
writer.Write(checked((ushort)properties.Count));
|
||
foreach (KeyValuePair<string, object?> property in properties)
|
||
{
|
||
WriteCompactAsciiString(writer, property.Key);
|
||
WritePropertyValue(writer, property.Key, property.Value);
|
||
}
|
||
|
||
writer.Flush();
|
||
return stream.ToArray();
|
||
}
|
||
|
||
private static IReadOnlyList<KeyValuePair<string, object?>> OrderedProperties(IReadOnlyDictionary<string, object?>? properties)
|
||
{
|
||
if (properties is null || properties.Count == 0)
|
||
{
|
||
return [];
|
||
}
|
||
|
||
// The native client stores properties in a SortedList keyed by name; mirror that
|
||
// deterministic ordering so the serialized bytes are stable.
|
||
List<KeyValuePair<string, object?>> ordered = [.. properties];
|
||
ordered.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||
return ordered;
|
||
}
|
||
|
||
private static void WritePropertyValue(BinaryWriter writer, string name, object? value)
|
||
{
|
||
if (value is not string s)
|
||
{
|
||
throw new ProtocolEvidenceMissingException(
|
||
$"Event property '{name}' has value type '{value?.GetType().Name ?? "null"}'. Only string-valued " +
|
||
"event properties have a captured write encoding; capture the typed-value framing before sending others.");
|
||
}
|
||
|
||
byte[] chars = Encoding.Unicode.GetBytes(s);
|
||
int valueByteLength = 2 + chars.Length; // UInt16 charCount + UTF-16 chars
|
||
if (valueByteLength > byte.MaxValue)
|
||
{
|
||
throw new ProtocolEvidenceMissingException(
|
||
$"Event property '{name}' string value is too long ({s.Length} chars); only a single-byte value " +
|
||
"length has been captured on the event-send wire.");
|
||
}
|
||
|
||
writer.Write(ValueTypeUtf16String);
|
||
writer.Write((byte)valueByteLength);
|
||
writer.Write((byte)0); // status
|
||
writer.Write(checked((ushort)s.Length));
|
||
writer.Write(chars);
|
||
}
|
||
|
||
/// <summary>Compact ASCII string: <c>0x09 LEN 0x00 LEN×ASCII bytes</c>.</summary>
|
||
private static void WriteCompactAsciiString(BinaryWriter writer, string value)
|
||
{
|
||
byte[] ascii = Encoding.ASCII.GetBytes(value);
|
||
if (ascii.Length > byte.MaxValue)
|
||
{
|
||
throw new ProtocolEvidenceMissingException(
|
||
$"String '{value}' exceeds the single-byte length captured for event compact strings.");
|
||
}
|
||
|
||
writer.Write(CompactStringMarker);
|
||
writer.Write((byte)ascii.Length);
|
||
writer.Write((byte)0);
|
||
writer.Write(ascii);
|
||
}
|
||
|
||
/// <summary>
|
||
/// FILETIME truncated to whole milliseconds — the native event path stores millisecond
|
||
/// resolution and floors sub-millisecond ticks (observed in the capture).
|
||
/// </summary>
|
||
private static long ToMillisecondFileTime(DateTime value)
|
||
{
|
||
DateTime utc = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime();
|
||
long ms = utc.Ticks / TimeSpan.TicksPerMillisecond;
|
||
DateTime truncated = new(ms * TimeSpan.TicksPerMillisecond, DateTimeKind.Utc);
|
||
return truncated.ToFileTimeUtc();
|
||
}
|
||
|
||
private static long ToFileTime(DateTime value)
|
||
{
|
||
DateTime utc = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime();
|
||
return utc.ToFileTimeUtc();
|
||
}
|
||
}
|