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>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user