Files
histsdk/src/AVEVA.Historian.Client/Wcf/HistorianEventRowProtocol.cs
T
dohertj2 c95824a65d Initial commit: managed .NET 10 AVEVA Historian SDK + reverse-engineering toolkit
Full read-only SDK (src/AVEVA.Historian.Client) implementing the CLAUDE.md required
surface against AVEVA Historian's binary WCF protocol — no native AVEVA runtime
dependency. All operations live-verified against a local Historian:

- ProbeAsync, ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync, ReadEventsAsync
- BrowseTagNamesAsync, GetTagMetadataAsync (17 native data-type codes mapped)
- GetConnectionStatusAsync, GetStoreForwardStatusAsync, GetSystemParameterAsync
- 108/108 unit + integration tests pass

Includes the reverse-engineering toolkit (tools/AVEVA.Historian.ReverseEngineering)
used to decode the protocol: WCF probes, IL inspection via dnlib, and IL-rewrite
instrumentation (instrument-wcf-{write,read}message etc.) plus the .NET Framework
trace harness (tools/AVEVA.Historian.NativeTraceHarness) for parity testing.

Sanitized handoff evidence under docs/reverse-engineering/. Native AVEVA binaries
(current/, aveva-install-x64/, aveva-install-x86/) are gitignored — fetch separately
from the AVEVA installer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 06:31:48 -04:00

256 lines
9.1 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>
/// Parser for the version-9 event-row buffer the Historian server returns from
/// <c>/Retr/GetNextEventQueryResultBuffer.pResultBuff</c>. Wire shape decoded from a captured
/// native event read (instrument-wcf-readmessage record 24, two rows for Alarm.Set + Alarm.Clear):
///
/// <code>
/// 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
/// }
/// }
/// }
/// </code>
///
/// Compact ASCII string: <c>0x09 LEN 0x00 LEN×ASCII bytes</c> (same encoding as
/// CTagMetadata strings).
/// </remarks>
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<HistorianEvent> Parse(ReadOnlySpan<byte> 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<HistorianEvent> 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<byte> 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<string, object?> 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<string, object?> 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<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is Guid g ? g : null;
private static DateTime? TryGetFiletime(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is DateTime dt ? dt : null;
private static string? TryGetString(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is string s ? s : null;
private static int? TryGetInt32(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is int i ? i : null;
/// <summary>
/// Compact ASCII string encoding: <c>0x09 LEN 0x00 LEN×ASCII bytes</c>.
/// </summary>
private static bool TryReadCompactAsciiString(ReadOnlySpan<byte> 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;
}
/// <summary>
/// Value encoding: <c>typeMarker(1) + length(1) + status(1) + length×value bytes</c>.
/// Decodes the value by typeMarker; unknown markers preserve the raw bytes as a
/// <see cref="byte[]"/> in the property bag.
/// </summary>
private static bool TryReadValue(ReadOnlySpan<byte> 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<byte> 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<byte> 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));
}
}