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:
dohertj2
2026-05-04 06:31:48 -04:00
commit c95824a65d
230 changed files with 38666 additions and 0 deletions
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AVEVA.Historian.Client\AVEVA.Historian.Client.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,38 @@
using System.Text;
using AVEVA.Historian.Client.Protocol;
namespace AVEVA.Historian.Client.Tests;
public sealed class BinaryPrimitiveTests
{
[Fact]
public void ToFileTimeUtc_TreatsUnspecifiedAsUtc()
{
DateTime value = new(2020, 4, 5, 10, 7, 42, DateTimeKind.Unspecified);
long actual = HistorianBinaryPrimitives.ToFileTimeUtc(value);
Assert.Equal(DateTime.SpecifyKind(value, DateTimeKind.Utc).ToFileTimeUtc(), actual);
}
[Fact]
public void WriteUtf16NullTerminated_WritesUnicodeWithTerminator()
{
using MemoryStream stream = new();
HistorianBinaryPrimitives.WriteUtf16NullTerminated(stream, "UTC");
Assert.Equal(Encoding.Unicode.GetBytes("UTC\0"), stream.ToArray());
}
[Fact]
public void WriteFileTimeUtc_WritesLittleEndianUInt64()
{
DateTime value = new(2020, 4, 5, 10, 7, 42, DateTimeKind.Utc);
using MemoryStream stream = new();
HistorianBinaryPrimitives.WriteFileTimeUtc(stream, value);
Assert.Equal(BitConverter.GetBytes(value.ToFileTimeUtc()), stream.ToArray());
}
}
@@ -0,0 +1,33 @@
using AVEVA.Historian.Client.Models;
namespace AVEVA.Historian.Client.Tests;
public sealed class EnumCompatibilityTests
{
[Fact]
public void RetrievalMode_ValuesMatchManagedWrapper()
{
Assert.Equal(0, (int)RetrievalMode.Cyclic);
Assert.Equal(1, (int)RetrievalMode.Delta);
Assert.Equal(2, (int)RetrievalMode.Full);
Assert.Equal(3, (int)RetrievalMode.Interpolated);
Assert.Equal(11, (int)RetrievalMode.ValueState);
Assert.Equal(14, (int)RetrievalMode.EndBound);
}
[Fact]
public void ConnectionKind_ValuesMatchManagedWrapper()
{
Assert.Equal(1, (int)HistorianConnectionKind.Process);
Assert.Equal(2, (int)HistorianConnectionKind.Event);
}
[Fact]
public void InterpolationType_ValuesMatchManagedWrapper()
{
Assert.Equal(0, (int)InterpolationType.StairStep);
Assert.Equal(1, (int)InterpolationType.Linear);
Assert.Equal(254, (int)InterpolationType.SystemDefault);
Assert.Equal(255, (int)InterpolationType.None);
}
}
@@ -0,0 +1,63 @@
using System.Runtime.Versioning;
using AVEVA.Historian.Client.Wcf;
using Xunit.Abstractions;
namespace AVEVA.Historian.Client.Tests;
[SupportedOSPlatform("windows")]
public sealed class EventChainDiagnosticTests
{
private readonly ITestOutputHelper _output;
public EventChainDiagnosticTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public async Task EventOrchestrator_DiagnosticDump_AgainstLocalHistorian()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClientOptions options = new()
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
};
HistorianWcfEventOrchestrator orchestrator = new(options);
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
int observed = 0;
AVEVA.Historian.Client.Models.HistorianEvent? firstEvent = null;
await foreach (var evt in orchestrator.ReadEventsAsync(startUtc, endUtc, CancellationToken.None))
{
observed++;
firstEvent ??= evt;
}
_output.WriteLine($"Events observed: {observed}");
if (firstEvent is not null)
{
_output.WriteLine($" EventTimeUtc: {firstEvent.EventTimeUtc:O}");
_output.WriteLine($" ReceivedTimeUtc: {firstEvent.ReceivedTimeUtc:O}");
_output.WriteLine($" Type: {firstEvent.Type}");
_output.WriteLine($" Properties.Count:{firstEvent.Properties.Count}");
_output.WriteLine($" Has alarm_id: {firstEvent.Id != Guid.Empty}");
}
_output.WriteLine($"LastEnsT2Handle: {HistorianWcfEventOrchestrator.LastEnsT2Handle}");
_output.WriteLine($"LastEnsT2PayloadSha256: {HistorianWcfEventOrchestrator.LastEnsT2PayloadSha256}");
_output.WriteLine($"LastUpdC3ReturnCode: {HistorianWcfEventOrchestrator.LastUpdC3ReturnCode}");
_output.WriteLine($"LastRTag2ReturnCode: {HistorianWcfEventOrchestrator.LastRTag2ReturnCode}");
_output.WriteLine($"LastAddReturnCode (EnsT2): {HistorianWcfEventOrchestrator.LastAddReturnCode}");
_output.WriteLine($"LastAddOutputLength: {HistorianWcfEventOrchestrator.LastAddOutputLength}");
_output.WriteLine($"LastResultBufferLength: {orchestrator.LastResultBufferLength}");
_output.WriteLine($"LastErrorBufferDescription: {orchestrator.LastErrorBufferDescription}");
}
}
@@ -0,0 +1,28 @@
using AVEVA.Historian.Client.Protocol;
namespace AVEVA.Historian.Client.Tests;
public sealed class FrameTests
{
[Fact]
public async Task FrameWriterAndReader_RoundTrip()
{
HistorianFrame frame = new((HistorianMessageType)42, 123u, new byte[] { 1, 2, 3, 4 });
byte[] bytes = HistorianFrameWriter.ToArray(frame);
HistorianFrame actual = await HistorianFrameReader.ReadAsync(new MemoryStream(bytes), CancellationToken.None);
Assert.Equal(frame.MessageType, actual.MessageType);
Assert.Equal(frame.CorrelationId, actual.CorrelationId);
Assert.True(frame.Payload.Span.SequenceEqual(actual.Payload.Span));
}
[Fact]
public async Task FrameReader_RejectsInvalidLength()
{
byte[] bytes = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
await Assert.ThrowsAsync<FrameFormatException>(async () =>
await HistorianFrameReader.ReadAsync(new MemoryStream(bytes), CancellationToken.None));
}
}
@@ -0,0 +1,303 @@
namespace AVEVA.Historian.Client.Tests;
public sealed class HistorianClientIntegrationTests
{
[Fact]
public async Task ProbeAsync_ReturnsTrueForConfiguredHistorian()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host))
{
return;
}
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort)
? parsedPort
: HistorianClientOptions.DefaultPort;
HistorianClient client = new(new HistorianClientOptions { Host = host, Port = port });
Assert.True(await client.ProbeAsync(CancellationToken.None));
}
[Fact]
public async Task BrowseTagNamesAsync_ReturnsConfiguredTestTag()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
string? filter = Environment.GetEnvironmentVariable("HISTORIAN_TAG_FILTER") ?? testTag;
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || string.IsNullOrWhiteSpace(filter))
{
return;
}
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort)
? parsedPort
: HistorianClientOptions.DefaultPort;
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
Port = port,
IntegratedSecurity = true,
UserName = Environment.GetEnvironmentVariable("HISTORIAN_USER") ?? string.Empty,
Password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD") ?? string.Empty
});
List<string> tagNames = [];
await foreach (string tagName in client.BrowseTagNamesAsync(filter, CancellationToken.None))
{
tagNames.Add(tagName);
}
Assert.Contains(testTag, tagNames);
}
[Fact]
public async Task ReadRawAsync_AgainstLocalHistorian_ReturnsAtLeastOneRow()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
{
// The managed read flow currently only supports the LocalPipe transport.
return;
}
if (!OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
List<AVEVA.Historian.Client.Models.HistorianSample> samples = [];
await foreach (AVEVA.Historian.Client.Models.HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None))
{
samples.Add(sample);
}
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
}
[Fact]
public async Task ReadAggregateAsync_AgainstLocalHistorian_ReturnsTimeWeightedAverageRows()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromMinutes(10);
List<AVEVA.Historian.Client.Models.HistorianAggregateSample> samples = [];
await foreach (AVEVA.Historian.Client.Models.HistorianAggregateSample sample in client.ReadAggregateAsync(
testTag, startUtc, endUtc,
AVEVA.Historian.Client.Models.RetrievalMode.TimeWeightedAverage,
TimeSpan.FromMinutes(1),
CancellationToken.None))
{
samples.Add(sample);
}
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
Assert.All(samples, s => Assert.Equal(AVEVA.Historian.Client.Models.RetrievalMode.TimeWeightedAverage, s.RetrievalMode));
}
[Fact]
public async Task ReadAtTimeAsync_AgainstLocalHistorian_ReturnsRequestedTimestamps()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
DateTime nowUtc = DateTime.UtcNow;
DateTime[] timestamps =
[
nowUtc - TimeSpan.FromMinutes(5),
nowUtc - TimeSpan.FromMinutes(2),
nowUtc - TimeSpan.FromMinutes(1)
];
IReadOnlyList<AVEVA.Historian.Client.Models.HistorianSample> samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None);
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
}
[Fact]
public async Task ReadEventsAsync_AgainstLocalHistorian_DoesNotThrow()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
// The event-row WCF wire format is not yet decoded; this test verifies the chain
// (ValCl + Open2 + Retr.IsOriginalAllowed + Retr.StartEventQuery) reaches the server
// without throwing. An empty event list is acceptable until row parsing is wired.
List<AVEVA.Historian.Client.Models.HistorianEvent> events = [];
await foreach (AVEVA.Historian.Client.Models.HistorianEvent evt in client.ReadEventsAsync(startUtc, endUtc, CancellationToken.None))
{
events.Add(evt);
}
Assert.NotNull(events);
}
[Fact]
public async Task GetSystemParameterAsync_AgainstLocalHistorian_ReturnsHistorianVersion()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
string? value = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None);
// The server returns a non-empty version string for the documented HistorianVersion parameter.
Assert.False(string.IsNullOrWhiteSpace(value));
}
[Fact]
public async Task GetConnectionStatusAsync_AgainstLocalHistorian_ReportsConnectedToServer()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
AVEVA.Historian.Client.Models.HistorianConnectionStatus status =
await client.GetConnectionStatusAsync(CancellationToken.None);
Assert.True(status.ConnectedToServer);
Assert.False(status.ErrorOccurred);
Assert.False(status.Pending);
Assert.Equal(host, status.ServerName);
}
[Fact]
public async Task GetStoreForwardStatusAsync_AgainstLocalHistorian_ReturnsDefaults()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
AVEVA.Historian.Client.Models.HistorianStoreForwardStatus status =
await client.GetStoreForwardStatusAsync(CancellationToken.None);
// The synthesized status returns defaults — no store-forward sidecar to probe in this build.
Assert.False(status.ErrorOccurred);
Assert.False(status.Pending);
Assert.Equal(host, status.ServerName);
}
[Fact]
public async Task GetTagMetadataAsync_ReturnsConfiguredTestTagMetadata()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort)
? parsedPort
: HistorianClientOptions.DefaultPort;
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
Port = port,
IntegratedSecurity = true,
UserName = Environment.GetEnvironmentVariable("HISTORIAN_USER") ?? string.Empty,
Password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD") ?? string.Empty
});
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
await client.GetTagMetadataAsync(testTag, CancellationToken.None);
Assert.NotNull(metadata);
Assert.Equal(testTag, metadata.Name);
Assert.NotNull(metadata.Key);
}
}
@@ -0,0 +1,230 @@
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;
}
}
@@ -0,0 +1,43 @@
using System.Runtime.Versioning;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
[SupportedOSPlatform("windows")]
public sealed class HistorianSspiClientTests
{
[Fact]
public void NativeFlagsRound0_MatchesDocumentedNativeWrapperValue()
{
Assert.Equal(0x2081C, HistorianSspiClient.NativeFlagsRound0);
}
[Fact]
public void NativeFlagsRoundSubsequent_MatchesDocumentedNativeWrapperValue()
{
Assert.Equal(0x81C, HistorianSspiClient.NativeFlagsRoundSubsequent);
}
[Fact]
public void Round0FlagsIncludeIdentify_LaterRoundsDoNot()
{
Assert.Equal(HistorianSspiClient.IscReqIdentify, HistorianSspiClient.NativeFlagsRound0 & HistorianSspiClient.IscReqIdentify);
Assert.Equal(0, HistorianSspiClient.NativeFlagsRoundSubsequent & HistorianSspiClient.IscReqIdentify);
}
[Fact]
public void AllRoundsRequestReplayAndSequenceDetection()
{
const int both = HistorianSspiClient.IscReqReplayDetect | HistorianSspiClient.IscReqSequenceDetect;
Assert.Equal(both, HistorianSspiClient.NativeFlagsRound0 & both);
Assert.Equal(both, HistorianSspiClient.NativeFlagsRoundSubsequent & both);
}
[Fact]
public void SelectRequestFlags_DispatchesByRoundIndex()
{
Assert.Equal(HistorianSspiClient.NativeFlagsRound0, HistorianSspiClient.SelectRequestFlags(0));
Assert.Equal(HistorianSspiClient.NativeFlagsRoundSubsequent, HistorianSspiClient.SelectRequestFlags(1));
Assert.Equal(HistorianSspiClient.NativeFlagsRoundSubsequent, HistorianSspiClient.SelectRequestFlags(7));
}
}
@@ -0,0 +1,16 @@
namespace AVEVA.Historian.Client.Tests;
public sealed class ProtocolGuardrailTests
{
[Fact]
public async Task ReadAtTime_RequiresAuthCredentials()
{
HistorianClient client = new(new HistorianClientOptions { Host = "localhost", IntegratedSecurity = false });
ProtocolEvidenceMissingException ex = await Assert.ThrowsAsync<ProtocolEvidenceMissingException>(() =>
client.ReadAtTimeAsync("SysTimeSec", [DateTime.UtcNow], CancellationToken.None));
Assert.Contains("IntegratedSecurity", ex.Operation);
}
}
@@ -0,0 +1,91 @@
using System.Runtime.Versioning;
using AVEVA.Historian.Client.Wcf;
using Xunit.Abstractions;
namespace AVEVA.Historian.Client.Tests;
[SupportedOSPlatform("windows")]
public sealed class TagMetadataDescriptorProbeTests
{
private readonly ITestOutputHelper _output;
public TagMetadataDescriptorProbeTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void ProbeDescriptorsForKnownSampleTags()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
{
return;
}
string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_DESCRIPTOR_PROBE_TAGS")
?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (sampleTags.Length == 0)
{
return;
}
HistorianClientOptions options = new()
{
Host = host,
IntegratedSecurity = true,
};
foreach (string tagName in sampleTags)
{
try
{
HistorianTagInfoResponse parsed = HistorianWcfTagClient.GetTagInfoForDescriptorProbe(options, tagName);
_output.WriteLine($" {tagName,-50} descriptor=0x{Convert.ToHexString(parsed.NativeDataTypeDescriptor)}");
}
catch (Exception ex)
{
_output.WriteLine($" {tagName,-50} ERROR: {ex.GetType().Name}: {ex.Message}");
}
}
}
[Fact]
public void EnumerateAllTagDescriptorsAcrossOneSession()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
{
return;
}
string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_DESCRIPTOR_PROBE_TAGS") ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (sampleTags.Length == 0)
{
return;
}
HistorianClientOptions options = new()
{
Host = host,
IntegratedSecurity = true,
};
IReadOnlyDictionary<string, HistorianTagInfoResponse?> results =
HistorianWcfTagClient.GetTagInfosForDescriptorProbe(options, sampleTags);
// Group by descriptor (hex string) and report counts only — no tag names in output to
// avoid leaking customer-tag identifiers.
var grouped = results
.Where(static kv => kv.Value is not null)
.GroupBy(static kv => Convert.ToHexString(kv.Value!.NativeDataTypeDescriptor))
.OrderBy(static g => g.Key);
_output.WriteLine($"Probed {results.Count} tags ({results.Count(static kv => kv.Value is null)} errors).");
foreach (var grp in grouped)
{
_output.WriteLine($" 0x{grp.Key} count={grp.Count()}");
}
}
}
@@ -0,0 +1,97 @@
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class WcfAuthenticationProtocolTests
{
[Fact]
public void WrapValidateClientCredentialToken_UsesNativeRoundAndLengthEnvelope()
{
byte[] actual = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken(
isFirstRound: true,
[0x4E, 0x54, 0x4C, 0x4D]);
Assert.Equal([0x01, 0x04, 0x00, 0x00, 0x00, 0x4E, 0x54, 0x4C, 0x4D], actual);
}
[Fact]
public void TryReadWrappedValidateClientCredentialToken_ReadsNativeEnvelope()
{
ValidateClientCredentialToken? actual =
HistorianWcfAuthenticationProtocol.TryReadWrappedValidateClientCredentialToken(
[0x00, 0x03, 0x00, 0x00, 0x00, 0xAA, 0xBB, 0xCC]);
Assert.NotNull(actual);
Assert.False(actual.IsFirstRound);
Assert.Equal([0xAA, 0xBB, 0xCC], actual.Token);
}
[Fact]
public void TryReadWrappedValidateClientCredentialToken_RejectsLengthMismatch()
{
Assert.Null(HistorianWcfAuthenticationProtocol.TryReadWrappedValidateClientCredentialToken(
[0x01, 0x04, 0x00, 0x00, 0x00, 0xAA]));
}
[Fact]
public void TryReadValidateClientCredentialResponse_ReadsContinueFlagAndServerToken()
{
ValidateClientCredentialResponse? actual =
HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(
[0x01, 0x11, 0x22, 0x33]);
Assert.NotNull(actual);
Assert.True(actual.Continue);
Assert.Equal([0x11, 0x22, 0x33], actual.Token);
}
[Fact]
public void TryReadValidateClientCredentialResponse_ReadsTerminalOneByteResponse()
{
ValidateClientCredentialResponse? actual =
HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse([0x00]);
Assert.NotNull(actual);
Assert.False(actual.Continue);
Assert.Empty(actual.Token);
}
[Fact]
public void TryReadValidateClientCredentialResponse_RejectsEmptyResponse()
{
Assert.Null(HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse([]));
}
[Fact]
public void TryApplyNativeNtlmNegotiateVersionFlag_MatchesObservedNativeFirstTokenFlag()
{
byte[] token =
[
0x4E, 0x54, 0x4C, 0x4D, 0x53, 0x53, 0x50, 0x00,
0x01, 0x00, 0x00, 0x00,
0xB7, 0xB2, 0x08, 0xE2
];
bool changed = HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(token);
Assert.True(changed);
Assert.Equal(
[
0x4E, 0x54, 0x4C, 0x4D, 0x53, 0x53, 0x50, 0x00,
0x01, 0x00, 0x00, 0x00,
0xB7, 0xB2, 0x18, 0xE2
],
token);
}
[Fact]
public void TryApplyNativeNtlmNegotiateVersionFlag_IgnoresNonNtlmNegotiateTokens()
{
byte[] token = [0x4B, 0x52, 0x42, 0x35];
bool changed = HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(token);
Assert.False(changed);
Assert.Equal([0x4B, 0x52, 0x42, 0x35], token);
}
}
@@ -0,0 +1,39 @@
using System.Runtime.Versioning;
using System.ServiceModel.Channels;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
[SupportedOSPlatform("windows")]
public sealed class WcfBindingFactoryTests
{
[Fact]
public void CreateMdasNetNamedPipeBinding_WrapsTheInnerEncoderInMdas()
{
Binding binding = HistorianWcfBindingFactory.CreateMdasNetNamedPipeBinding(TimeSpan.FromSeconds(5));
BindingElementCollection elements = binding.CreateBindingElements();
Assert.Contains(elements, e => e is MdasMessageEncodingBindingElement);
}
[Fact]
public void CreateMdasNetNamedPipeBinding_AppliesProvidedTimeout()
{
TimeSpan timeout = TimeSpan.FromSeconds(7);
Binding binding = HistorianWcfBindingFactory.CreateMdasNetNamedPipeBinding(timeout);
Assert.Equal(timeout, binding.OpenTimeout);
Assert.Equal(timeout, binding.CloseTimeout);
Assert.Equal(timeout, binding.SendTimeout);
Assert.Equal(timeout, binding.ReceiveTimeout);
}
[Fact]
public void CreatePipeEndpointAddress_BuildsNetPipeUri()
{
var address = HistorianWcfBindingFactory.CreatePipeEndpointAddress("localhost", "Hist");
Assert.Equal(new Uri("net.pipe://localhost/Hist"), address.Uri);
}
}
@@ -0,0 +1,156 @@
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class WcfDataQueryProtocolTests
{
[Fact]
public void SerializerMatchesInstrumentedNativeFullHistoryRequest()
{
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
["OtOpcUaParityTest_001.Counter"],
new DateTime(2026, 5, 1, 14, 17, 5, 659, DateTimeKind.Utc).AddTicks(3154),
new DateTime(2026, 5, 2, 14, 17, 5, 659, DateTimeKind.Utc).AddTicks(3154),
MaxStates: 100,
BatchSize: 1,
Option: string.Empty));
byte[] expected = Convert.FromBase64String(
"CQACAAAAAAAAAAAAAAAC4ScwddncAQKhkVo+2twBAAAAAAAAAAAAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
Assert.Equal(expected, actual);
}
[Fact]
public void SerializerMatchesInstrumentedNativeTimeWeightedAverageRequest()
{
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
["OtOpcUaParityTest_001.Counter"],
new DateTime(2026, 5, 1, 14, 29, 2, 223, DateTimeKind.Utc).AddTicks(2955),
new DateTime(2026, 5, 2, 14, 29, 2, 223, DateTimeKind.Utc).AddTicks(2955),
MaxStates: 100,
BatchSize: 3,
Option: string.Empty)
{
QueryType = 5,
Resolution = TimeSpan.FromMinutes(1)
});
byte[] expected = Convert.FromBase64String(
"CQAFAAAAAAAAAAAAAAB73ULbdtncAXudrAVA2twBAAAAAKPhwUEAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAABg3vt0BQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
Assert.Equal(expected, actual);
}
[Fact]
public void SerializerMatchesInstrumentedNativeInterpolatedRequest()
{
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
["OtOpcUaParityTest_001.Counter"],
new DateTime(2026, 5, 1, 14, 32, 12, 72, DateTimeKind.Utc).AddTicks(8924),
new DateTime(2026, 5, 2, 14, 32, 12, 72, DateTimeKind.Utc).AddTicks(8924),
MaxStates: 100,
BatchSize: 3,
Option: string.Empty)
{
QueryType = 3,
Resolution = TimeSpan.FromMinutes(1)
});
byte[] expected = Convert.FromBase64String(
"CQADAAAAAAAAAAAAAABcnWtMd9ncAVxd1XZA2twBAAAAAKPhwUEAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAABg3vt0BQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
Assert.Equal(expected, actual);
}
[Fact]
public void SerializerUsesDecompiledEmptyMetadataAndAutoSummaryLayout()
{
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
["T"],
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc),
MaxStates: 100,
BatchSize: 1,
Option: string.Empty));
byte[] expectedMiddle =
[
0x64, 0x00,
0x01,
0x01, 0x00, 0x00,
0x01, 0x00, 0x00,
0x01, 0x00, 0x00,
0x09, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x01, 0x00
];
AssertContains(expectedMiddle, actual);
AssertEndsWith(ExpectedEmptyEndpointAndAutoSummarySuffix(), actual);
}
[Fact]
public void SerializerWritesPackedCqtiFlagsSeparatelyFromColumnSelectorFlags()
{
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
["T"],
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc),
MaxStates: 100,
BatchSize: 1,
Option: "NoOption")
{
InterpolationType = 255,
TimestampRule = 1,
QualityRule = 0,
ColumnSelectorFlags = 0x0000_0000_0003_FFFF
});
int resultBufferOffset = 2 + 4 + 4 + 4 + 8 + 8 + 8 + 4 + 4 + 10 + 4;
Assert.Equal([0x00, 0x00, 0x01, 0x00, 0xFF, 0x01], actual[resultBufferOffset..(resultBufferOffset + 6)]);
AssertContains([0x01, 0x00, 0xFF, 0xFF, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00], actual);
}
private static byte[] ExpectedEmptyEndpointAndAutoSummarySuffix()
{
List<byte> expected = [];
AppendEmptyEndpoint(expected);
AppendEmptyEndpoint(expected);
expected.AddRange(new byte[8]);
expected.AddRange([0x00, 0x00, 0x00, 0x00]);
expected.AddRange([0x00, 0x00, 0x00, 0x00]);
expected.AddRange([0x01, 0x00]);
expected.AddRange(new byte[16]);
expected.AddRange(new byte[5]);
expected.AddRange([0x00, 0x00, 0x00, 0x00]);
return expected.ToArray();
}
private static void AppendEmptyEndpoint(List<byte> bytes)
{
bytes.AddRange([0x01, 0x00]);
bytes.AddRange([0x00, 0x00, 0x00, 0x00]);
bytes.AddRange([0x00, 0x00]);
}
private static void AssertContains(byte[] expected, byte[] actual)
{
for (int index = 0; index <= actual.Length - expected.Length; index++)
{
if (actual.AsSpan(index, expected.Length).SequenceEqual(expected))
{
return;
}
}
Assert.Fail($"Expected byte sequence {Convert.ToHexString(expected)} was not found.");
}
private static void AssertEndsWith(byte[] expectedSuffix, byte[] actual)
{
Assert.True(actual.Length >= expectedSuffix.Length);
Assert.Equal(expectedSuffix, actual[^expectedSuffix.Length..]);
}
}
@@ -0,0 +1,109 @@
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class WcfDataQueryResultBufferTests
{
// Captured from artifacts/reverse-engineering/instrumented-openconnection3-correlation/capture.ndjson
// Wcf.GetNextQueryResultBuffer2.ResultBytes for a 4-row OtOpcUaParityTest_001.Counter Full read.
private static readonly byte[] CapturedResultBytes = Convert.FromBase64String(
"CQAEAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAu" +
"AEMAbwB1AG4AdABlAHIAAQAAAGvPzFvD2dwBhQAAAPgAAADAAAAAAAAAAAAAAAAAAAAAAABZQAAA" +
"AWvPzFvD2dwBAAAAAAAAAAClBtClfAAAAAAAAAAAAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAA" +
"YQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAAQAAABDWnAFA2twBAQAA" +
"ABgAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAARDWnAFA2twBAAAAAAAAAAAwZOgAAAAAAAEAAAAA" +
"AAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMA" +
"bwB1AG4AdABlAHIAAQAAAEA6hQJA2twBAQAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUA6" +
"hQJA2twBAAAAAAAAAABQwwAAAAAAAAEAAAAAAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQBy" +
"AGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAAQAAAJD9hQJA2twBAAAAAPgA" +
"AADAAAAAAAAAAAAAAAAAAAAAAABZQAAAAZD9hQJA2twBAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAA");
private static readonly byte[] TerminalNoMoreData = Convert.FromBase64String("BB4AAAA=");
[Fact]
public void TryParseGetNextQueryResultBufferRows_ParsesFourCanonicalFixtureRows()
{
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
CapturedResultBytes,
TerminalNoMoreData,
out IReadOnlyList<HistorianSample> rows,
out bool hasMoreData);
Assert.True(ok);
Assert.False(hasMoreData);
Assert.Equal(4, rows.Count);
Assert.All(rows, r => Assert.Equal("OtOpcUaParityTest_001.Counter", r.TagName));
HistorianSample row0 = rows[0];
Assert.Equal(133, row0.Quality);
Assert.Equal(248u, row0.QualityDetail);
Assert.Equal(192, row0.OpcQuality);
Assert.Equal(0, row0.NumericValue);
Assert.Equal(100.0, row0.PercentGood);
Assert.Equal(DateTime.FromFileTimeUtc(0x01DCD9C35BCCCF6B), row0.TimestampUtc);
HistorianSample row3 = rows[3];
Assert.Equal(0, row3.Quality);
Assert.Equal(248u, row3.QualityDetail);
Assert.Equal(192, row3.OpcQuality);
Assert.Equal(100.0, row3.PercentGood);
}
[Fact]
public void TryParseGetNextQueryResultBufferRows_FlagsContinuationWhenErrorTerminalIsEmpty()
{
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
CapturedResultBytes,
errorTerminal: [],
out _,
out bool hasMoreData);
Assert.True(ok);
Assert.True(hasMoreData);
}
[Fact]
public void TryParseGetNextQueryResultBufferRows_FlagsContinuationWhenErrorIsNotNoMoreData()
{
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
CapturedResultBytes,
errorTerminal: [0x04, 0x01, 0x00, 0x00, 0x00],
out _,
out bool hasMoreData);
Assert.True(ok);
Assert.True(hasMoreData);
}
[Fact]
public void TryParseGetNextQueryResultBufferRows_RejectsBufferWithUnsupportedVersion()
{
byte[] mangled = (byte[])CapturedResultBytes.Clone();
mangled[0] = 0x07;
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
mangled,
TerminalNoMoreData,
out IReadOnlyList<HistorianSample> rows,
out _);
Assert.False(ok);
Assert.Empty(rows);
}
[Fact]
public void TryParseGetNextQueryResultBufferRows_HandlesEmptyResultBuffer()
{
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
result: [],
TerminalNoMoreData,
out IReadOnlyList<HistorianSample> rows,
out bool hasMoreData);
Assert.True(ok);
Assert.False(hasMoreData);
Assert.Empty(rows);
}
}
@@ -0,0 +1,46 @@
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class WcfEventQueryProtocolTests
{
[Fact]
public void SerializerMatchesInstrumentedNativeEventRequest()
{
HistorianEventQueryAttempt attempt = Assert.Single(HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
new DateTime(2026, 4, 25, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646),
new DateTime(2026, 5, 2, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646),
3));
byte[] expected = Convert.FromBase64String(
"BQBuHAVXwdTcAW5c6X9B2twBAwAAAAAAAAAAAAEAAAAAAAAAAAAAAQADAAAAVQBUAEMAAQEAAAEAAAEAAAAAAAA=");
Assert.Equal(expected, attempt.RequestBuffer);
Assert.Equal("6b955b02087047a3199a8c74f3eee85c3b49aaa29b05de12eff2dd536f2da0d5", attempt.RequestSha256);
}
[Fact]
public void NativeEmptyFilterAttemptMatchesDecompiledSaveOrder()
{
HistorianEventQueryAttempt attempt = Assert.Single(HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc),
3));
byte[] actual = attempt.RequestBuffer;
Assert.Equal("native-empty-filter-version5", attempt.Name);
Assert.Equal(3, HistorianEventQueryProtocol.QueryRequestTypeEvent);
Assert.Equal(65, actual.Length);
Assert.Equal([0x05, 0x00], actual[..2]);
Assert.Equal(3u, BitConverter.ToUInt32(actual, 18));
Assert.Equal(0u, BitConverter.ToUInt32(actual, 22));
Assert.Equal(0, BitConverter.ToUInt16(actual, 26));
Assert.Equal(1, BitConverter.ToUInt16(actual, 28));
Assert.Equal([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], actual[30..37]);
Assert.Equal(65_536u, BitConverter.ToUInt32(actual, 37));
Assert.Equal([0x03, 0x00, 0x00, 0x00, 0x55, 0x00, 0x54, 0x00, 0x43, 0x00], actual[41..51]);
Assert.Equal([0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00], actual[51..61]);
Assert.Equal([0x00, 0x00, 0x00, 0x00], actual[^4..]);
}
}
@@ -0,0 +1,105 @@
using System.Reflection;
using System.ServiceModel;
using AVEVA.Historian.Client.Wcf;
using AVEVA.Historian.Client.Wcf.Contracts;
namespace AVEVA.Historian.Client.Tests;
public sealed class WcfEvidenceTests
{
[Fact]
public void ServiceContractsUseDecompiledNamesAndNamespace()
{
AssertServiceContract<IHistoryServiceContract>("Hist");
AssertServiceContract<IHistoryServiceContract2>("Hist");
AssertServiceContract<IRetrievalServiceContract>("Retr");
AssertServiceContract<IRetrievalServiceContract4>("Retr");
AssertServiceContract<IStatusServiceContract>("Stat");
AssertServiceContract<IStatusServiceContract2>("Stat");
AssertServiceContract<IStorageServiceContract>("Storage");
AssertServiceContract<ITransactionServiceContract>("Trx");
}
[Fact]
public void RelayEvidenceIdentifiesHistorySecurityEndpointNames()
{
Assert.Equal("HistCert", HistorianWcfServiceNames.HistoryCertificate);
Assert.Equal("Hist-Integrated", HistorianWcfServiceNames.HistoryIntegrated);
}
[Fact]
public void KnownOperationAliasesMatchManagedWrapperEvidence()
{
AssertOperation<IHistoryServiceContract>(nameof(IHistoryServiceContract.GetInterfaceVersion), "GetV");
AssertOperation<IHistoryServiceContract>(nameof(IHistoryServiceContract.OpenConnection), "Open");
AssertOperation<IHistoryServiceContract>(nameof(IHistoryServiceContract.ValidateClient), "VldC");
AssertOperation<IHistoryServiceContract>(nameof(IHistoryServiceContract.UpdateClientStatus), "UpdC");
AssertOperation<IHistoryServiceContract2>(nameof(IHistoryServiceContract2.OpenConnection2), "Open2");
AssertOperation<IHistoryServiceContract2>(nameof(IHistoryServiceContract2.ExchangeKey), "ExKey");
AssertOperation<IRetrievalServiceContract2>(nameof(IRetrievalServiceContract2.GetTagInfosFromId), "GetTg");
AssertOperation<IRetrievalServiceContract3>(nameof(IRetrievalServiceContract3.StartTagQuery), "QTB");
AssertOperation<IRetrievalServiceContract4>(nameof(IRetrievalServiceContract4.GetTagExtendedPropertiesFromName), "GetTepByNm");
AssertOperation<IStorageServiceContract>(nameof(IStorageServiceContract.OpenStorageConnection), "Open");
AssertOperation<IStorageServiceContract>(nameof(IStorageServiceContract.LoadBlocks), "LoadB");
AssertOperation<ITransactionServiceContract>(nameof(ITransactionServiceContract.GetInterfaceVersion), "GetV");
AssertDefaultOperation<IRetrievalServiceContract>(nameof(IRetrievalServiceContract.StartQuery));
AssertDefaultOperation<IRetrievalServiceContract4>(nameof(IRetrievalServiceContract4.StartEventQuery));
AssertDefaultOperation<IStatusServiceContract>(nameof(IStatusServiceContract.GetServerTime));
AssertDefaultOperation<IStatusServiceContract2>(nameof(IStatusServiceContract2.GetSystemParameter));
AssertOperation<IStatusServiceContract2>(nameof(IStatusServiceContract2.GetHistorianInfo), "GETHI");
AssertOperation<IStatusServiceContract2>(nameof(IStatusServiceContract2.PingServer), "PNGS");
AssertOperation<IStatusServiceContract2>(nameof(IStatusServiceContract2.PingPipe), "PNGP");
}
[Fact]
public void MdasBindingUsesNetTcpAndCustomContentType()
{
var binding = HistorianWcfBindingFactory.CreateMdasNetTcpBinding(TimeSpan.FromSeconds(5));
var encoder = binding.CreateBindingElements().Find<MdasMessageEncodingBindingElement>();
var endpoint = HistorianWcfBindingFactory.CreateEndpointAddress("localhost", HistorianWcfBindingFactory.DefaultPort, HistorianWcfServiceNames.History);
Assert.NotNull(encoder);
Assert.Equal("net.tcp://localhost:32568/Hist", endpoint.Uri.AbsoluteUri);
Assert.Equal(MdasMessageEncoder.MdasContentType, encoder.CreateMessageEncoderFactory().Encoder.ContentType);
}
[Fact]
public void CertificateBindingUsesMdasEncodingOverTransportSecurity()
{
var binding = HistorianWcfBindingFactory.CreateMdasNetTcpCertificateBinding(TimeSpan.FromSeconds(5));
var elements = binding.CreateBindingElements();
var encoder = elements.Find<MdasMessageEncodingBindingElement>();
var security = elements.Find<System.ServiceModel.Channels.SslStreamSecurityBindingElement>();
Assert.NotNull(encoder);
Assert.NotNull(security);
Assert.Equal(MdasMessageEncoder.MdasContentType, encoder.CreateMessageEncoderFactory().Encoder.ContentType);
}
private static void AssertServiceContract<TContract>(string name)
{
var attribute = typeof(TContract).GetCustomAttribute<ServiceContractAttribute>();
Assert.NotNull(attribute);
Assert.Equal(name, attribute.Name);
Assert.Equal("aa", attribute.Namespace);
}
private static void AssertOperation<TContract>(string methodName, string operationName)
{
var method = typeof(TContract).GetMethod(methodName);
var attribute = method?.GetCustomAttribute<OperationContractAttribute>();
Assert.NotNull(attribute);
Assert.Equal(operationName, attribute.Name);
}
private static void AssertDefaultOperation<TContract>(string methodName)
{
var method = typeof(TContract).GetMethod(methodName);
var attribute = method?.GetCustomAttribute<OperationContractAttribute>();
Assert.NotNull(attribute);
Assert.Null(attribute.Name);
}
}
@@ -0,0 +1,283 @@
using System.Text;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class WcfOpen2ProtocolTests
{
[Fact]
public void LegacyVersion1SerializerMatchesDecompiledSaveOpenConnectionParamsLayout()
{
byte[] actual = HistorianOpen2Protocol.SerializeLegacyVersion1(new HistorianOpen2Request(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: "U",
Password: Encoding.Unicode.GetBytes("pw"),
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 2,
MetadataNamespace: HistorianMetadataNamespace.Empty));
byte[] expected =
[
0x01, 0x00,
0x01, 0x00, 0x00, 0x00, 0x48, 0x00,
0x01, 0x00, 0x00, 0x00, 0x50, 0x00,
0x04, 0x03, 0x02, 0x01,
0x01, 0x00, 0x00, 0x00, 0x55, 0x00,
0x04, 0x00, 0x00, 0x00, 0x70, 0x00, 0x77, 0x00,
0x04,
0x0B, 0x00,
0x02, 0x00, 0x00, 0x00,
0x01,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
];
Assert.Equal(expected, actual);
}
[Fact]
public void LegacyVersion1SerializerUsesUtf16CodeUnitStringLengths()
{
byte[] actual = HistorianOpen2Protocol.SerializeLegacyVersion1(new HistorianOpen2Request(
HostName: "A\ud83d\ude00",
ProcessName: string.Empty,
ProcessId: 0,
UserName: string.Empty,
Password: [],
ClientType: 4,
ClientVersion: 0,
ConnectionMode: 2,
MetadataNamespace: HistorianMetadataNamespace.Empty));
Assert.Equal([0x03, 0x00, 0x00, 0x00], actual[2..6]);
Assert.Equal(Encoding.Unicode.GetBytes("A\ud83d\ude00"), actual[6..12]);
}
[Fact]
public void NativeErrorParserReadsObservedFiveByteBuffers()
{
HistorianNativeError? error = HistorianOpen2Protocol.TryReadNativeError([0x04, 0xAB, 0x00, 0x00, 0x00]);
Assert.NotNull(error);
Assert.Equal(4, error.Type);
Assert.Equal<uint>(171, error.Code);
Assert.Equal("AuthenticationFailed", error.Name);
}
[Fact]
public void NativeErrorParserRejectsShortBuffers()
{
Assert.Null(HistorianOpen2Protocol.TryReadNativeError([0x04, 0xAB, 0x00, 0x00]));
}
[Fact]
public void LegacyOpen2OutputParserReadsObservedWcfLayout()
{
byte[] buffer =
[
0x78, 0x56, 0x34, 0x12,
0x33, 0x22, 0x11, 0x00,
0x55, 0x44,
0x77, 0x66,
0x88, 0x99, 0xAA, 0xBB,
0xCC, 0xDD, 0xEE, 0xFF,
0x08, 0x07, 0x06, 0x05,
0x04, 0x03, 0x02, 0x01,
0x44, 0x33, 0x22, 0x11
];
HistorianLegacyOpen2Output? output = HistorianOpen2Protocol.TryReadLegacyOpen2Output(buffer);
Assert.NotNull(output);
Assert.Equal<uint>(0x12345678, output.Handle);
Assert.Equal(new Guid("00112233-4455-6677-8899-aabbccddeeff"), output.StorageSessionId);
Assert.Equal(0x0102030405060708, output.ConnectTimeFileTimeUtc);
Assert.Equal<uint>(0x11223344, output.ServerStatus);
}
[Fact]
public void LegacyOpen2OutputParserRejectsNonLegacyLength()
{
Assert.Null(HistorianOpen2Protocol.TryReadLegacyOpen2Output([0x00]));
}
[Fact]
public void NativeOpen3OutputParserReadsObservedDeserializerLayout()
{
byte[] buffer =
[
0x03,
0x78, 0x56, 0x34, 0x12,
0x33, 0x22, 0x11, 0x00,
0x55, 0x44,
0x77, 0x66,
0x88, 0x99, 0xAA, 0xBB,
0xCC, 0xDD, 0xEE, 0xFF,
0x08, 0x07, 0x06, 0x05,
0x04, 0x03, 0x02, 0x01,
0x18, 0x17, 0x16, 0x15,
0x14, 0x13, 0x12, 0x11,
0x44, 0x33, 0x22, 0x11,
0x00
];
HistorianNativeOpen3Output? output = HistorianOpen2Protocol.TryReadNativeOpen3Output(buffer);
Assert.NotNull(output);
Assert.Equal(3, output.ProtocolVersion);
Assert.Equal<uint>(0x12345678, output.Handle);
Assert.Equal(new Guid("00112233-4455-6677-8899-aabbccddeeff"), output.StorageSessionId);
Assert.Equal(0x0102030405060708, output.ConnectTimeFileTimeUtc);
Assert.Equal(0x1112131415161718, output.ServerTimeFileTimeUtc);
Assert.Equal([0x44, 0x33, 0x22, 0x11, 0x00], output.TrailingBytes);
}
[Fact]
public void NativeOpen3OutputParserRejectsUnsupportedVersion()
{
Assert.Null(HistorianOpen2Protocol.TryReadNativeOpen3Output([0x01, 0x00, 0x00, 0x00]));
}
[Fact]
public void NativeVersion3SerializerMatchesDecompiledFieldOrder()
{
byte[] actual = HistorianOpen2Protocol.SerializeNativeVersion3(
new HistorianOpen2Request(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: string.Empty,
Password: [0xAA, 0xBB],
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 1026,
MetadataNamespace: HistorianMetadataNamespace.Empty),
new HistorianClientCommonInfo(
FormatVersion: 3,
ServerNodeName: "S",
ClientNodeName: "C",
ProcessId: 0x11223344,
HcalVersion: 17,
ProcessName: "Proc",
Proxy: string.Empty,
DataSourceId: string.Empty,
ShardId: new Guid("00112233-4455-6677-8899-aabbccddeeff"),
ClientVersion: 0x55667788,
ClientTimestamp: 0x0102030405060708,
ClientDllVersion: string.Empty));
byte[] expectedPrefix =
[
0x03,
0x01, 0x00, 0x00, 0x00, 0x48, 0x00,
0x02, 0x00, 0xAA, 0xBB,
0x04,
0x02, 0x04, 0x00, 0x00
];
Assert.Equal(expectedPrefix, actual[..expectedPrefix.Length]);
Assert.Contains<byte>(0x03, actual);
byte[] expectedSuffix = [0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01];
Assert.Equal(expectedSuffix, actual[^expectedSuffix.Length..]);
}
[Fact]
public void NativeOpenConnection3Version6SerializerAddsObservedPrefixBeforeContent()
{
HistorianOpen2Request request = new(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: string.Empty,
Password: [0xAA, 0xBB],
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 1026,
MetadataNamespace: HistorianMetadataNamespace.Empty);
HistorianClientCommonInfo commonInfo = new(
FormatVersion: 3,
ServerNodeName: "S",
ClientNodeName: "C",
ProcessId: 0x11223344,
HcalVersion: 17,
ProcessName: "Proc",
Proxy: string.Empty,
DataSourceId: string.Empty,
ShardId: new Guid("00112233-4455-6677-8899-aabbccddeeff"),
ClientVersion: 0x55667788,
ClientTimestamp: 0x0102030405060708,
ClientDllVersion: string.Empty);
byte[] actual = HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6(
request,
commonInfo,
new Guid("00112233-4455-6677-8899-aabbccddeeff"));
byte[] expectedPrefix =
[
0x06,
0x33, 0x22, 0x11, 0x00,
0x55, 0x44,
0x77, 0x66,
0x88, 0x99, 0xAA, 0xBB,
0xCC, 0xDD, 0xEE, 0xFF,
0x00
];
Assert.Equal(expectedPrefix, actual[..expectedPrefix.Length]);
byte[] expectedContentPrefix =
[
0x01, 0x00, 0x00, 0x00, 0x48, 0x00,
0x02, 0x00, 0xAA, 0xBB,
0x04,
0x02, 0x04, 0x00, 0x00,
0x01,
0x01, 0x00, 0x00,
0x01, 0x00, 0x00,
0x01, 0x00, 0x00
];
Assert.Equal(expectedContentPrefix, actual[expectedPrefix.Length..(expectedPrefix.Length + expectedContentPrefix.Length)]);
}
[Fact]
public void NativeOpenConnection3Version6SerializerCanUseSeparateCredentialBlock()
{
HistorianOpen2Request request = new(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: string.Empty,
Password: [0xAA, 0xBB],
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 1026,
MetadataNamespace: HistorianMetadataNamespace.Empty);
HistorianClientCommonInfo commonInfo = new(
FormatVersion: 2,
ServerNodeName: string.Empty,
ClientNodeName: string.Empty,
ProcessId: 0,
HcalVersion: 17,
ProcessName: string.Empty,
Proxy: string.Empty,
DataSourceId: string.Empty,
ShardId: Guid.Empty,
ClientVersion: 0,
ClientTimestamp: 0,
ClientDllVersion: string.Empty);
byte[] actual = HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6(
request,
commonInfo,
Guid.Empty,
[0x00, 0x00, 0x00, 0x00]);
int hostLengthOffset = 18;
int credentialLengthOffset = hostLengthOffset + 4 + Encoding.Unicode.GetByteCount("H");
Assert.Equal([0x04, 0x00], actual[credentialLengthOffset..(credentialLengthOffset + 2)]);
Assert.Equal([0x00, 0x00, 0x00, 0x00], actual[(credentialLengthOffset + 2)..(credentialLengthOffset + 6)]);
}
}
@@ -0,0 +1,45 @@
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class WcfStatusProtocolTests
{
[Fact]
public void SystemTimeParserReadsWindowsSystemTimeLayout()
{
byte[] buffer =
[
0xEA, 0x07,
0x04, 0x00,
0x04, 0x00,
0x1E, 0x00,
0x0D, 0x00,
0x2A, 0x00,
0x07, 0x00,
0x7B, 0x00
];
DateTime? parsed = HistorianStatusProtocol.TryReadSystemTime(buffer);
Assert.Equal(new DateTime(2026, 4, 30, 13, 42, 7, 123), parsed);
}
[Fact]
public void SystemTimeParserRejectsShortAndInvalidBuffers()
{
Assert.Null(HistorianStatusProtocol.TryReadSystemTime([0xEA, 0x07]));
byte[] invalidMonth =
[
0xEA, 0x07,
0x00, 0x00,
0x04, 0x00,
0x1E, 0x00,
0x0D, 0x00,
0x2A, 0x00,
0x07, 0x00,
0x7B, 0x00
];
Assert.Null(HistorianStatusProtocol.TryReadSystemTime(invalidMonth));
}
}
@@ -0,0 +1,168 @@
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class WcfTagQueryProtocolTests
{
[Fact]
public void SerializerMatchesInstrumentedNativeTagQueryRequest()
{
HistorianTagQueryAttempt attempt = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(
"TagName eq 'OtOpcUaParityTest_001.Counter'");
byte[] expected = Convert.FromBase64String(
"UWcBACoAAABUAGEAZwBOAGEAbQBlACAAZQBxACAAJwBPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAJwA=");
Assert.Equal(expected, attempt.RequestBuffer);
Assert.Equal("af1dbcdd3eb0ad91a18882c22252aa74aff82998e96a39b63415ab4792a962ac", attempt.RequestSha256);
}
[Fact]
public void SerializerUsesDecompiledMarkerVersionAndUtf16Filter()
{
HistorianTagQueryAttempt attempt = HistorianTagQueryProtocol.CreateStartTagQueryAttempt("T*");
byte[] actual = attempt.RequestBuffer;
Assert.Equal("native-start-tag-query-version1", attempt.Name);
Assert.Equal(HistorianTagQueryProtocol.NativeStartTagQueryMarker, BitConverter.ToUInt16(actual, 0));
Assert.Equal(HistorianTagQueryProtocol.NativeStartTagQueryVersion, BitConverter.ToUInt16(actual, 2));
Assert.Equal(2u, BitConverter.ToUInt32(actual, 4));
Assert.Equal("T*", System.Text.Encoding.Unicode.GetString(actual, 8, actual.Length - 8));
}
[Fact]
public void SerializerMatchesInstrumentedNativeHeaderOnlyTagQueryRequest()
{
HistorianTagQueryAttempt attempt = HistorianTagQueryProtocol.CreateStartTagQueryHeaderOnlyAttempt();
Assert.Equal("native-start-tag-query-header-only", attempt.Name);
Assert.Equal(Convert.FromBase64String("UWcBAA=="), attempt.RequestBuffer);
Assert.Equal("17956e4fbe53d5edc0f9170203b013432e4afcc0591c795a10522a98d9fce926", attempt.RequestSha256);
}
[Fact]
public void ParsesInstrumentedNativeStartTagQueryResponse()
{
byte[] response = Convert.FromBase64String("CAAAAAEAAAA=");
HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(response);
Assert.Equal(8u, parsed.QueryHandle);
Assert.Equal(1u, parsed.TagCount);
}
[Fact]
public void ParsesInstrumentedNativeGetTagInfoResponse()
{
byte[] response = Convert.FromBase64String(
"AQAAAAPDADGEIoxAWOGHSphLPb7L4KpC7gAAAAkdAE90T3BjVWFQYXJpdHlUZXN0XzAwMS5Db3VudGVyCQQATURBUwIDAQIAAADQV/SUZdjcAQoAAAAAAAAAJEAAAAAAAAAkQP4AAAAAAA==");
IReadOnlyList<HistorianTagInfoResponse> tags = HistorianTagQueryProtocol.ParseGetTagInfoResponse(response);
HistorianTagInfoResponse tag = Assert.Single(tags);
Assert.Equal("OtOpcUaParityTest_001.Counter", tag.TagName);
Assert.Equal(238u, tag.TagKey);
Assert.Equal(new Guid("408c2284-e158-4a87-984b-3dbecbe0aa42"), tag.TypeId);
Assert.Equal([0x03, 0xC3, 0x00, 0x31], tag.NativeDataTypeDescriptor);
Assert.Equal("MDAS", tag.MetadataProvider);
Assert.Equal(2, tag.NativeTagClass);
Assert.Equal(3, tag.StorageType);
Assert.Equal(1, tag.DeadbandType);
Assert.Equal(2, tag.InterpolationType);
}
[Fact]
public void ParsesDirectWcfGetTagInfoFromNameResponse()
{
byte[] response = Convert.FromBase64String(
"A8MAMYQijEBY4YdKmEs9vsvgqkLuAAAACR0AT3RPcGNVYVBhcml0eVRlc3RfMDAxLkNvdW50ZXIJBABNREFTAgMBAgAAANBX9JRl2NwBCgAAAAAAAAAkQAAAAAAAACRA/gA=");
HistorianTagInfoResponse tag = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
Assert.Equal("OtOpcUaParityTest_001.Counter", tag.TagName);
Assert.Equal(238u, tag.TagKey);
Assert.Equal([0x03, 0xC3, 0x00, 0x31], tag.NativeDataTypeDescriptor);
Assert.Equal(Models.HistorianDataType.Int4, HistorianWcfTagClient.MapDataType(tag.NativeDataTypeDescriptor));
}
[Fact]
public void MapDataType_UInt2Descriptor_ReturnsUInt2()
{
// Built-in SysTimeSec exposes this descriptor (Runtime AnalogTag.IntegerSize=16,
// SignedInteger=0 → UInt16).
Assert.Equal(Models.HistorianDataType.UInt2, HistorianWcfTagClient.MapDataType([0x03, 0xCF, 0x04, 0x09]));
}
[Theory]
// Captured descriptors from TagMetadataDescriptorProbeTests against a live local Historian.
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x01 }, Models.HistorianDataType.Float)] // SysDataAcqOverallItemsPerSec (RawType=2, IntegerSize=0)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x02 }, Models.HistorianDataType.Int1)] // SysClassicDataRedirector (DiscreteTag)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x09 }, Models.HistorianDataType.UInt2)] // SysCritErrCnt (UInt16, StorageType=Delta)
[InlineData(new byte[] { 0x03, 0xCF, 0x04, 0x09 }, Models.HistorianDataType.UInt2)] // SysTimeSec (UInt16, StorageType=Cyclic)
[InlineData(new byte[] { 0x03, 0xCF, 0x04, 0x11 }, Models.HistorianDataType.UInt4)] // SysConfigStatus (UInt32)
[InlineData(new byte[] { 0x03, 0xC3, 0x00, 0x31 }, Models.HistorianDataType.Int4)] // OtOpcUaParityTest_001.Counter (Int32)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x43 }, Models.HistorianDataType.DoubleByteString)] // SysString
// Inferred from CDataType predicate IL (IsConvertableToDouble/Int64/UInt64, IsEvent, IsStruct, IsString):
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x03 }, Models.HistorianDataType.SingleByteString)] // string class without bit 0x40 (wide flag)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x04 }, Models.HistorianDataType.Event)] // IsEvent: low 3 bits == 4
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x05 }, Models.HistorianDataType.Structure)] // IsStruct: low 3 bits == 5
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x21 }, Models.HistorianDataType.Double)] // IsConvertableToDouble matches 33
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x29 }, Models.HistorianDataType.Int2)] // IsConvertableToInt64 matches 41 (= UInt16=0x09 + signed bit 0x20)
// Newly extended HistorianDataType enum entries (codes recovered from same predicate IL):
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x08 }, Models.HistorianDataType.UInt1)] // 1-byte unsigned (IsConvertableToUInt64 matches 8)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x10 }, Models.HistorianDataType.Guid)] // IsGuid: byte == 16
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x18 }, Models.HistorianDataType.FileTime)] // IsFileTime: byte == 24
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x19 }, Models.HistorianDataType.Int8)] // 8-byte signed (IsConvertableToInt64 matches 25)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x39 }, Models.HistorianDataType.UInt8)] // 8-byte unsigned (IsConvertableToUInt64 matches 57)
[InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x81 }, Models.HistorianDataType.Int1)] // Boolean extended (IsBoolean: byte == 129)
public void MapDataType_KnownDescriptors_ReturnsExpectedType(byte[] descriptor, Models.HistorianDataType expected)
{
Assert.Equal(expected, HistorianWcfTagClient.MapDataType(descriptor));
}
[Theory]
// Storage-attribute byte (byte 2) variants should still map to the same data type because
// the dispatch is on byte 3.
[InlineData(new byte[] { 0x03, 0xCF, 0x07, 0x09 }, Models.HistorianDataType.UInt2)]
[InlineData(new byte[] { 0x03, 0xCF, 0xFF, 0x11 }, Models.HistorianDataType.UInt4)]
public void MapDataType_StorageAttributeVariants_DispatchesByDataTypeCode(byte[] descriptor, Models.HistorianDataType expected)
{
Assert.Equal(expected, HistorianWcfTagClient.MapDataType(descriptor));
}
[Fact]
public void MapDataType_UnknownDataTypeCode_ThrowsEvidenceMissing()
{
// Byte 3 = 0xFF is not yet observed; should throw rather than guess.
Assert.Throws<ProtocolEvidenceMissingException>(() =>
HistorianWcfTagClient.MapDataType([0x03, 0xCF, 0x00, 0xFF]));
}
[Fact]
public void MapDataType_WrongFormatVersion_ThrowsEvidenceMissing()
{
// Byte 0 must be 0x03; anything else throws.
Assert.Throws<ProtocolEvidenceMissingException>(() =>
HistorianWcfTagClient.MapDataType([0x04, 0xCF, 0x00, 0x09]));
}
[Fact]
public void ParsesManagedWcfLikeTagNamesResponse()
{
byte[] response = Convert.FromBase64String(
"AQAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIA");
IReadOnlyList<string> tagNames = HistorianTagQueryProtocol.ParseGetLikeTagNamesResponse(response);
Assert.Equal(["OtOpcUaParityTest_001.Counter"], tagNames);
}
[Theory]
[InlineData("*", "%")]
[InlineData("Sys*", "Sys%")]
[InlineData("OtOpcUaParityTest%", "OtOpcUaParityTest%")]
public void NormalizesPublicWildcardToHistorianLikeWildcard(string filter, string expected)
{
Assert.Equal(expected, HistorianWcfTagClient.NormalizeLikeFilter(filter));
}
}