using System.Buffers.Binary; using System.Text; using AVEVA.Historian.Client.Models; namespace AVEVA.Historian.Client.Wcf; /// /// Parser for the version-9 event-row buffer the Historian server returns from /// /Retr/GetNextEventQueryResultBuffer.pResultBuff. Wire shape decoded from a captured /// native event read (instrument-wcf-readmessage record 24, two rows for Alarm.Set + Alarm.Clear): /// /// /// UInt16 version = 9 /// UInt32 rowCount /// rowCount × Row { /// UInt32 rowMarker = 0x1E /// UInt16 rowFormat = 7 /// Int64 eventTimeUtcFiletime /// UInt16 × 8 // purpose unclear (slot offsets?) /// compact ASCII string // event type (Alarm.Set, Alarm.Clear, ...) /// UInt16 propertyCount /// propertyCount × Property { /// compact ASCII string // property name /// Value { /// UInt8 typeMarker /// UInt8 length // bytes of value following status /// UInt8 status // observed 0x00 in successful captures /// length × byte // encoding determined by typeMarker: /// 0x02 → Boolean (1 byte: 0/1) /// 0x10 → GUID (16 bytes) /// 0x18 → FILETIME UTC (Int64) /// 0x31 → Int32 little-endian /// 0x43 → UTF-16 string: UInt16 charCount + charCount × UInt16 chars /// } /// } /// } /// /// /// Compact ASCII string: 0x09 LEN 0x00 LEN×ASCII bytes (same encoding as /// CTagMetadata strings). /// internal static class HistorianEventRowProtocol { public const ushort EventRowProtocolVersion = 9; public const uint RowMarker = 0x0000001Eu; public const ushort RowFormatV9 = 7; private const int HeaderSize = 6; private const int RowFixedHeaderSize = 4 + 2 + 8 + 16; private const byte ValueTypeBool = 0x02; private const byte ValueTypeGuid = 0x10; private const byte ValueTypeFiletime = 0x18; private const byte ValueTypeInt32 = 0x31; private const byte ValueTypeUtf16String = 0x43; public static IReadOnlyList Parse(ReadOnlySpan buffer) { if (buffer.Length < HeaderSize) { return []; } ushort version = BinaryPrimitives.ReadUInt16LittleEndian(buffer[..2]); if (version != EventRowProtocolVersion) { return []; } uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(2, 4)); if (rowCount == 0) { return []; } List events = new(checked((int)rowCount)); int cursor = HeaderSize; for (uint rowIndex = 0; rowIndex < rowCount; rowIndex++) { if (!TryReadRow(buffer, ref cursor, out HistorianEvent? row)) { break; } events.Add(row); } return events; } private static bool TryReadRow(ReadOnlySpan buffer, ref int cursor, out HistorianEvent row) { row = null!; if (cursor + RowFixedHeaderSize > buffer.Length) { return false; } uint marker = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(cursor, 4)); if (marker != RowMarker) { return false; } ushort format = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(cursor + 4, 2)); if (format != RowFormatV9) { return false; } long filetime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(cursor + 6, 8)); DateTime eventTimeUtc = DateTime.FromFileTimeUtc(filetime); int afterFixedHeader = cursor + RowFixedHeaderSize; if (!TryReadCompactAsciiString(buffer, afterFixedHeader, out string eventType, out int afterType)) { return false; } if (afterType + 2 > buffer.Length) { return false; } ushort propertyCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(afterType, 2)); int propertyCursor = afterType + 2; Dictionary properties = new(propertyCount, StringComparer.OrdinalIgnoreCase); for (int p = 0; p < propertyCount; p++) { if (!TryReadCompactAsciiString(buffer, propertyCursor, out string name, out int afterName)) { return false; } if (!TryReadValue(buffer, afterName, out object? value, out int afterValue)) { return false; } properties[name] = value; propertyCursor = afterValue; } row = BuildEvent(eventTimeUtc, eventType, properties); cursor = propertyCursor; return true; } private static HistorianEvent BuildEvent(DateTime eventTimeUtc, string eventType, Dictionary properties) { Guid id = TryGetGuid(properties, "alarm_id") ?? Guid.Empty; DateTime receivedTime = TryGetFiletime(properties, "receivedtime") ?? eventTimeUtc; string sourceName = TryGetString(properties, "source_processvariable") ?? TryGetString(properties, "source_object") ?? string.Empty; string ns = TryGetString(properties, "namespace") ?? TryGetString(properties, "provider_system") ?? string.Empty; ushort revisionVersion = TryGetInt32(properties, "revisionversion") is int rv && rv is >= 0 and <= ushort.MaxValue ? (ushort)rv : (ushort)0; return new HistorianEvent( Id: id, EventTimeUtc: eventTimeUtc, ReceivedTimeUtc: receivedTime, Type: eventType, SourceName: sourceName, Namespace: ns, RevisionVersion: revisionVersion, Properties: properties); } private static Guid? TryGetGuid(Dictionary properties, string key) => properties.TryGetValue(key, out object? value) && value is Guid g ? g : null; private static DateTime? TryGetFiletime(Dictionary properties, string key) => properties.TryGetValue(key, out object? value) && value is DateTime dt ? dt : null; private static string? TryGetString(Dictionary properties, string key) => properties.TryGetValue(key, out object? value) && value is string s ? s : null; private static int? TryGetInt32(Dictionary properties, string key) => properties.TryGetValue(key, out object? value) && value is int i ? i : null; /// /// Compact ASCII string encoding: 0x09 LEN 0x00 LEN×ASCII bytes. /// private static bool TryReadCompactAsciiString(ReadOnlySpan buffer, int offset, out string value, out int afterOffset) { value = string.Empty; afterOffset = offset; if (offset + 3 > buffer.Length || buffer[offset] != 0x09) { return false; } byte length = buffer[offset + 1]; int payloadStart = offset + 3; if (payloadStart + length > buffer.Length) { return false; } value = Encoding.ASCII.GetString(buffer.Slice(payloadStart, length)); afterOffset = payloadStart + length; return true; } /// /// Value encoding: typeMarker(1) + length(1) + status(1) + length×value bytes. /// Decodes the value by typeMarker; unknown markers preserve the raw bytes as a /// in the property bag. /// private static bool TryReadValue(ReadOnlySpan buffer, int offset, out object? value, out int afterOffset) { value = null; afterOffset = offset; if (offset + 3 > buffer.Length) { return false; } byte typeMarker = buffer[offset]; byte length = buffer[offset + 1]; // buffer[offset + 2] is the status byte (observed 0x00 in successful captures). int valueStart = offset + 3; if (valueStart + length > buffer.Length) { return false; } ReadOnlySpan valueBytes = buffer.Slice(valueStart, length); value = typeMarker switch { ValueTypeBool when length >= 1 => valueBytes[0] != 0, ValueTypeGuid when length == 16 => new Guid(valueBytes), ValueTypeFiletime when length == 8 => DateTime.FromFileTimeUtc(BinaryPrimitives.ReadInt64LittleEndian(valueBytes)), ValueTypeInt32 when length == 4 => BinaryPrimitives.ReadInt32LittleEndian(valueBytes), ValueTypeUtf16String when length >= 2 => DecodeUtf16String(valueBytes), _ => valueBytes.ToArray() }; afterOffset = valueStart + length; return true; } private static string DecodeUtf16String(ReadOnlySpan valueBytes) { ushort charCount = BinaryPrimitives.ReadUInt16LittleEndian(valueBytes[..2]); int byteCount = checked(charCount * 2); if (byteCount > valueBytes.Length - 2) { byteCount = valueBytes.Length - 2; } return Encoding.Unicode.GetString(valueBytes.Slice(2, byteCount)); } }