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,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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user