Files
histsdk/tests/AVEVA.Historian.Client.Tests/HistorianEventRowProtocolTests.cs
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

231 lines
8.6 KiB
C#

using System.Buffers.Binary;
using System.Text;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class HistorianEventRowProtocolTests
{
private static readonly Guid PlaceholderAlarmId = new("00000000-0000-0000-0000-000000000001");
[Fact]
public void Parse_EmptyBuffer_ReturnsEmpty()
{
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse([]);
Assert.Empty(events);
}
[Fact]
public void Parse_HeaderWithZeroRowCount_ReturnsEmpty()
{
byte[] buffer = BuildHeader(rowCount: 0);
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
Assert.Empty(events);
}
[Fact]
public void Parse_WrongVersion_ReturnsEmpty()
{
byte[] buffer = new byte[6];
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 8); // not 9
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(2, 4), 5u);
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
Assert.Empty(events);
}
[Fact]
public void Parse_TwoSyntheticRows_ReturnsTimestampsAndEventTypes()
{
DateTime t1 = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
DateTime t2 = t1.AddSeconds(10);
byte[] buffer = Concat(
BuildHeader(rowCount: 2),
BuildRow(t1, "Alarm.Set", []),
BuildRow(t2, "Alarm.Clear", []));
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
Assert.Equal(2, events.Count);
Assert.Equal(t1, events[0].EventTimeUtc);
Assert.Equal("Alarm.Set", events[0].Type);
Assert.Equal(t2, events[1].EventTimeUtc);
Assert.Equal("Alarm.Clear", events[1].Type);
}
[Fact]
public void Parse_RowWithKnownProperties_PopulatesEventFields()
{
DateTime eventTime = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
DateTime receivedTime = eventTime.AddMilliseconds(250);
var properties = new (string Name, byte[] Value)[]
{
("alarm_inalarm", BuildBool(true)),
("alarm_id", BuildGuid(PlaceholderAlarmId)),
("severity", BuildInt32(2)),
("priority", BuildInt32(500)),
("alarm_class", BuildUtf16String("DSC")),
("source_processvariable", BuildUtf16String("Sample.Tag")),
("provider_system", BuildUtf16String("Application Server")),
("receivedtime", BuildFiletime(receivedTime)),
("revisionversion", BuildInt32(7)),
};
byte[] buffer = Concat(BuildHeader(rowCount: 1), BuildRow(eventTime, "Alarm.Set", properties));
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
HistorianEvent evt = Assert.Single(events);
Assert.Equal(PlaceholderAlarmId, evt.Id);
Assert.Equal(eventTime, evt.EventTimeUtc);
Assert.Equal(receivedTime, evt.ReceivedTimeUtc);
Assert.Equal("Alarm.Set", evt.Type);
Assert.Equal("Sample.Tag", evt.SourceName);
Assert.Equal("Application Server", evt.Namespace);
Assert.Equal(7, evt.RevisionVersion);
Assert.Equal(true, evt.Properties["alarm_inalarm"]);
Assert.Equal("DSC", evt.Properties["alarm_class"]);
Assert.Equal(2, evt.Properties["severity"]);
Assert.Equal(500, evt.Properties["priority"]);
}
[Fact]
public void Parse_UnknownTypeMarker_KeepsRawBytesInPropertyBag()
{
DateTime eventTime = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
// Custom type 0xAA with 3-byte value.
byte[] customValue = [0xAA, 0x03, 0x00, 0xDE, 0xAD, 0xBE];
byte[] buffer = Concat(
BuildHeader(rowCount: 1),
BuildRowWithRawValue(eventTime, "Alarm.Set", "custom_field", customValue));
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
HistorianEvent evt = Assert.Single(events);
Assert.IsType<byte[]>(evt.Properties["custom_field"]);
Assert.Equal([0xDE, 0xAD, 0xBE], (byte[])evt.Properties["custom_field"]!);
}
[Fact]
public void Parse_RowWithMissingMarker_StopsAtBadRow()
{
DateTime t1 = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
byte[] goodRow = BuildRow(t1, "Alarm.Set", []);
byte[] badRow = new byte[goodRow.Length];
byte[] buffer = Concat(BuildHeader(rowCount: 2), goodRow, badRow);
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
Assert.Single(events);
Assert.Equal("Alarm.Set", events[0].Type);
}
private static byte[] BuildHeader(uint rowCount)
{
byte[] header = new byte[6];
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0, 2), HistorianEventRowProtocol.EventRowProtocolVersion);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(2, 4), rowCount);
return header;
}
private static byte[] BuildRow(DateTime eventTimeUtc, string eventType, (string Name, byte[] Value)[] properties)
{
byte[] eventTypeBytes = BuildCompactAscii(eventType);
ushort propertyCount = (ushort)properties.Length;
int propertyBlockSize = 0;
byte[][] propertyBlocks = new byte[properties.Length][];
for (int i = 0; i < properties.Length; i++)
{
byte[] nameBlock = BuildCompactAscii(properties[i].Name);
propertyBlocks[i] = Concat(nameBlock, properties[i].Value);
propertyBlockSize += propertyBlocks[i].Length;
}
byte[] row = new byte[4 + 2 + 8 + 16 + eventTypeBytes.Length + 2 + propertyBlockSize];
Span<byte> span = row;
BinaryPrimitives.WriteUInt32LittleEndian(span[..4], HistorianEventRowProtocol.RowMarker);
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(4, 2), HistorianEventRowProtocol.RowFormatV9);
BinaryPrimitives.WriteInt64LittleEndian(span.Slice(6, 8), eventTimeUtc.ToFileTimeUtc());
// 16 bytes of zeroed slot ushorts left as-is.
int eventTypeOffset = 4 + 2 + 8 + 16;
eventTypeBytes.CopyTo(span[eventTypeOffset..]);
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(eventTypeOffset + eventTypeBytes.Length, 2), propertyCount);
int cursor = eventTypeOffset + eventTypeBytes.Length + 2;
foreach (byte[] block in propertyBlocks)
{
block.CopyTo(span[cursor..]);
cursor += block.Length;
}
return row;
}
private static byte[] BuildRowWithRawValue(DateTime eventTimeUtc, string eventType, string propertyName, byte[] rawValueBytes)
{
return BuildRow(eventTimeUtc, eventType, [(propertyName, rawValueBytes)]);
}
private static byte[] BuildCompactAscii(string s)
{
byte[] ascii = Encoding.ASCII.GetBytes(s);
byte[] result = new byte[3 + ascii.Length];
result[0] = 0x09;
result[1] = (byte)ascii.Length;
result[2] = 0x00;
ascii.CopyTo(result, 3);
return result;
}
private static byte[] BuildBool(bool value) => [0x02, 0x01, 0x00, value ? (byte)1 : (byte)0];
private static byte[] BuildInt32(int value)
{
byte[] result = [0x31, 0x04, 0x00, 0, 0, 0, 0];
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(3, 4), value);
return result;
}
private static byte[] BuildGuid(Guid value)
{
byte[] result = new byte[19];
result[0] = 0x10;
result[1] = 0x10;
result[2] = 0x00;
value.ToByteArray().CopyTo(result, 3);
return result;
}
private static byte[] BuildFiletime(DateTime value)
{
byte[] result = [0x18, 0x08, 0x00, 0, 0, 0, 0, 0, 0, 0, 0];
BinaryPrimitives.WriteInt64LittleEndian(result.AsSpan(3, 8), value.ToFileTimeUtc());
return result;
}
private static byte[] BuildUtf16String(string value)
{
byte[] chars = Encoding.Unicode.GetBytes(value);
ushort innerLength = (ushort)(2 + chars.Length); // UInt16 charCount + chars
byte[] result = new byte[3 + innerLength];
result[0] = 0x43;
result[1] = (byte)innerLength;
result[2] = 0x00;
BinaryPrimitives.WriteUInt16LittleEndian(result.AsSpan(3, 2), (ushort)value.Length);
chars.CopyTo(result, 5);
return result;
}
private static byte[] Concat(params byte[][] arrays)
{
int total = 0;
foreach (byte[] a in arrays) total += a.Length;
byte[] result = new byte[total];
int offset = 0;
foreach (byte[] a in arrays)
{
Buffer.BlockCopy(a, 0, result, offset, a.Length);
offset += a.Length;
}
return result;
}
}