using System.Buffers.Binary; using System.Text; using AVEVA.Historian.Client.Models; namespace AVEVA.Historian.Client.Wcf; /// /// Serializer for the AddS2 (IHistoryServiceContract2.AddStreamValues2) /// pBuf that carries a single to the built-in /// CM_EVENT tag. This is the inverse of and the /// managed equivalent of native HistorianEvent.PackToVtq → /// CCommonArchestraEventValue::PackToVtq. /// /// Wire shape decoded byte-for-byte from two captured native event sends /// (instrument-wcf-writemessage; User.Write and Alarm.Set, diffed to separate /// constant framing from value-dependent fields): /// /// /// 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 /// } /// /// /// Compact ASCII string: 0x09 LEN 0x00 LEN×ASCII bytes (same as /// 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 /// 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. /// 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; /// Built-in CM_EVENT tag id every streamed event targets. public static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321"); private const byte CompactStringMarker = 0x09; private const byte ValueTypeUtf16String = 0x43; /// /// Builds the AddS2 pBuf for a single event. /// is the storage/received timestamp the native path generates uniquely; the orchestrator /// passes a current time. /// 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> properties = OrderedProperties(evt.Properties); writer.Write(checked((ushort)properties.Count)); foreach (KeyValuePair property in properties) { WriteCompactAsciiString(writer, property.Key); WritePropertyValue(writer, property.Key, property.Value); } writer.Flush(); return stream.ToArray(); } private static IReadOnlyList> OrderedProperties(IReadOnlyDictionary? 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> 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); } /// Compact ASCII string: 0x09 LEN 0x00 LEN×ASCII bytes. 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); } /// /// FILETIME truncated to whole milliseconds — the native event path stores millisecond /// resolution and floors sub-millisecond ticks (observed in the capture). /// 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(); } }