Files
histsdk/src/AVEVA.Historian.Client/Wcf/HistorianEventWriteProtocol.cs
T
Joseph Doherty f1e23a3a02 M2: implement SendEventAsync — event-send rides WCF AddS2, not the storage pipe
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
2026-06-20 18:00:52 -04:00

196 lines
9.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}