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,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.7" />
<PackageReference Include="System.ServiceModel.NetNamedPipe" Version="10.0.652802" />
<PackageReference Include="System.ServiceModel.NetTcp" Version="10.0.652802" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AVEVA.Historian.Client.Tests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AVEVA.Historian.ReverseEngineering</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -0,0 +1,137 @@
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Protocol;
using AVEVA.Historian.Client.Transport;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client;
public sealed class HistorianClient : IAsyncDisposable
{
private readonly HistorianClientOptions _options;
private readonly IHistorianTransportFactory _transportFactory;
private readonly Historian2020ProtocolDialect _protocol;
public HistorianClient(HistorianClientOptions options)
: this(options, TcpHistorianTransport.Factory)
{
}
internal HistorianClient(HistorianClientOptions options, IHistorianTransportFactory transportFactory)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_transportFactory = transportFactory ?? throw new ArgumentNullException(nameof(transportFactory));
_protocol = new Historian2020ProtocolDialect(_options);
}
public async Task<bool> ProbeAsync(CancellationToken cancellationToken = default)
{
return await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
}
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
int maxValues,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxValues);
ValidateTimeRange(startUtc, endUtc);
return _protocol.ReadRawAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
}
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
RetrievalMode mode,
TimeSpan interval,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(interval, TimeSpan.Zero);
ValidateTimeRange(startUtc, endUtc);
return _protocol.ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
}
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
string tag,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
ArgumentNullException.ThrowIfNull(timestampsUtc);
if (timestampsUtc.Count == 0)
{
return Task.FromResult<IReadOnlyList<HistorianSample>>(Array.Empty<HistorianSample>());
}
return _protocol.ReadAtTimeAsync(tag, timestampsUtc, cancellationToken);
}
public IAsyncEnumerable<HistorianBlock> ReadBlocksAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
ValidateTimeRange(startUtc, endUtc);
return _protocol.ReadBlocksAsync(tag, startUtc, endUtc, cancellationToken);
}
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
DateTime startUtc,
DateTime endUtc,
CancellationToken cancellationToken = default)
{
ValidateTimeRange(startUtc, endUtc);
return _protocol.ReadEventsAsync(startUtc, endUtc, cancellationToken);
}
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
return HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken);
}
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
return HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
}
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default)
{
return _protocol.GetConnectionStatusAsync(cancellationToken);
}
public Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(CancellationToken cancellationToken = default)
{
return _protocol.GetStoreForwardStatusAsync(cancellationToken);
}
public Task<string?> GetSystemParameterAsync(string name, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return _protocol.GetSystemParameterAsync(name, cancellationToken);
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
private static void ValidateTimeRange(DateTime startUtc, DateTime endUtc)
{
if (startUtc.ToUniversalTime() > endUtc.ToUniversalTime())
{
throw new ArgumentException("Start time must be less than or equal to end time.");
}
}
}
@@ -0,0 +1,30 @@
using AVEVA.Historian.Client.Models;
namespace AVEVA.Historian.Client;
public sealed class HistorianClientOptions
{
public const int DefaultPort = 32568;
public required string Host { get; init; }
public int Port { get; init; } = DefaultPort;
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30);
public string UserName { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public bool IntegratedSecurity { get; init; }
public bool Compression { get; init; }
public HistorianConnectionKind ConnectionKind { get; init; } = HistorianConnectionKind.Process;
public HistorianTransport Transport { get; init; } = HistorianTransport.LocalPipe;
public string TargetSpn { get; init; } = @"NT SERVICE\aahClientAccessPoint";
}
@@ -0,0 +1,8 @@
namespace AVEVA.Historian.Client;
public enum HistorianTransport
{
LocalPipe = 0,
RemoteTcpIntegrated = 1,
RemoteTcpCertificate = 2
}
@@ -0,0 +1,15 @@
namespace AVEVA.Historian.Client.Models;
public enum AggregationType
{
Minimum,
Maximum,
Average,
Total,
Percent,
MinContained,
MaxContained,
TotalContained,
AverageContained,
PercentContained
}
@@ -0,0 +1,12 @@
namespace AVEVA.Historian.Client.Models;
public sealed record HistorianAggregateSample(
string TagName,
DateTime StartTimeUtc,
DateTime EndTimeUtc,
double Value,
ushort Quality,
uint QualityDetail,
ushort OpcQuality,
RetrievalMode RetrievalMode,
TimeSpan Resolution);
@@ -0,0 +1,7 @@
namespace AVEVA.Historian.Client.Models;
public sealed record HistorianBlock(
string TagName,
DateTime StartTimeUtc,
DateTime EndTimeUtc,
IReadOnlyList<HistorianSample> Samples);
@@ -0,0 +1,10 @@
using System;
namespace AVEVA.Historian.Client.Models;
[Flags]
public enum HistorianConnectionKind
{
Process = 1,
Event = 2
}
@@ -0,0 +1,11 @@
namespace AVEVA.Historian.Client.Models;
public sealed record HistorianConnectionStatus(
string ServerName,
bool Pending,
bool ErrorOccurred,
string? Error,
bool ConnectedToServer,
bool ConnectedToServerStorage,
bool ConnectedToStoreForward,
HistorianConnectionKind ConnectionKind);
@@ -0,0 +1,37 @@
namespace AVEVA.Historian.Client.Models;
/// <summary>
/// AVEVA Historian native tag data types. Existing values (0..10, 13) match the
/// numeric layout the wrapper has historically used; new values (14+) extend the
/// model with types recovered from the native CDataType predicate IL — they aren't
/// part of the original wrapper enum but cover the full native type space.
/// </summary>
public enum HistorianDataType
{
Int1 = 0,
Int2 = 2,
UInt2 = 3,
Int4 = 4,
UInt4 = 5,
Float = 6,
Double = 7,
SingleByteString = 8,
DoubleByteString = 9,
Event = 10,
Structure = 13,
/// <summary>1-byte unsigned integer (native code 0x08).</summary>
UInt1 = 14,
/// <summary>8-byte signed integer (native code 0x19).</summary>
Int8 = 15,
/// <summary>8-byte unsigned integer (native code 0x39).</summary>
UInt8 = 16,
/// <summary>16-byte GUID (native code 0x10, matches CDataType.IsGuid).</summary>
Guid = 17,
/// <summary>Windows FILETIME (8 bytes, 100-ns ticks since 1601-01-01 UTC; native code 0x18, matches CDataType.IsFileTime).</summary>
FileTime = 18
}
@@ -0,0 +1,9 @@
namespace AVEVA.Historian.Client.Models;
public sealed record HistorianDataValue(
string TagName,
DateTime TimestampUtc,
double? NumericValue,
string? StringValue,
ushort Quality = 192,
uint QualityDetail = 0);
@@ -0,0 +1,11 @@
namespace AVEVA.Historian.Client.Models;
public sealed record HistorianEvent(
Guid Id,
DateTime EventTimeUtc,
DateTime ReceivedTimeUtc,
string Type,
string SourceName,
string Namespace,
ushort RevisionVersion,
IReadOnlyDictionary<string, object?> Properties);
@@ -0,0 +1,11 @@
namespace AVEVA.Historian.Client.Models;
public sealed record HistorianSample(
string TagName,
DateTime TimestampUtc,
double? NumericValue,
string? StringValue,
ushort Quality,
uint QualityDetail,
ushort OpcQuality,
double PercentGood);
@@ -0,0 +1,10 @@
namespace AVEVA.Historian.Client.Models;
public sealed record HistorianStoreForwardStatus(
string ServerName,
bool Pending,
bool ErrorOccurred,
string? Error,
bool DataStored,
bool Storing,
HistorianConnectionKind ConnectionKind);
@@ -0,0 +1,10 @@
namespace AVEVA.Historian.Client.Models;
public sealed record HistorianTagMetadata(
string Name,
uint? Key,
HistorianDataType DataType,
string? Description = null,
string? EngineeringUnit = null,
double? MinRaw = null,
double? MaxRaw = null);
@@ -0,0 +1,9 @@
namespace AVEVA.Historian.Client.Models;
public enum InterpolationType
{
StairStep = 0,
Linear = 1,
SystemDefault = 254,
None = 255
}
@@ -0,0 +1,9 @@
namespace AVEVA.Historian.Client.Models;
public enum QualityRule
{
Extended,
Good,
None,
Optimistic
}
@@ -0,0 +1,20 @@
namespace AVEVA.Historian.Client.Models;
public enum RetrievalMode
{
Cyclic,
Delta,
Full,
Interpolated,
BestFit,
TimeWeightedAverage,
MinimumWithTime,
MaximumWithTime,
Integral,
Slope,
Counter,
ValueState,
RoundTrip,
StartBound,
EndBound
}
@@ -0,0 +1,8 @@
namespace AVEVA.Historian.Client.Models;
public enum TimestampRule
{
Start,
End,
None
}
@@ -0,0 +1,13 @@
namespace AVEVA.Historian.Client.Models;
public enum ValueSelector
{
Auto = 1,
First,
Last,
Integral,
StandardDeviation,
Minimum,
Maximum,
Average
}
@@ -0,0 +1,9 @@
namespace AVEVA.Historian.Client.Protocol;
internal sealed class FrameFormatException : Exception
{
public FrameFormatException(string message)
: base(message)
{
}
}
@@ -0,0 +1,163 @@
using System.Runtime.InteropServices;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Protocol;
internal sealed class Historian2020ProtocolDialect
{
private readonly HistorianClientOptions _options;
public Historian2020ProtocolDialect(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken)
{
if (_options.Transport != HistorianTransport.LocalPipe)
{
return Missing<HistorianSample>($"StartDataRetrievalQuery/Full over {_options.Transport}", cancellationToken);
}
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return Missing<HistorianSample>("StartDataRetrievalQuery/Full requires Windows for the LocalPipe + SSPI path", cancellationToken);
}
return ReadRawWindowsAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
}
private IAsyncEnumerable<HistorianSample> ReadRawWindowsAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken)
{
#pragma warning disable CA1416 // Validated by RuntimeInformation.IsOSPlatform check above.
HistorianWcfReadOrchestrator orchestrator = new(_options);
return orchestrator.ReadRawAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
#pragma warning restore CA1416
}
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken)
{
if (_options.Transport != HistorianTransport.LocalPipe)
{
return Missing<HistorianAggregateSample>($"StartDataRetrievalQuery/{mode} over {_options.Transport}", cancellationToken);
}
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return Missing<HistorianAggregateSample>($"StartDataRetrievalQuery/{mode} requires Windows for the LocalPipe + SSPI path", cancellationToken);
}
return ReadAggregateWindowsAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
}
private IAsyncEnumerable<HistorianAggregateSample> ReadAggregateWindowsAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken)
{
#pragma warning disable CA1416
HistorianWcfReadOrchestrator orchestrator = new(_options);
return orchestrator.ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
#pragma warning restore CA1416
}
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_options.Transport != HistorianTransport.LocalPipe)
{
throw new ProtocolEvidenceMissingException($"StartDataRetrievalQuery/Interpolated at-time over {_options.Transport}");
}
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
throw new ProtocolEvidenceMissingException("StartDataRetrievalQuery/Interpolated at-time requires Windows for the LocalPipe + SSPI path");
}
#pragma warning disable CA1416
HistorianWcfReadOrchestrator orchestrator = new(_options);
return orchestrator.ReadAtTimeAsync(tag, timestampsUtc, cancellationToken);
#pragma warning restore CA1416
}
public IAsyncEnumerable<HistorianBlock> ReadBlocksAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
{
return Missing<HistorianBlock>("StartBlockRetrievalQuery", cancellationToken);
}
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
{
if (_options.Transport != HistorianTransport.LocalPipe)
{
return Missing<HistorianEvent>($"StartEventDataRetrievalQuery over {_options.Transport}", cancellationToken);
}
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return Missing<HistorianEvent>("StartEventDataRetrievalQuery requires Windows for the LocalPipe + SSPI path", cancellationToken);
}
return ReadEventsWindowsAsync(startUtc, endUtc, cancellationToken);
}
private IAsyncEnumerable<HistorianEvent> ReadEventsWindowsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
{
#pragma warning disable CA1416
HistorianWcfEventOrchestrator orchestrator = new(_options);
return orchestrator.ReadEventsAsync(startUtc, endUtc, cancellationToken);
#pragma warning restore CA1416
}
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter, CancellationToken cancellationToken)
{
return Missing<string>("StartLikeTagNameSearch/GetLikeTagnames", cancellationToken);
}
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
throw new ProtocolEvidenceMissingException("GetTagInfoByName/GetTagInfos");
}
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!OperatingSystem.IsWindows())
{
throw new ProtocolEvidenceMissingException("GetConnectionStatus on non-Windows");
}
return Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken);
}
public Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!OperatingSystem.IsWindows())
{
throw new ProtocolEvidenceMissingException("GetStoreForwardStatus on non-Windows");
}
return Wcf.HistorianWcfStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken);
}
public Task<string?> GetSystemParameterAsync(string name, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(name);
if (!OperatingSystem.IsWindows())
{
throw new ProtocolEvidenceMissingException("GetSystemParameter on non-Windows");
}
return Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
}
private static async IAsyncEnumerable<T> Missing<T>(
string operation,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
throw new ProtocolEvidenceMissingException(operation);
#pragma warning disable CS0162
yield break;
#pragma warning restore CS0162
}
}
@@ -0,0 +1,50 @@
using System.Buffers.Binary;
using System.Text;
namespace AVEVA.Historian.Client.Protocol;
internal static class HistorianBinaryPrimitives
{
public static long ToFileTimeUtc(DateTime value)
{
return value.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(value, DateTimeKind.Utc).ToFileTimeUtc()
: value.ToUniversalTime().ToFileTimeUtc();
}
public static void WriteUInt16LittleEndian(Stream stream, ushort value)
{
Span<byte> buffer = stackalloc byte[sizeof(ushort)];
BinaryPrimitives.WriteUInt16LittleEndian(buffer, value);
stream.Write(buffer);
}
public static void WriteUInt32LittleEndian(Stream stream, uint value)
{
Span<byte> buffer = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
stream.Write(buffer);
}
public static void WriteUInt64LittleEndian(Stream stream, ulong value)
{
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
stream.Write(buffer);
}
public static void WriteFileTimeUtc(Stream stream, DateTime value)
{
WriteUInt64LittleEndian(stream, unchecked((ulong)ToFileTimeUtc(value)));
}
public static void WriteUtf16NullTerminated(Stream stream, string value)
{
ArgumentNullException.ThrowIfNull(value);
byte[] bytes = Encoding.Unicode.GetBytes(value);
stream.Write(bytes);
stream.WriteByte(0);
stream.WriteByte(0);
}
}
@@ -0,0 +1,75 @@
using AVEVA.Historian.Client.Transport;
namespace AVEVA.Historian.Client.Protocol;
internal sealed class HistorianConnection : IAsyncDisposable
{
private readonly HistorianClientOptions _options;
private readonly IHistorianTransport _transport;
public HistorianConnection(HistorianClientOptions options, IHistorianTransport transport)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
}
public async ValueTask ConnectTcpAsync(CancellationToken cancellationToken)
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_options.ConnectTimeout);
await _transport.ConnectAsync(_options, timeout.Token).ConfigureAwait(false);
}
public ValueTask OpenProtocolSessionAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
throw new ProtocolEvidenceMissingException("OpenConnection handshake");
}
public async ValueTask SendFrameAsync(HistorianFrame frame, CancellationToken cancellationToken)
{
byte[] buffer = HistorianFrameWriter.ToArray(frame);
await _transport.SendAsync(buffer, cancellationToken).ConfigureAwait(false);
}
public async ValueTask<HistorianFrame> ReceiveFrameAsync(CancellationToken cancellationToken)
{
using MemoryStream frameBytes = new();
byte[] header = new byte[HistorianFrameReader.HeaderSize];
await ReadTransportExactlyAsync(header, cancellationToken).ConfigureAwait(false);
frameBytes.Write(header);
int frameLength = BitConverter.ToInt32(header, 0);
if (frameLength < HistorianFrameReader.HeaderSize || frameLength > HistorianFrameReader.MaxFrameSize)
{
throw new FrameFormatException($"Invalid frame length {frameLength}.");
}
byte[] payload = new byte[frameLength - HistorianFrameReader.HeaderSize];
await ReadTransportExactlyAsync(payload, cancellationToken).ConfigureAwait(false);
frameBytes.Write(payload);
frameBytes.Position = 0;
return await HistorianFrameReader.ReadAsync(frameBytes, cancellationToken).ConfigureAwait(false);
}
public ValueTask DisposeAsync()
{
return _transport.DisposeAsync();
}
private async ValueTask ReadTransportExactlyAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
int offset = 0;
while (offset < buffer.Length)
{
int read = await _transport.ReceiveAsync(buffer[offset..], cancellationToken).ConfigureAwait(false);
if (read == 0)
{
throw new EndOfStreamException("Unexpected end of stream from Historian transport.");
}
offset += read;
}
}
}
@@ -0,0 +1,6 @@
namespace AVEVA.Historian.Client.Protocol;
internal readonly record struct HistorianFrame(
HistorianMessageType MessageType,
uint CorrelationId,
ReadOnlyMemory<byte> Payload);
@@ -0,0 +1,45 @@
using System.Buffers.Binary;
namespace AVEVA.Historian.Client.Protocol;
internal static class HistorianFrameReader
{
public const int HeaderSize = 10;
public const int MaxFrameSize = 16 * 1024 * 1024;
public static async ValueTask<HistorianFrame> ReadAsync(Stream stream, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(stream);
byte[] header = new byte[HeaderSize];
await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false);
int frameLength = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(0, 4));
if (frameLength < HeaderSize || frameLength > MaxFrameSize)
{
throw new FrameFormatException($"Invalid frame length {frameLength}.");
}
ushort messageType = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(4, 2));
uint correlationId = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(6, 4));
byte[] payload = new byte[frameLength - HeaderSize];
await ReadExactlyAsync(stream, payload, cancellationToken).ConfigureAwait(false);
return new HistorianFrame((HistorianMessageType)messageType, correlationId, payload);
}
private static async ValueTask ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken cancellationToken)
{
int offset = 0;
while (offset < buffer.Length)
{
int read = await stream.ReadAsync(buffer[offset..], cancellationToken).ConfigureAwait(false);
if (read == 0)
{
throw new EndOfStreamException("Unexpected end of stream while reading Historian frame.");
}
offset += read;
}
}
}
@@ -0,0 +1,27 @@
using System.Buffers.Binary;
namespace AVEVA.Historian.Client.Protocol;
internal static class HistorianFrameWriter
{
public static void Write(Stream stream, HistorianFrame frame)
{
ArgumentNullException.ThrowIfNull(stream);
int frameLength = HistorianFrameReader.HeaderSize + frame.Payload.Length;
Span<byte> header = stackalloc byte[HistorianFrameReader.HeaderSize];
BinaryPrimitives.WriteInt32LittleEndian(header[0..4], frameLength);
BinaryPrimitives.WriteUInt16LittleEndian(header[4..6], (ushort)frame.MessageType);
BinaryPrimitives.WriteUInt32LittleEndian(header[6..10], frame.CorrelationId);
stream.Write(header);
stream.Write(frame.Payload.Span);
}
public static byte[] ToArray(HistorianFrame frame)
{
using MemoryStream stream = new();
Write(stream, frame);
return stream.ToArray();
}
}
@@ -0,0 +1,6 @@
namespace AVEVA.Historian.Client.Protocol;
internal enum HistorianMessageType : ushort
{
Unknown = 0
}
@@ -0,0 +1,9 @@
namespace AVEVA.Historian.Client.Protocol;
internal static class HistorianProtocolFacts
{
public const int DefaultTcpPort = 32568;
public const int DataQueryResultRowSizeBytes = 544;
public const int EventQueryFiltersSizeBytes = 72;
public const string QueryTimezone = "UTC";
}
@@ -0,0 +1,12 @@
namespace AVEVA.Historian.Client;
public sealed class ProtocolEvidenceMissingException : NotSupportedException
{
public ProtocolEvidenceMissingException(string operation)
: base($"Protocol evidence for '{operation}' has not been captured yet. Add sanitized fixtures before enabling this operation.")
{
Operation = operation;
}
public string Operation { get; }
}
@@ -0,0 +1,9 @@
namespace AVEVA.Historian.Client;
public sealed class ProtocolNotImplementedException : NotImplementedException
{
public ProtocolNotImplementedException(string message)
: base(message)
{
}
}
@@ -0,0 +1,10 @@
namespace AVEVA.Historian.Client.Transport;
internal interface IHistorianTransport : IAsyncDisposable
{
ValueTask ConnectAsync(HistorianClientOptions options, CancellationToken cancellationToken);
ValueTask SendAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken);
ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken);
}
@@ -0,0 +1,6 @@
namespace AVEVA.Historian.Client.Transport;
internal interface IHistorianTransportFactory
{
IHistorianTransport Create();
}
@@ -0,0 +1,55 @@
using System.Net.Sockets;
namespace AVEVA.Historian.Client.Transport;
internal sealed class TcpHistorianTransport : IHistorianTransport
{
public static readonly IHistorianTransportFactory Factory = new FactoryImpl();
private TcpClient? _client;
private NetworkStream? _stream;
public async ValueTask ConnectAsync(HistorianClientOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
_client = new TcpClient();
await _client.ConnectAsync(options.Host, options.Port, cancellationToken).ConfigureAwait(false);
_stream = _client.GetStream();
}
public async ValueTask SendAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
{
if (_stream is null)
{
throw new InvalidOperationException("Transport is not connected.");
}
await _stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
}
public async ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
if (_stream is null)
{
throw new InvalidOperationException("Transport is not connected.");
}
return await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
}
public ValueTask DisposeAsync()
{
_stream?.Dispose();
_client?.Dispose();
return ValueTask.CompletedTask;
}
private sealed class FactoryImpl : IHistorianTransportFactory
{
public IHistorianTransport Create()
{
return new TcpHistorianTransport();
}
}
}
@@ -0,0 +1,79 @@
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.History, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IHistoryServiceContract
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract(Name = "Open")]
uint OpenConnection(
string HostName,
string ProcessName,
uint ProcessId,
string UserName,
byte[] Password,
[MessageParameter(Name = "pwdLength")] ushort passwordLength,
byte clientType,
ushort clientVersion,
[MessageParameter(Name = "ConnectionMode")] uint connectionMode,
[MessageParameter(Name = "ConnectionTimeout")] uint connectionTimeout,
ref string StorageSessionId,
out uint Handle,
out long ConnectTime,
out uint ServerStatus);
[OperationContract(Name = "Close")]
uint CloseConnection([MessageParameter(Name = "handle")] uint clientHandle);
[OperationContract(Name = "VldC")]
uint ValidateClient(
[MessageParameter(Name = "Handle")] uint handle,
[MessageParameter(Name = "HostName")] string hostName,
[MessageParameter(Name = "ProcessName")] string processName,
[MessageParameter(Name = "ProcessId")] uint processId,
[MessageParameter(Name = "UserName")] string userName,
[MessageParameter(Name = "ConnectTime")] ref long connectTime,
[MessageParameter(Name = "ServerStatus")] out uint serverStatus);
[OperationContract(Name = "UpdC")]
uint UpdateClientStatus(
[MessageParameter(Name = "Hnd")] uint handle,
[MessageParameter(Name = "Stat")] uint status,
[MessageParameter(Name = "TCnt")] uint tagCount,
[MessageParameter(Name = "VCnt")] long valueCount,
[MessageParameter(Name = "VRate")] float valueRate,
[MessageParameter(Name = "SStat")] out uint serverStatus);
[OperationContract(Name = "AddT")]
uint AddTags(
[MessageParameter(Name = "Handle")] uint handle,
[MessageParameter(Name = "ElementCount")] uint elementCount,
[MessageParameter(Name = "InByteCount")] uint inByteCount,
[MessageParameter(Name = "pInBuff")] byte[] inputBuffer,
[MessageParameter(Name = "OutByteCount")] out uint outByteCount,
[MessageParameter(Name = "pOutBuff")] out byte[] outputBuffer);
[OperationContract(Name = "RTag")]
uint RegisterTags(
[MessageParameter(Name = "Handle")] uint handle,
[MessageParameter(Name = "ElementCount")] uint elementCount,
[MessageParameter(Name = "InByteCount")] uint inByteCount,
[MessageParameter(Name = "pInBuff")] byte[] inputBuffer,
[MessageParameter(Name = "OutByteCount")] out uint outByteCount,
[MessageParameter(Name = "pOutBuff")] out byte[] outputBuffer);
[OperationContract(Name = "AddS")]
uint AddStreamValues(
[MessageParameter(Name = "Handle")] uint handle,
[MessageParameter(Name = "Size")] uint size,
[MessageParameter(Name = "pBuf")] byte[] buffer);
[OperationContract(Name = "SetT")]
uint SetClientTimeOut(
[MessageParameter(Name = "Handle")] uint handle,
[MessageParameter(Name = "TimeOut")] int timeout,
[MessageParameter(Name = "pRet")] out uint returnValue);
}
@@ -0,0 +1,140 @@
using System.Runtime.InteropServices;
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.History, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IHistoryServiceContract2 : IHistoryServiceContract
{
[OperationContract(Name = "UpdC2")]
uint UpdateClientStatus2(uint handle, uint clientStatus, uint tagCount, long valueCount, float valueRate, out long areaVersion, out uint serverStatus);
[OperationContract(Name = "EnsT")]
uint EnsureTags(
[MessageParameter(Name = "Handle")] uint handle,
uint elementCount,
[MessageParameter(Name = "InByteCount")] uint inByteCount,
[MessageParameter(Name = "InBuff")] byte[] inBuffer,
[MessageParameter(Name = "OutByteCount")] out uint outByteCount,
[MessageParameter(Name = "OutBuff")] out byte[] outBuffer);
[OperationContract(Name = "DelT")]
[return: MarshalAs(UnmanagedType.U1)]
bool DeleteTags(
uint handle,
uint tagNamesSize,
byte[] tagNames,
ref uint statusSize,
ref byte[] status,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract(Name = "UpdC3")]
[return: MarshalAs(UnmanagedType.U1)]
bool UpdateClientStatus3(
string handle,
uint clientStatusSize,
ref byte[] clientStatus,
out uint serverStatusSize,
out byte[] serverStatus,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract(Name = "Open2")]
[return: MarshalAs(UnmanagedType.U1)]
bool OpenConnection2(
[MessageParameter(Name = "inParameters")] ref byte[] inParameters,
[MessageParameter(Name = "outParameters")] out byte[] outParameters,
[MessageParameter(Name = "err")] out byte[] err);
[OperationContract(Name = "Close2")]
[return: MarshalAs(UnmanagedType.U1)]
bool CloseConnection2(string handle, out byte[] errorBuffer);
[OperationContract(Name = "VldC2")]
[return: MarshalAs(UnmanagedType.U1)]
bool ValidateClient2(
string handle,
[MessageParameter(Name = "HostName")] string hostName,
[MessageParameter(Name = "ProcessName")] string processName,
[MessageParameter(Name = "ProcessId")] uint processId,
[MessageParameter(Name = "UserName")] string userName,
[MessageParameter(Name = "ConnectTime")] ref long connectTime,
[MessageParameter(Name = "ServerStatus")] out uint serverStatus,
out byte[] errorBuffer);
[OperationContract(Name = "RTag2")]
[return: MarshalAs(UnmanagedType.U1)]
bool RegisterTags2(
string handle,
[MessageParameter(Name = "ElementCount")] uint elementCount,
[MessageParameter(Name = "pInBuff")] byte[] inputBuffer,
[MessageParameter(Name = "outBuff")] out byte[] outputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "AddS2")]
[return: MarshalAs(UnmanagedType.U1)]
bool AddStreamValues2(
string handle,
[MessageParameter(Name = "pBuf")] byte[] buffer,
out byte[] errorBuffer);
[OperationContract(Name = "EnsT2")]
[return: MarshalAs(UnmanagedType.U1)]
bool EnsureTags2(
[MessageParameter(Name = "Handle")] string handle,
uint elementCount,
[MessageParameter(Name = "InBuff")] byte[] inputBuffer,
[MessageParameter(Name = "OutBuff")] out byte[] outputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "ExKey")]
[return: MarshalAs(UnmanagedType.U1)]
bool ExchangeKey(
string handle,
[MessageParameter(Name = "inBuff")] byte[] inputBuffer,
[MessageParameter(Name = "OutBuff")] out byte[] outputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "AddTEx")]
[return: MarshalAs(UnmanagedType.U1)]
bool AddTagExtendedProperties(
string handle,
[MessageParameter(Name = "inBuff")] byte[] inputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "DelTep")]
[return: MarshalAs(UnmanagedType.U1)]
bool DeleteTagExtendedProperties(
string handle,
[MessageParameter(Name = "inBuff")] byte[] inputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "StJb")]
[return: MarshalAs(UnmanagedType.U1)]
bool StartJob(
string handle,
byte[] jobBuffer,
[MessageParameter(Name = "strJobid")] out string jobId,
out byte[] errorBuffer);
[OperationContract(Name = "GtJb")]
[return: MarshalAs(UnmanagedType.U1)]
bool GetJobStatus(
string handle,
[MessageParameter(Name = "strJobid")] string jobId,
[MessageParameter(Name = "jobstatus")] out byte[] jobStatus,
out byte[] errorBuffer);
[OperationContract(Name = "ValCl")]
[return: MarshalAs(UnmanagedType.U1)]
bool ValidateClientCredential(
string handle,
[MessageParameter(Name = "inBuff")] byte[] inputBuffer,
[MessageParameter(Name = "outBuff")] out byte[] outputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "GetI")]
[return: MarshalAs(UnmanagedType.U1)]
bool GetInfo(string request, out byte[] info, out byte[] errorBuffer);
}
@@ -0,0 +1,57 @@
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
internal enum InsqlTagType
{
All = 0
}
[ServiceContract(Name = HistorianWcfServiceNames.Retrieval, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IRetrievalServiceContract
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract]
uint StartQuery(
uint clientHandle,
ushort queryRequestType,
uint requestSize,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
out uint responseSize,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
ref uint queryHandle);
[OperationContract]
uint GetNextQueryResultBuffer(
uint clientHandle,
uint queryHandle,
out uint resultSize,
[MessageParameter(Name = "pResultBuff")] out byte[] resultBuffer,
out uint errorCode);
[OperationContract]
uint EndQuery(uint clientHandle, uint queryHandle);
[OperationContract]
uint GetTagTypeFromName(uint clientHandle, string tagName, out uint tagType);
[OperationContract]
uint IsOriginalAllowed(uint clientHandle, out bool isAllowed);
[OperationContract]
uint IsManualTag(uint clientHandle, string tagName, out bool isManual);
[OperationContract]
uint IsTagnameValid(uint clientHandle, string tagName, bool isWide, InsqlTagType tagType, out bool isValid);
[OperationContract]
uint StartLikeTagNameSearch(uint clientHandle, string tagNameFilter, uint tagType, bool isNotLike);
[OperationContract]
uint GetLikeTagnames(uint clientHandle, out byte[] tagNameBuffer, out uint tagNameBufferSize, out bool isMore);
[OperationContract]
uint GetTagInfoFromName(uint clientHandle, string tagName, out uint tagMetadataByteCount, out byte[] tagMetadata);
}
@@ -0,0 +1,41 @@
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Retrieval, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IRetrievalServiceContract2 : IRetrievalServiceContract
{
[OperationContract(Name = "GetTg")]
uint GetTagInfosFromId(uint handle, uint tagIdsSize, byte[] tagIds, ref uint sequence, out uint tagInfosSize, out byte[] tagInfos);
[OperationContract(Name = "GetTgByNm")]
uint GetTagInfosFromName(uint handle, uint tagNamesSize, byte[] tagNames, ref uint sequence, out uint tagInfosSize, out byte[] tagInfos);
[OperationContract]
bool StartQuery2(
uint clientHandle,
ushort queryRequestType,
uint requestSize,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
out uint responseSize,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
ref uint queryHandle,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract]
bool GetNextQueryResultBuffer2(
uint clientHandle,
uint queryHandle,
out uint resultSize,
[MessageParameter(Name = "pResultBuff")] out byte[] resultBuffer,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract]
bool EndQuery2(
uint clientHandle,
uint queryHandle,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
}
@@ -0,0 +1,45 @@
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Retrieval, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IRetrievalServiceContract3 : IRetrievalServiceContract2
{
[OperationContract(Name = "ExeC")]
bool ExecuteSqlCommand(
string handle,
string command,
uint option,
ref uint queryHandle,
[MessageParameter(Name = "retValue")] out int returnValue,
out uint errorSize,
out byte[] errorBuffer);
[OperationContract(Name = "GetR")]
bool GetRecordSetByteStream(
string handle,
uint queryHandle,
ref uint sequence,
out uint resultSize,
[MessageParameter(Name = "pResultBuff")] out byte[] resultBuffer,
out uint errorSize,
out byte[] errorBuffer);
[OperationContract(Name = "QTB")]
bool StartTagQuery(
string handle,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "QTG")]
bool QueryTag(
string handle,
ref uint queryId,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "QTE")]
bool EndTagQuery(string handle, ref uint queryId, out byte[] errorBuffer);
}
@@ -0,0 +1,51 @@
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Retrieval, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IRetrievalServiceContract4 : IRetrievalServiceContract3
{
[OperationContract]
bool StartEventQuery(
uint clientHandle,
ushort queryRequestType,
uint requestSize,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
out uint responseSize,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
ref uint queryHandle,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract]
bool GetNextEventQueryResultBuffer(
uint clientHandle,
uint queryHandle,
out uint resultSize,
[MessageParameter(Name = "pResultBuff")] out byte[] resultBuffer,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract]
bool EndEventQuery(
uint clientHandle,
uint queryHandle,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract]
bool GetTagidsByTagnameAndSource(string handle, byte[] tagNameIds, out byte[] tagIds, out byte[] errorBuffer);
[OperationContract]
bool GetShardTagidsByTagnameAndSource(
string handle,
byte[] tagNameIds,
[MessageParameter(Name = "shardTagids")] out byte[] shardTagIds,
out byte[] errorBuffer);
[OperationContract(Name = "GetTgByNm2")]
bool GetTagInfosFromName2(string handle, byte[] tagNames, ref uint sequence, out byte[] tagInfos, out byte[] errorBuffer);
[OperationContract(Name = "GetTepByNm")]
bool GetTagExtendedPropertiesFromName(string handle, byte[] tagNames, ref uint sequence, out byte[] tagExtendedProperties, out byte[] errorBuffer);
}
@@ -0,0 +1,37 @@
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Status, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IStatusServiceContract
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract]
uint GetServerTime(out byte[] systemTime, out uint systemTimeSize);
[OperationContract]
uint LogError(
uint clientHandle,
int errorLevel,
int destination,
int queueTime,
int errorCode,
int lineNumber,
int hasParam,
int moduleId,
int systemError,
string hostName,
string file,
string stringParameter);
[OperationContract]
uint GetTimeZoneInfo(uint handle, string timeZoneName, out bool isDaylight, out byte[] timeZoneInfo);
[OperationContract]
uint IsDBCaseSensitive(uint handle, out bool isCaseSensitive);
[OperationContract]
uint GetSystemTimeZoneName(uint clientHandle, out string systemTimeZoneName);
}
@@ -0,0 +1,39 @@
using System.Runtime.InteropServices;
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Status, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IStatusServiceContract2 : IStatusServiceContract
{
[OperationContract]
uint GetTimeZoneNames(uint clientHandle, ref uint sequence, out uint bufferSize, out byte[] buffer);
[OperationContract]
uint IsLicenseFeatureEnabled(uint clientHandle, int feature, out bool isEnabled);
[OperationContract]
[return: MarshalAs(UnmanagedType.U1)]
bool GetSystemParameter(
uint clientHandle,
string parameterName,
out string parameterValue,
out uint errorSize,
out byte[] errorBuffer);
[OperationContract(Name = "GETHI")]
[return: MarshalAs(UnmanagedType.U1)]
bool GetHistorianInfo(
string handle,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "PNGS")]
[return: MarshalAs(UnmanagedType.U1)]
bool PingServer(string handle, string pipeName, uint timeout, ref byte[] errorBuffer);
[OperationContract(Name = "PNGP")]
[return: MarshalAs(UnmanagedType.U1)]
bool PingPipe(string handle, string pipeName, ref byte[] errorBuffer);
}
@@ -0,0 +1,129 @@
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Storage, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IStorageServiceContract
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract(Name = "Open")]
uint OpenStorageConnection(
string hostName,
string enginePath,
uint freeDiskSpace,
string processName,
uint processId,
string userName,
byte[] password,
ushort passwordLength,
byte clientType,
ushort clientVersion,
uint connectionMode,
uint connectionTimeout,
ref string storageSessionId,
out uint handle,
out long connectTime,
out uint storageStatus);
[OperationContract(Name = "Close")]
uint CloseStorageConnection(uint handle);
[OperationContract(Name = "Ping")]
uint Ping(uint handle, out uint outByteCount, out byte[] outputBuffer);
[OperationContract(Name = "AddT")]
uint AddTags(uint handle, uint elementCount, uint inByteCount, byte[] inputBuffer, out uint outByteCount, out byte[] outputBuffer);
[OperationContract(Name = "RTag")]
uint RegisterTags(uint handle, uint elementCount, uint inByteCount, byte[] inputBuffer, out uint outByteCount, out byte[] outputBuffer);
[OperationContract(Name = "AddS")]
uint AddStreamValues(uint handle, uint size, byte[] buffer);
[OperationContract(Name = "GetId")]
uint GetTagIds(uint handle, ref uint sequence, out uint tagIdsSize, out byte[] tagIds);
[OperationContract(Name = "GetTg")]
uint GetTags(uint handle, uint tagIdsSize, byte[] tagIds, ref uint sequence, out uint tagInfosSize, out byte[] tagInfos);
[OperationContract(Name = "FlshMD")]
uint FlushMetadata(uint handle, uint tagIdsSize, byte[] tagIds);
[OperationContract(Name = "Flush")]
uint FlushData(uint handle);
[OperationContract(Name = "LoadB")]
uint LoadBlocks(uint handle, ref uint sequence, out uint historyBlocksSize, out byte[] historyBlocks);
[OperationContract(Name = "GetSS")]
uint GetSnapshots(uint handle, long blockStartTime, ref uint sequence, out uint snapshotSize, out byte[] snapshot);
[OperationContract(Name = "QSS")]
uint StartQuerySnapshot(uint handle, long blockStartTime, uint snapshotInfoSize, ref byte[] snapshotInfo, ref uint snapshotQueryId);
[OperationContract(Name = "NxtQSS")]
uint NextQuerySnapshot(uint handle, uint snapshotQueryId, ref uint sequence, out uint snapshotSize, out byte[] snapshot);
[OperationContract(Name = "EndSS")]
uint EndSnapshot(uint handle, uint snapshotQueryId, long blockStartTime, uint snapshotInfoSize, ref byte[] snapshotInfo, bool isDeleteSnapshot);
[OperationContract(Name = "Stop")]
uint Stop(uint handle);
[OperationContract(Name = "ClrTP")]
uint ClearTagIdPairs(uint handle);
[OperationContract(Name = "AddTP")]
uint AddTagIdPairs(uint handle, uint elementCount, uint inByteCount, byte[] inputBuffer);
[OperationContract(Name = "GetSFP")]
bool GetStoreForwardParameter(uint clientHandle, string parameterName, out string parameterValue, out uint errorSize, out byte[] error);
[OperationContract(Name = "SetSFP")]
bool SetStoreForwardParameter(uint clientHandle, string parameterName, ref string parameterValue, out uint errorSize, out byte[] error);
[OperationContract]
bool SendSnapshotBegin(uint handle, ulong totalSize, ulong startTime, ulong endTime, ref string storageSessionIdString, ref uint queryId, out uint errorSize, out byte[] error);
[OperationContract]
bool SendSnapshotEnd(uint handle, string storageSessionIdString, uint queryId, uint timeRangeSize, byte[] timeRangeBytes, out uint errorSize, out byte[] error);
[OperationContract]
bool SendSnapshot(uint handle, string storageSessionIdString, uint queryId, uint size, ulong snapshotChunkOffset, byte[] buffer, out uint errorSize, out byte[] error);
[OperationContract]
bool DeleteSnapshot(uint clientHandle, ulong startTime, uint snapshotInfoSize, ref byte[] snapshotInfo, out uint errorSize, out byte[] errorBuffer);
[OperationContract(Name = "AddS2")]
bool AddStreamValues2(uint handle, string shardIdString, byte[] buffer, out byte[] errorBuffer);
[OperationContract(Name = "ClrST")]
bool ClearShardTagIds(uint handle, out byte[] errorBuffer);
[OperationContract(Name = "AddST")]
bool AddShardTagIds(uint handle, byte[] buffer, out byte[] errorBuffer);
[OperationContract(Name = "SpltS")]
bool SplitUnknownShards(uint handle, out byte[] errorBuffer);
[OperationContract(Name = "GetR")]
bool GetRemainingSnapshotsSize(uint handle, ref ulong snapshotSize, out byte[] errorBuffer);
[OperationContract(Name = "DelT")]
bool DeleteTags(uint handle, byte[] buffer, out byte[] errorBuffer);
[OperationContract(Name = "Open2")]
bool OpenStorageConnection2(ref byte[] inputParameters, out byte[] outputParameters, out byte[] error);
[OperationContract(Name = "ValCl")]
bool ValidateClientCredential(
string handle,
[MessageParameter(Name = "inBuff")] byte[] inputBuffer,
[MessageParameter(Name = "outBuff")] out byte[] outputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "GetI")]
bool GetInfo(string request, out byte[] info, out byte[] errorBuffer);
}
@@ -0,0 +1,28 @@
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Transaction, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface ITransactionServiceContract
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract]
uint ForwardSnapshotBegin(uint handle, ulong totalSize, ulong startTime, ulong endTime, ref string storageSessionIdString, ref uint queryId);
[OperationContract]
uint ForwardSnapshotEnd(uint handle, string storageSessionIdString, uint queryId, uint timeRangeSize, byte[] timeRangeBytes);
[OperationContract]
uint ForwardSnapshot(uint handle, string storageSessionIdString, uint queryId, uint size, ulong snapshotChunkOffset, byte[] buffer);
[OperationContract]
uint AddNonStreamValuesBegin(uint handle, out string transactionId);
[OperationContract]
uint AddNonStreamValues(uint handle, string transactionId, uint size, byte[] buffer);
[OperationContract]
uint AddNonStreamValuesEnd(uint handle, string transactionId, bool commit);
}
@@ -0,0 +1,94 @@
using System.Text;
namespace AVEVA.Historian.Client.Wcf;
/// <remarks>
/// CTagMetadata serialiser for the CM_EVENT default-event-tag registration that the AVEVA
/// native wrapper performs via <c>IHistoryServiceContract2.EnsureTags2</c> (WCF op
/// <c>EnsT2</c>) before any event read can return rows. The action URI on the wire is
/// <c>aa/Hist/EnsT2</c>, not the previously-suspected <c>aa/Hist/AddT</c>. Layout
/// captured byte-for-byte from a successful native event read via the
/// <c>instrument-wcf-writemessage</c> IL-rewrite tooling on
/// <c>aahMDASEncoder.ClientMessageEncoder.WriteMessage</c>:
///
/// <code>
/// byte version = 3
/// ushort optional-mask = 0x0086
/// byte CDataType = 5
/// 16 bytes tag id GUID = 353b8145-5df0-4d46-a253-871aef49b321
/// compact ASCII tag name "CM_EVENT"
/// compact ASCII description "AnE Event"
/// 7 bytes 0x02 0x02 0x01 0x00 0x00 0x00 0x01 (storage type 2 + flags; LAST BYTE IS 0x01)
/// uint32 storage rate = 0
/// int64 created FILETIME UTC
/// 16 bytes common Archestra event type GUID = 5f59ae42-3bb6-4760-91a5-ab0be01f9f02
/// (note: this differs from the previously-documented ...e01f2f27 — the captured
/// native bytes use ...9f02. The earlier docs were inferred from
/// ConvertEventTagToTagMetadata IL inspection without the wire capture.)
/// 3 trailing bytes 0x2F 0x27 0x01 (purpose unknown; appears stable across captures)
/// </code>
///
/// Earlier probe attempts via the (wrong) <c>AddT</c> WCF op + a payload with the
/// (wrong) trailer order returned server failures. Routing through <c>EnsT2</c> with
/// this exact byte layout is the path the native wrapper uses.
/// </remarks>
internal static class HistorianAddTagsProtocol
{
public static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321");
/// <remarks>
/// Captured native byte sequence is `42 AE 59 5F B6 3B 60 47 91 A5 AB 0B E0 1F 9F 02`,
/// which decodes to GUID `5f59ae42-3bb6-4760-91a5-ab0be01f9f02`. Prior notes documented
/// `5f59ae42-3bb6-4760-91a5-ab0be01f2f27` from IL inspection — the wire capture is the
/// authoritative value.
/// </remarks>
public static readonly Guid CommonArchestraEventTypeId = new("5f59ae42-3bb6-4760-91a5-ab0be01f9f02");
public static byte[] SerializeCmEventCTagMetadata(DateTime createdUtc)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((byte)3);
writer.Write((ushort)0x0086);
writer.Write((byte)5);
writer.Write(CmEventTagId.ToByteArray());
WriteCompressedHistorianString(writer, "CM_EVENT");
WriteCompressedHistorianString(writer, "AnE Event");
writer.Write(new byte[] { 0x02, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01 });
writer.Write(0u);
writer.Write(createdUtc.ToUniversalTime().ToFileTimeUtc());
writer.Write(CommonArchestraEventTypeId.ToByteArray());
// 5-byte tail captured byte-for-byte from native: 2F 27 01 01 01.
writer.Write(new byte[] { 0x2F, 0x27, 0x01, 0x01, 0x01 });
return stream.ToArray();
}
private static void WriteCompressedHistorianString(BinaryWriter writer, string value)
{
if (value.Length == 0)
{
writer.Write((byte)0);
return;
}
if (value.Length > byte.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(value), "Compact CTagMetadata strings only support short ASCII payloads.");
}
writer.Write((byte)0x09);
writer.Write((byte)value.Length);
writer.Write((byte)0);
foreach (char character in value)
{
if (character > byte.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(value), "Compact CTagMetadata strings only support ASCII characters.");
}
writer.Write((byte)character);
}
}
}
@@ -0,0 +1,365 @@
using System.Buffers.Binary;
using System.Text;
using AVEVA.Historian.Client.Models;
namespace AVEVA.Historian.Client.Wcf;
internal static class HistorianDataQueryProtocol
{
public const ushort QueryRequestTypeData = 1;
private const ushort GetNextResultBufferVersion = 9;
private const int GetNextResultBufferHeaderSize = 6;
private const int GetNextResultRowFixedTailSize = 75;
private const byte TerminalErrorType = 4;
private const uint TerminalErrorCodeNoMoreData = 30;
/// <remarks>
/// Walks the WCF GetNextQueryResultBuffer2 result body for raw/Full retrieval. Layout (decoded from
/// the canonical OtOpcUaParityTest_001.Counter capture, 4 rows × 141 bytes inside a 570-byte body):
/// header is UInt16 version=9 + UInt32 rowCount; each row is UInt32 tagKey + UInt32 tagNameLen +
/// (tagNameLen × 2) UTF-16 chars + UInt32 sampleCount + Int64 startUtc FILETIME + UInt32 quality +
/// UInt32 qualityDetail + UInt32 opcQuality + Double numericValue + Double percentGood + 1-byte
/// marker + 34 trailing bytes whose meaning is undecoded for raw rows. The 5-byte error/terminal
/// buffer accompanying the result decodes as `04 1E 00 00 00` = type 4, code 30 = "no more data";
/// any other shape leaves <paramref name="hasMoreData"/> true.
/// </remarks>
public static bool TryParseGetNextQueryResultBufferRows(
ReadOnlySpan<byte> result,
ReadOnlySpan<byte> errorTerminal,
out IReadOnlyList<HistorianSample> rows,
out bool hasMoreData)
{
rows = [];
hasMoreData = !IsTerminalNoMoreData(errorTerminal);
if (result.Length == 0)
{
return true;
}
if (result.Length < GetNextResultBufferHeaderSize)
{
return false;
}
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(result[..2]);
if (version != GetNextResultBufferVersion)
{
return false;
}
uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(result.Slice(2, 4));
int cursor = GetNextResultBufferHeaderSize;
List<HistorianSample> parsed = new(checked((int)rowCount));
for (uint i = 0; i < rowCount; i++)
{
if (cursor + 8 > result.Length)
{
return false;
}
uint tagNameChars = BinaryPrimitives.ReadUInt32LittleEndian(result.Slice(cursor + 4, 4));
int tagNameByteLength = checked((int)(tagNameChars * 2));
int rowSize = checked(8 + tagNameByteLength + GetNextResultRowFixedTailSize);
if (cursor + rowSize > result.Length)
{
return false;
}
ReadOnlySpan<byte> row = result.Slice(cursor, rowSize);
string tagName = Encoding.Unicode.GetString(row.Slice(8, tagNameByteLength));
int tail = 8 + tagNameByteLength;
long startTimeFileTimeUtc = BinaryPrimitives.ReadInt64LittleEndian(row.Slice(tail + 4, 8));
uint quality = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 12, 4));
uint qualityDetail = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 16, 4));
uint opcQuality = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 20, 4));
double numericValue = BinaryPrimitives.ReadDoubleLittleEndian(row.Slice(tail + 24, 8));
double percentGood = BinaryPrimitives.ReadDoubleLittleEndian(row.Slice(tail + 32, 8));
parsed.Add(new HistorianSample(
TagName: tagName,
TimestampUtc: DateTime.FromFileTimeUtc(startTimeFileTimeUtc),
NumericValue: numericValue,
StringValue: null,
Quality: checked((ushort)quality),
QualityDetail: qualityDetail,
OpcQuality: checked((ushort)opcQuality),
PercentGood: percentGood));
cursor += rowSize;
}
rows = parsed;
return true;
}
/// <remarks>
/// Same wire layout as the raw parser, but interprets FILETIME #1 at row offset
/// `8 + tagNameLen*2 + 4` as the interval END timestamp and FILETIME #2 at trailer
/// offset 2 (row offset `8 + tagNameLen*2 + 43`) as the interval START. Native struct
/// evidence (`getnextrow-interpolated-memory-latest.json` /
/// `getnextrow-timeweightedaverage-memory-latest.json`) maps `+0x28 = EndDateTime`
/// and `+0x150 = StartDateTime`; the wire FILETIME #1 sits in the EndDateTime slot
/// after marshaling. For raw rows where Start == End the two values are equal, which
/// is consistent with the captured fixture. Live aggregate verification will
/// confirm or correct this orientation.
/// </remarks>
public static bool TryParseGetNextQueryResultBufferAggregateRows(
ReadOnlySpan<byte> result,
ReadOnlySpan<byte> errorTerminal,
Models.RetrievalMode mode,
TimeSpan resolution,
out IReadOnlyList<HistorianAggregateSample> rows,
out bool hasMoreData)
{
rows = [];
hasMoreData = !IsTerminalNoMoreData(errorTerminal);
if (result.Length == 0)
{
return true;
}
if (result.Length < GetNextResultBufferHeaderSize)
{
return false;
}
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(result[..2]);
if (version != GetNextResultBufferVersion)
{
return false;
}
uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(result.Slice(2, 4));
int cursor = GetNextResultBufferHeaderSize;
List<HistorianAggregateSample> parsed = new(checked((int)rowCount));
for (uint i = 0; i < rowCount; i++)
{
if (cursor + 8 > result.Length)
{
return false;
}
uint tagNameChars = BinaryPrimitives.ReadUInt32LittleEndian(result.Slice(cursor + 4, 4));
int tagNameByteLength = checked((int)(tagNameChars * 2));
int rowSize = checked(8 + tagNameByteLength + GetNextResultRowFixedTailSize);
if (cursor + rowSize > result.Length)
{
return false;
}
ReadOnlySpan<byte> row = result.Slice(cursor, rowSize);
string tagName = Encoding.Unicode.GetString(row.Slice(8, tagNameByteLength));
int tail = 8 + tagNameByteLength;
long endTimeFileTimeUtc = BinaryPrimitives.ReadInt64LittleEndian(row.Slice(tail + 4, 8));
uint quality = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 12, 4));
uint qualityDetail = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 16, 4));
uint opcQuality = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 20, 4));
double aggregateValue = BinaryPrimitives.ReadDoubleLittleEndian(row.Slice(tail + 24, 8));
long startTimeFileTimeUtc = BinaryPrimitives.ReadInt64LittleEndian(row.Slice(tail + 43, 8));
parsed.Add(new HistorianAggregateSample(
TagName: tagName,
StartTimeUtc: DateTime.FromFileTimeUtc(startTimeFileTimeUtc),
EndTimeUtc: DateTime.FromFileTimeUtc(endTimeFileTimeUtc),
Value: aggregateValue,
Quality: checked((ushort)quality),
QualityDetail: qualityDetail,
OpcQuality: checked((ushort)opcQuality),
RetrievalMode: mode,
Resolution: resolution));
cursor += rowSize;
}
rows = parsed;
return true;
}
private static bool IsTerminalNoMoreData(ReadOnlySpan<byte> errorTerminal)
{
if (errorTerminal.Length != 5 || errorTerminal[0] != TerminalErrorType)
{
return false;
}
return BinaryPrimitives.ReadUInt32LittleEndian(errorTerminal[1..]) == TerminalErrorCodeNoMoreData;
}
public static byte[] SerializeFullHistoryRequest(HistorianDataQueryRequest request)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
bool noOption = string.Equals(request.Option, "NoOption", StringComparison.Ordinal);
writer.Write(noOption ? (ushort)3 : (ushort)9);
writer.Write((uint)request.QueryType);
writer.Write(request.QueryFormat);
writer.Write(request.SummaryType);
writer.Write(request.StartUtc.ToFileTimeUtc());
writer.Write(request.EndUtc.ToFileTimeUtc());
writer.Write((double)request.Resolution.Ticks);
writer.Write(request.ValueDeadband);
writer.Write(request.TimeDeadband);
WriteHistorianString(writer, request.TimeZone);
writer.Write(request.VersionType);
writer.Write(request.ResultBufferSize);
writer.Write(PackQueryTimeInterpolationFlags(request));
if (!noOption)
{
WriteHistorianString(writer, request.Option);
}
WriteHistorianString(writer, request.Filter);
writer.Write((ushort)request.ValueSelector);
writer.Write((ushort)request.AggregationType);
writer.Write((ushort)1);
writer.Write(request.ColumnSelectorFlags);
WriteStringVector(writer, request.TagNames);
writer.Write(request.MaxStates);
WriteMetadataNamespace(writer, request.MetadataNamespace);
writer.Write(request.ClientVersion);
writer.Write(request.SkipRows);
writer.Write(request.ReservedAfterSkipRows);
WriteRedundantEndpoint(writer, request.MdsEndpoint);
WriteRedundantEndpoint(writer, request.StorageEndpoint);
writer.Write(checked(request.Resolution.Ticks * 10_000L));
WriteStringVector(writer, request.SliceByTagNames);
writer.Write(request.TimeoutQueryProcessingMilliseconds);
WriteAutoSummaryParameters(writer);
return stream.ToArray();
}
private static void WriteMetadataNamespace(BinaryWriter writer, HistorianMetadataNamespace metadataNamespace)
{
writer.Write((byte)1);
WriteScrambledHistorianString(writer, metadataNamespace.Namespace);
WriteScrambledHistorianString(writer, metadataNamespace.TagPrefix);
WriteScrambledHistorianString(writer, metadataNamespace.PropertyPrefix);
}
private static void WriteStringVector(BinaryWriter writer, IReadOnlyList<string> values)
{
writer.Write((uint)values.Count);
foreach (string value in values)
{
WriteHistorianString(writer, value);
}
}
private static void WriteRedundantEndpoint(BinaryWriter writer, HistorianRedundantEndpoint endpoint)
{
writer.Write((ushort)1);
WriteHistorianString(writer, endpoint.EndpointName);
checked
{
writer.Write((ushort)endpoint.Endpoints.Count);
}
foreach (HistorianEndpoint candidate in endpoint.Endpoints)
{
WriteHistorianString(writer, candidate.NodeName);
WriteHistorianString(writer, candidate.PipeName);
}
}
private static void WriteAutoSummaryParameters(BinaryWriter writer)
{
writer.Write((ushort)1);
writer.Write(0L);
writer.Write(0L);
for (int index = 0; index < 5; index++)
{
writer.Write((byte)0);
}
writer.Write(0u);
}
private static ushort PackQueryTimeInterpolationFlags(HistorianDataQueryRequest request)
{
ushort interpolation = request.InterpolationType == 254 ? (ushort)255 : request.InterpolationType;
return checked((ushort)((request.QualityRule << 12) | (request.TimestampRule << 8) | interpolation));
}
private static void WriteHistorianString(BinaryWriter writer, string value)
{
writer.Write((uint)value.Length);
if (value.Length > 0)
{
writer.Write(Encoding.Unicode.GetBytes(value));
}
}
private static void WriteScrambledHistorianString(BinaryWriter writer, string value)
{
if (value.Length == 0)
{
writer.Write((ushort)1);
writer.Write((byte)0);
return;
}
ushort scrambleKey = 1;
foreach (char c in value)
{
if (c >= scrambleKey)
{
scrambleKey = checked((ushort)(c + 1));
}
}
writer.Write(scrambleKey);
writer.Write((byte)1);
writer.Write((byte)value.Length);
foreach (char c in value)
{
writer.Write((ushort)(c ^ scrambleKey));
}
}
}
internal sealed record HistorianDataQueryRequest(
IReadOnlyList<string> TagNames,
DateTime StartUtc,
DateTime EndUtc,
ushort MaxStates,
uint BatchSize,
string Option)
{
public uint QueryType { get; init; } = 2;
public uint QueryFormat { get; init; }
public uint SummaryType { get; init; }
public TimeSpan Resolution { get; init; } = TimeSpan.Zero;
public float ValueDeadband { get; init; }
public uint TimeDeadband { get; init; }
public string TimeZone { get; init; } = "UTC";
public uint VersionType { get; init; } = 1;
public uint ResultBufferSize { get; init; } = 65_536;
public ushort InterpolationType { get; init; } = 255;
public ushort TimestampRule { get; init; } = 1;
public ushort QualityRule { get; init; }
public ulong ColumnSelectorFlags { get; init; } = 0x0000_8182_0007_82FF;
public string Filter { get; init; } = "NoFilter";
public uint ValueSelector { get; init; } = 1;
public uint AggregationType { get; init; } = 3;
public HistorianMetadataNamespace MetadataNamespace { get; init; } = HistorianMetadataNamespace.Empty;
public ushort ClientVersion { get; init; } = 9;
public uint SkipRows { get; init; }
public uint ReservedAfterSkipRows { get; init; }
public HistorianRedundantEndpoint MdsEndpoint { get; init; } = HistorianRedundantEndpoint.Empty;
public HistorianRedundantEndpoint StorageEndpoint { get; init; } = HistorianRedundantEndpoint.Empty;
public IReadOnlyList<string> SliceByTagNames { get; init; } = [];
public uint TimeoutQueryProcessingMilliseconds { get; init; }
public uint MaxQueryMemoryConsumptionInMb { get; init; }
}
internal sealed record HistorianRedundantEndpoint(string EndpointName, IReadOnlyList<HistorianEndpoint> Endpoints)
{
public static HistorianRedundantEndpoint Empty { get; } = new(string.Empty, []);
}
internal sealed record HistorianEndpoint(string NodeName, string PipeName);
@@ -0,0 +1,157 @@
using System.Security.Cryptography;
using System.Text;
namespace AVEVA.Historian.Client.Wcf;
internal static class HistorianEventQueryProtocol
{
public const ushort QueryRequestTypeEvent = 3;
public static IReadOnlyList<HistorianEventQueryAttempt> CreateStartEventQueryAttempts(DateTime startUtc, DateTime endUtc, uint eventCount)
{
List<HistorianEventQueryAttempt> attempts = [];
attempts.Add(CreateNativeEmptyFilterAttempt(startUtc, endUtc, eventCount));
return attempts;
}
private static HistorianEventQueryAttempt CreateNativeEmptyFilterAttempt(DateTime startUtc, DateTime endUtc, uint eventCount)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((ushort)5);
writer.Write(startUtc.ToFileTimeUtc());
writer.Write(endUtc.ToFileTimeUtc());
writer.Write(eventCount);
writer.Write(0u);
writer.Write((ushort)0);
writer.Write((ushort)1);
WriteNativeEmptyFilterBlock(writer);
writer.Write(65_536u);
WriteHistorianString(writer, "UTC");
WriteMetadataNamespace(writer);
writer.Write(0u);
byte[] request = stream.ToArray();
return new HistorianEventQueryAttempt(
"native-empty-filter-version5",
5,
request,
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
}
private static HistorianEventQueryAttempt CreateAttempt(
string shape,
ushort version,
DateTime startUtc,
DateTime endUtc,
uint eventCount,
Action<BinaryWriter> writeFilters,
bool writeTimeZoneBeforeFilter)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write(version);
writer.Write(startUtc.ToFileTimeUtc());
writer.Write(endUtc.ToFileTimeUtc());
writer.Write(eventCount);
writer.Write(0u);
writer.Write((ushort)0);
writer.Write((ushort)1);
if (writeTimeZoneBeforeFilter)
{
WriteHistorianString(writer, "UTC");
writeFilters(writer);
}
else
{
writeFilters(writer);
WriteHistorianString(writer, "UTC");
}
byte[] request = stream.ToArray();
return new HistorianEventQueryAttempt(
$"{shape}-version{version}",
version,
request,
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
}
private static void WriteFilterBlockV1(BinaryWriter writer)
{
writer.Write((ushort)1);
writer.Write((byte)0);
writer.Write(0L);
writer.Write(Guid.Empty.ToByteArray());
writer.Write(0u);
}
private static void WriteNativeEmptyFilterBlock(BinaryWriter writer)
{
writer.Write((ushort)0);
writer.Write(0u);
writer.Write((byte)0);
}
private static void WriteMetadataNamespace(BinaryWriter writer)
{
writer.Write((byte)1);
WriteScrambledHistorianString(writer, string.Empty);
WriteScrambledHistorianString(writer, string.Empty);
WriteScrambledHistorianString(writer, string.Empty);
}
private static void WriteScrambledHistorianString(BinaryWriter writer, string value)
{
if (value.Length == 0)
{
writer.Write((ushort)1);
writer.Write((byte)0);
return;
}
ushort scrambleKey = 1;
foreach (char c in value)
{
if (c >= scrambleKey)
{
scrambleKey = checked((ushort)(c + 1));
}
}
writer.Write(scrambleKey);
writer.Write((byte)1);
writer.Write((byte)value.Length);
foreach (char c in value)
{
writer.Write((ushort)(c ^ scrambleKey));
}
}
private static void WriteFilterBlockContinuationOnly(BinaryWriter writer)
{
writer.Write((byte)0);
writer.Write(0L);
writer.Write(Guid.Empty.ToByteArray());
writer.Write(0u);
}
private static void WriteFilterBlockCountOnly(BinaryWriter writer)
{
writer.Write(0u);
}
private static void WriteHistorianString(BinaryWriter writer, string value)
{
writer.Write((uint)value.Length);
if (value.Length > 0)
{
writer.Write(Encoding.Unicode.GetBytes(value));
}
}
}
internal sealed record HistorianEventQueryAttempt(string Name, ushort Version, byte[] RequestBuffer, string RequestSha256);
@@ -0,0 +1,255 @@
using System.Buffers.Binary;
using System.Text;
using AVEVA.Historian.Client.Models;
namespace AVEVA.Historian.Client.Wcf;
/// <remarks>
/// Parser for the version-9 event-row buffer the Historian server returns from
/// <c>/Retr/GetNextEventQueryResultBuffer.pResultBuff</c>. Wire shape decoded from a captured
/// native event read (instrument-wcf-readmessage record 24, two rows for Alarm.Set + Alarm.Clear):
///
/// <code>
/// UInt16 version = 9
/// UInt32 rowCount
/// rowCount × Row {
/// UInt32 rowMarker = 0x1E
/// UInt16 rowFormat = 7
/// Int64 eventTimeUtcFiletime
/// UInt16 × 8 // purpose unclear (slot offsets?)
/// compact ASCII string // event type (Alarm.Set, Alarm.Clear, ...)
/// UInt16 propertyCount
/// propertyCount × Property {
/// compact ASCII string // property name
/// Value {
/// UInt8 typeMarker
/// UInt8 length // bytes of value following status
/// UInt8 status // observed 0x00 in successful captures
/// length × byte // encoding determined by typeMarker:
/// 0x02 → Boolean (1 byte: 0/1)
/// 0x10 → GUID (16 bytes)
/// 0x18 → FILETIME UTC (Int64)
/// 0x31 → Int32 little-endian
/// 0x43 → UTF-16 string: UInt16 charCount + charCount × UInt16 chars
/// }
/// }
/// }
/// </code>
///
/// Compact ASCII string: <c>0x09 LEN 0x00 LEN×ASCII bytes</c> (same encoding as
/// CTagMetadata strings).
/// </remarks>
internal static class HistorianEventRowProtocol
{
public const ushort EventRowProtocolVersion = 9;
public const uint RowMarker = 0x0000001Eu;
public const ushort RowFormatV9 = 7;
private const int HeaderSize = 6;
private const int RowFixedHeaderSize = 4 + 2 + 8 + 16;
private const byte ValueTypeBool = 0x02;
private const byte ValueTypeGuid = 0x10;
private const byte ValueTypeFiletime = 0x18;
private const byte ValueTypeInt32 = 0x31;
private const byte ValueTypeUtf16String = 0x43;
public static IReadOnlyList<HistorianEvent> Parse(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < HeaderSize)
{
return [];
}
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(buffer[..2]);
if (version != EventRowProtocolVersion)
{
return [];
}
uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(2, 4));
if (rowCount == 0)
{
return [];
}
List<HistorianEvent> events = new(checked((int)rowCount));
int cursor = HeaderSize;
for (uint rowIndex = 0; rowIndex < rowCount; rowIndex++)
{
if (!TryReadRow(buffer, ref cursor, out HistorianEvent? row))
{
break;
}
events.Add(row);
}
return events;
}
private static bool TryReadRow(ReadOnlySpan<byte> buffer, ref int cursor, out HistorianEvent row)
{
row = null!;
if (cursor + RowFixedHeaderSize > buffer.Length)
{
return false;
}
uint marker = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(cursor, 4));
if (marker != RowMarker)
{
return false;
}
ushort format = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(cursor + 4, 2));
if (format != RowFormatV9)
{
return false;
}
long filetime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(cursor + 6, 8));
DateTime eventTimeUtc = DateTime.FromFileTimeUtc(filetime);
int afterFixedHeader = cursor + RowFixedHeaderSize;
if (!TryReadCompactAsciiString(buffer, afterFixedHeader, out string eventType, out int afterType))
{
return false;
}
if (afterType + 2 > buffer.Length)
{
return false;
}
ushort propertyCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(afterType, 2));
int propertyCursor = afterType + 2;
Dictionary<string, object?> properties = new(propertyCount, StringComparer.OrdinalIgnoreCase);
for (int p = 0; p < propertyCount; p++)
{
if (!TryReadCompactAsciiString(buffer, propertyCursor, out string name, out int afterName))
{
return false;
}
if (!TryReadValue(buffer, afterName, out object? value, out int afterValue))
{
return false;
}
properties[name] = value;
propertyCursor = afterValue;
}
row = BuildEvent(eventTimeUtc, eventType, properties);
cursor = propertyCursor;
return true;
}
private static HistorianEvent BuildEvent(DateTime eventTimeUtc, string eventType, Dictionary<string, object?> properties)
{
Guid id = TryGetGuid(properties, "alarm_id") ?? Guid.Empty;
DateTime receivedTime = TryGetFiletime(properties, "receivedtime") ?? eventTimeUtc;
string sourceName = TryGetString(properties, "source_processvariable") ?? TryGetString(properties, "source_object") ?? string.Empty;
string ns = TryGetString(properties, "namespace") ?? TryGetString(properties, "provider_system") ?? string.Empty;
ushort revisionVersion = TryGetInt32(properties, "revisionversion") is int rv && rv is >= 0 and <= ushort.MaxValue
? (ushort)rv
: (ushort)0;
return new HistorianEvent(
Id: id,
EventTimeUtc: eventTimeUtc,
ReceivedTimeUtc: receivedTime,
Type: eventType,
SourceName: sourceName,
Namespace: ns,
RevisionVersion: revisionVersion,
Properties: properties);
}
private static Guid? TryGetGuid(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is Guid g ? g : null;
private static DateTime? TryGetFiletime(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is DateTime dt ? dt : null;
private static string? TryGetString(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is string s ? s : null;
private static int? TryGetInt32(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is int i ? i : null;
/// <summary>
/// Compact ASCII string encoding: <c>0x09 LEN 0x00 LEN×ASCII bytes</c>.
/// </summary>
private static bool TryReadCompactAsciiString(ReadOnlySpan<byte> buffer, int offset, out string value, out int afterOffset)
{
value = string.Empty;
afterOffset = offset;
if (offset + 3 > buffer.Length || buffer[offset] != 0x09)
{
return false;
}
byte length = buffer[offset + 1];
int payloadStart = offset + 3;
if (payloadStart + length > buffer.Length)
{
return false;
}
value = Encoding.ASCII.GetString(buffer.Slice(payloadStart, length));
afterOffset = payloadStart + length;
return true;
}
/// <summary>
/// Value encoding: <c>typeMarker(1) + length(1) + status(1) + length×value bytes</c>.
/// Decodes the value by typeMarker; unknown markers preserve the raw bytes as a
/// <see cref="byte[]"/> in the property bag.
/// </summary>
private static bool TryReadValue(ReadOnlySpan<byte> buffer, int offset, out object? value, out int afterOffset)
{
value = null;
afterOffset = offset;
if (offset + 3 > buffer.Length)
{
return false;
}
byte typeMarker = buffer[offset];
byte length = buffer[offset + 1];
// buffer[offset + 2] is the status byte (observed 0x00 in successful captures).
int valueStart = offset + 3;
if (valueStart + length > buffer.Length)
{
return false;
}
ReadOnlySpan<byte> valueBytes = buffer.Slice(valueStart, length);
value = typeMarker switch
{
ValueTypeBool when length >= 1 => valueBytes[0] != 0,
ValueTypeGuid when length == 16 => new Guid(valueBytes),
ValueTypeFiletime when length == 8 => DateTime.FromFileTimeUtc(BinaryPrimitives.ReadInt64LittleEndian(valueBytes)),
ValueTypeInt32 when length == 4 => BinaryPrimitives.ReadInt32LittleEndian(valueBytes),
ValueTypeUtf16String when length >= 2 => DecodeUtf16String(valueBytes),
_ => valueBytes.ToArray()
};
afterOffset = valueStart + length;
return true;
}
private static string DecodeUtf16String(ReadOnlySpan<byte> valueBytes)
{
ushort charCount = BinaryPrimitives.ReadUInt16LittleEndian(valueBytes[..2]);
int byteCount = checked(charCount * 2);
if (byteCount > valueBytes.Length - 2)
{
byteCount = valueBytes.Length - 2;
}
return Encoding.Unicode.GetString(valueBytes.Slice(2, byteCount));
}
}
@@ -0,0 +1,275 @@
using System.Buffers.Binary;
using System.Text;
namespace AVEVA.Historian.Client.Wcf;
internal static class HistorianOpen2Protocol
{
public static byte[] SerializeLegacyVersion1(HistorianOpen2Request request)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((ushort)1);
WriteHistorianString(writer, request.HostName);
WriteHistorianString(writer, request.ProcessName);
writer.Write(request.ProcessId);
WriteHistorianString(writer, request.UserName);
writer.Write((uint)request.Password.Length);
writer.Write(request.Password);
writer.Write(request.ClientType);
writer.Write(request.ClientVersion);
writer.Write(request.ConnectionMode);
WriteMetadataNamespace(writer, request.MetadataNamespace);
return stream.ToArray();
}
public static byte[] SerializeNativeVersion3(HistorianOpen2Request request, HistorianClientCommonInfo commonInfo)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((byte)3);
WriteNativeOpenConnectionContent(writer, request, commonInfo);
return stream.ToArray();
}
public static byte[] SerializeNativeOpenConnection3Version6(
HistorianOpen2Request request,
HistorianClientCommonInfo commonInfo,
Guid clientKey,
byte[]? credentialBlock = null)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((byte)6);
writer.Write(clientKey.ToByteArray());
writer.Write((byte)0);
WriteNativeOpenConnectionContent(writer, request, commonInfo, credentialBlock, useCompactMetadataNamespace: true);
return stream.ToArray();
}
private static void WriteNativeOpenConnectionContent(
BinaryWriter writer,
HistorianOpen2Request request,
HistorianClientCommonInfo commonInfo,
byte[]? credentialBlock = null,
bool useCompactMetadataNamespace = false)
{
byte[] secretBytes = credentialBlock ?? request.Password;
WriteHistorianString(writer, request.HostName);
checked
{
writer.Write((ushort)secretBytes.Length);
}
writer.Write(secretBytes);
writer.Write(request.ClientType);
writer.Write(request.ConnectionMode);
if (useCompactMetadataNamespace)
{
WriteCompactMetadataNamespace(writer, request.MetadataNamespace);
}
else
{
WriteMetadataNamespace(writer, request.MetadataNamespace);
}
WriteHistorianString(writer, string.Empty);
WriteHistorianString(writer, string.Empty);
WriteClientCommonInfo(writer, commonInfo);
}
public static HistorianNativeError? TryReadNativeError(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < 5)
{
return null;
}
byte type = buffer[0];
uint code = BinaryPrimitives.ReadUInt32LittleEndian(buffer[1..5]);
return new HistorianNativeError(type, code, GetKnownErrorName(code));
}
public static HistorianLegacyOpen2Output? TryReadLegacyOpen2Output(ReadOnlySpan<byte> buffer)
{
if (buffer.Length != 32)
{
return null;
}
uint handle = BinaryPrimitives.ReadUInt32LittleEndian(buffer[..4]);
Guid storageSessionId = new(buffer.Slice(4, 16));
long connectTime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(20, 8));
uint serverStatus = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(28, 4));
return new HistorianLegacyOpen2Output(handle, storageSessionId, connectTime, serverStatus);
}
public static HistorianNativeOpen3Output? TryReadNativeOpen3Output(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < 29)
{
return null;
}
byte protocolVersion = buffer[0];
if (protocolVersion is not (2 or 3))
{
return null;
}
int minimumLength = protocolVersion >= 3 ? 37 : 29;
if (buffer.Length < minimumLength)
{
return null;
}
uint handle = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(1, 4));
Guid storageSessionId = new(buffer.Slice(5, 16));
long connectTime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(21, 8));
long? serverTime = null;
if (protocolVersion >= 3)
{
serverTime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(29, 8));
}
byte[] trailingBytes = buffer[minimumLength..].ToArray();
return new HistorianNativeOpen3Output(
protocolVersion,
handle,
storageSessionId,
connectTime,
serverTime,
trailingBytes);
}
public static byte[] EncodeWidePassword(string password)
{
return string.IsNullOrEmpty(password) ? [] : Encoding.Unicode.GetBytes(password);
}
private static void WriteMetadataNamespace(BinaryWriter writer, HistorianMetadataNamespace metadataNamespace)
{
writer.Write(metadataNamespace.HasValue ? (byte)1 : (byte)0);
WriteHistorianString(writer, metadataNamespace.Namespace);
WriteHistorianString(writer, metadataNamespace.TagPrefix);
WriteHistorianString(writer, metadataNamespace.PropertyPrefix);
}
private static void WriteCompactMetadataNamespace(BinaryWriter writer, HistorianMetadataNamespace metadataNamespace)
{
if (!metadataNamespace.HasValue
|| metadataNamespace.Namespace.Length != 0
|| metadataNamespace.TagPrefix.Length != 0
|| metadataNamespace.PropertyPrefix.Length != 0)
{
throw new ProtocolEvidenceMissingException("OpenConnection3 non-empty metadata namespace");
}
writer.Write((byte)1);
WriteCompactEmptyString(writer);
WriteCompactEmptyString(writer);
WriteCompactEmptyString(writer);
}
private static void WriteCompactEmptyString(BinaryWriter writer)
{
writer.Write((ushort)1);
writer.Write((byte)0);
}
private static void WriteHistorianString(BinaryWriter writer, string value)
{
writer.Write((uint)value.Length);
if (value.Length > 0)
{
writer.Write(Encoding.Unicode.GetBytes(value));
}
}
private static void WriteClientCommonInfo(BinaryWriter writer, HistorianClientCommonInfo commonInfo)
{
writer.Write(commonInfo.FormatVersion);
WriteHistorianString(writer, commonInfo.ServerNodeName);
WriteHistorianString(writer, commonInfo.ClientNodeName);
writer.Write(commonInfo.ProcessId);
writer.Write(commonInfo.HcalVersion);
WriteHistorianString(writer, commonInfo.ProcessName);
WriteHistorianString(writer, commonInfo.Proxy);
WriteHistorianString(writer, commonInfo.DataSourceId);
writer.Write(commonInfo.ShardId.ToByteArray());
writer.Write(commonInfo.ClientVersion);
if (commonInfo.FormatVersion >= 3)
{
writer.Write(commonInfo.ClientTimestamp);
}
if (commonInfo.FormatVersion >= 4)
{
WriteHistorianString(writer, commonInfo.ClientDllVersion);
}
}
private static string? GetKnownErrorName(uint code)
{
return code switch
{
1 => "Failure",
73 => "InvalidPacketVersion",
171 => "AuthenticationFailed",
_ => null
};
}
}
internal sealed record HistorianOpen2Request(
string HostName,
string ProcessName,
uint ProcessId,
string UserName,
byte[] Password,
byte ClientType,
ushort ClientVersion,
uint ConnectionMode,
HistorianMetadataNamespace MetadataNamespace);
internal sealed record HistorianMetadataNamespace(
bool HasValue,
string Namespace,
string TagPrefix,
string PropertyPrefix)
{
public static HistorianMetadataNamespace Empty { get; } = new(true, string.Empty, string.Empty, string.Empty);
}
internal sealed record HistorianNativeError(byte Type, uint Code, string? Name);
internal sealed record HistorianLegacyOpen2Output(
uint Handle,
Guid StorageSessionId,
long ConnectTimeFileTimeUtc,
uint ServerStatus);
internal sealed record HistorianNativeOpen3Output(
byte ProtocolVersion,
uint Handle,
Guid StorageSessionId,
long ConnectTimeFileTimeUtc,
long? ServerTimeFileTimeUtc,
byte[] TrailingBytes);
internal sealed record HistorianClientCommonInfo(
byte FormatVersion,
string ServerNodeName,
string ClientNodeName,
uint ProcessId,
ushort HcalVersion,
string ProcessName,
string Proxy,
string DataSourceId,
Guid ShardId,
uint ClientVersion,
ulong ClientTimestamp,
string ClientDllVersion);
@@ -0,0 +1,346 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace AVEVA.Historian.Client.Wcf;
/// <remarks>
/// Mirrors the request flags the AVEVA wrapper passes to InitializeSecurityContextW: 0x2081C round 0,
/// 0x81C subsequent. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation; without it
/// AcceptSecurityContext rejects the type-3 token with SEC_E_INVALID_TOKEN. ALLOCATE_MEMORY is added
/// for output-buffer convenience and the server tolerates it.
/// </remarks>
[SupportedOSPlatform("windows")]
internal sealed class HistorianSspiClient : IDisposable
{
public const int IscReqReplayDetect = 0x4;
public const int IscReqSequenceDetect = 0x8;
public const int IscReqConfidentiality = 0x10;
public const int IscReqConnection = 0x800;
public const int IscReqIdentify = 0x20000;
public const int IscReqAllocateMemory = 0x100;
public const int NativeFlagsRound0 = IscReqIdentify | IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
public const int NativeFlagsRoundSubsequent = IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
private const int SecpkgCredOutbound = 2;
private const int SecbufferToken = 2;
private const int SecEOk = 0;
private const int SecIContinueNeeded = 0x00090312;
private readonly string _targetName;
private SecHandle _credential;
private SecHandle _context;
private bool _haveContext;
private int _roundIndex;
private bool _disposed;
public HistorianSspiClient(string targetName, string package = "Negotiate")
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(package);
_targetName = targetName;
_credential = default;
int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, ref _credential, out _);
ThrowIfFailed(status, "AcquireCredentialsHandle");
}
/// <remarks>
/// Acquires Negotiate credentials for an explicit user/domain/password instead of the
/// calling thread's Windows identity. Builds a SEC_WINNT_AUTH_IDENTITY (Unicode) and
/// passes it as <c>pAuthData</c> to <c>AcquireCredentialsHandleW</c>. Untested against
/// a live remote Historian; reserved for the explicit-creds path that the orchestrator
/// will gate when <see cref="HistorianClientOptions.IntegratedSecurity"/> is false.
/// </remarks>
public HistorianSspiClient(string targetName, string domain, string userName, string password, string package = "Negotiate")
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
ArgumentException.ThrowIfNullOrWhiteSpace(package);
_targetName = targetName;
_credential = default;
IntPtr userPtr = IntPtr.Zero;
IntPtr domainPtr = IntPtr.Zero;
IntPtr passwordPtr = IntPtr.Zero;
IntPtr authDataPtr = IntPtr.Zero;
try
{
userPtr = Marshal.StringToCoTaskMemUni(userName);
domainPtr = string.IsNullOrEmpty(domain) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(domain);
passwordPtr = string.IsNullOrEmpty(password) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(password);
SecWinntAuthIdentity authIdentity = new()
{
User = userPtr,
UserLength = userName.Length,
Domain = domainPtr,
DomainLength = domain?.Length ?? 0,
Password = passwordPtr,
PasswordLength = password?.Length ?? 0,
Flags = SecWinntAuthIdentityUnicode
};
authDataPtr = Marshal.AllocCoTaskMem(Marshal.SizeOf<SecWinntAuthIdentity>());
Marshal.StructureToPtr(authIdentity, authDataPtr, false);
int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, authDataPtr, IntPtr.Zero, IntPtr.Zero, ref _credential, out _);
ThrowIfFailed(status, "AcquireCredentialsHandle");
}
finally
{
if (authDataPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(authDataPtr);
if (passwordPtr != IntPtr.Zero) Marshal.ZeroFreeCoTaskMemUnicode(passwordPtr);
if (domainPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(domainPtr);
if (userPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(userPtr);
}
}
private const int SecWinntAuthIdentityUnicode = 0x2;
[StructLayout(LayoutKind.Sequential)]
private struct SecWinntAuthIdentity
{
public IntPtr User;
public int UserLength;
public IntPtr Domain;
public int DomainLength;
public IntPtr Password;
public int PasswordLength;
public int Flags;
}
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call will use.</summary>
internal int NextRequestFlags => SelectRequestFlags(_roundIndex) | IscReqAllocateMemory;
public static int SelectRequestFlags(int roundIndex) => roundIndex == 0 ? NativeFlagsRound0 : NativeFlagsRoundSubsequent;
public HistorianSspiStepResult Next(byte[] incoming)
{
ArgumentNullException.ThrowIfNull(incoming);
ObjectDisposedException.ThrowIf(_disposed, this);
SecBufferDesc outDesc = CreateOutputBufferDesc();
SecBufferDesc? inDesc = incoming.Length == 0 ? null : CreateInputBufferDesc(incoming);
try
{
int requirements = NextRequestFlags;
SecHandle newContext = default;
int status;
uint contextAttributes;
long expiry;
if (inDesc.HasValue)
{
SecBufferDesc input = inDesc.Value;
status = InitializeSecurityContext(
ref _credential,
ref _context,
_targetName,
requirements,
0,
0,
ref input,
0,
ref newContext,
ref outDesc,
out contextAttributes,
out expiry);
}
else
{
status = InitializeSecurityContext(
ref _credential,
IntPtr.Zero,
_targetName,
requirements,
0,
0,
IntPtr.Zero,
0,
ref newContext,
ref outDesc,
out contextAttributes,
out expiry);
}
if (!_haveContext)
{
_context = newContext;
_haveContext = true;
}
ThrowIfFailed(status, "InitializeSecurityContext", allowContinue: true);
byte[] token = ReadTokenAndFree(outDesc);
_roundIndex++;
return new HistorianSspiStepResult(token, status == SecEOk);
}
finally
{
if (inDesc.HasValue)
{
FreeBufferDesc(inDesc.Value, freeToken: true);
}
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_haveContext)
{
DeleteSecurityContext(ref _context);
}
FreeCredentialsHandle(ref _credential);
}
private static byte[] ReadTokenAndFree(SecBufferDesc desc)
{
try
{
SecBuffer buffer = Marshal.PtrToStructure<SecBuffer>(desc.pBuffers);
if (buffer.cbBuffer == 0 || buffer.pvBuffer == IntPtr.Zero)
{
return [];
}
byte[] bytes = new byte[buffer.cbBuffer];
Marshal.Copy(buffer.pvBuffer, bytes, 0, bytes.Length);
FreeContextBuffer(buffer.pvBuffer);
return bytes;
}
finally
{
FreeBufferDesc(desc, freeToken: false);
}
}
private static SecBufferDesc CreateOutputBufferDesc()
{
SecBuffer buffer = new() { BufferType = SecbufferToken, cbBuffer = 0, pvBuffer = IntPtr.Zero };
IntPtr bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBuffer>());
Marshal.StructureToPtr(buffer, bufferPtr, false);
return new SecBufferDesc { ulVersion = 0, cBuffers = 1, pBuffers = bufferPtr };
}
private static SecBufferDesc CreateInputBufferDesc(byte[] token)
{
IntPtr tokenPtr = Marshal.AllocHGlobal(token.Length);
Marshal.Copy(token, 0, tokenPtr, token.Length);
SecBuffer buffer = new() { BufferType = SecbufferToken, cbBuffer = token.Length, pvBuffer = tokenPtr };
IntPtr bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBuffer>());
Marshal.StructureToPtr(buffer, bufferPtr, false);
return new SecBufferDesc { ulVersion = 0, cBuffers = 1, pBuffers = bufferPtr };
}
private static void FreeBufferDesc(SecBufferDesc desc, bool freeToken)
{
if (desc.pBuffers == IntPtr.Zero)
{
return;
}
if (freeToken)
{
SecBuffer buffer = Marshal.PtrToStructure<SecBuffer>(desc.pBuffers);
if (buffer.pvBuffer != IntPtr.Zero)
{
Marshal.FreeHGlobal(buffer.pvBuffer);
}
}
Marshal.FreeHGlobal(desc.pBuffers);
}
private static void ThrowIfFailed(int status, string operation, bool allowContinue = false)
{
if (status == SecEOk || (allowContinue && status == SecIContinueNeeded))
{
return;
}
throw new Win32Exception(status, operation + " failed with 0x" + status.ToString("X8"));
}
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int AcquireCredentialsHandle(
string? pszPrincipal,
string pszPackage,
int fCredentialUse,
IntPtr pvLogonId,
IntPtr pAuthData,
IntPtr pGetKeyFn,
IntPtr pvGetKeyArgument,
ref SecHandle phCredential,
out long ptsExpiry);
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int InitializeSecurityContext(
ref SecHandle phCredential,
ref SecHandle phContext,
string pszTargetName,
int fContextReq,
int reserved1,
int targetDataRep,
ref SecBufferDesc pInput,
int reserved2,
ref SecHandle phNewContext,
ref SecBufferDesc pOutput,
out uint pfContextAttr,
out long ptsExpiry);
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int InitializeSecurityContext(
ref SecHandle phCredential,
IntPtr phContext,
string pszTargetName,
int fContextReq,
int reserved1,
int targetDataRep,
IntPtr pInput,
int reserved2,
ref SecHandle phNewContext,
ref SecBufferDesc pOutput,
out uint pfContextAttr,
out long ptsExpiry);
[DllImport("secur32.dll", SetLastError = false)]
private static extern int FreeCredentialsHandle(ref SecHandle phCredential);
[DllImport("secur32.dll", SetLastError = false)]
private static extern int DeleteSecurityContext(ref SecHandle phContext);
[DllImport("secur32.dll", SetLastError = false)]
private static extern int FreeContextBuffer(IntPtr pvContextBuffer);
[StructLayout(LayoutKind.Sequential)]
private struct SecHandle
{
public IntPtr dwLower;
public IntPtr dwUpper;
}
[StructLayout(LayoutKind.Sequential)]
private struct SecBuffer
{
public int cbBuffer;
public int BufferType;
public IntPtr pvBuffer;
}
[StructLayout(LayoutKind.Sequential)]
private struct SecBufferDesc
{
public int ulVersion;
public int cBuffers;
public IntPtr pBuffers;
}
}
internal readonly record struct HistorianSspiStepResult(byte[] Token, bool IsCompleted);
@@ -0,0 +1,33 @@
using System.Buffers.Binary;
namespace AVEVA.Historian.Client.Wcf;
internal static class HistorianStatusProtocol
{
public const int SystemTimeByteCount = 16;
public static DateTime? TryReadSystemTime(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < SystemTimeByteCount)
{
return null;
}
ushort year = BinaryPrimitives.ReadUInt16LittleEndian(buffer[0..2]);
ushort month = BinaryPrimitives.ReadUInt16LittleEndian(buffer[2..4]);
ushort day = BinaryPrimitives.ReadUInt16LittleEndian(buffer[6..8]);
ushort hour = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]);
ushort minute = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]);
ushort second = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]);
ushort millisecond = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]);
try
{
return new DateTime(year, month, day, hour, minute, second, millisecond, DateTimeKind.Unspecified);
}
catch (ArgumentOutOfRangeException)
{
return null;
}
}
}
@@ -0,0 +1,198 @@
using System.Security.Cryptography;
using System.Text;
namespace AVEVA.Historian.Client.Wcf;
internal static class HistorianTagQueryProtocol
{
public const ushort NativeStartTagQueryMarker = 26_449;
public const ushort NativeStartTagQueryVersion = 1;
public static HistorianTagQueryAttempt CreateStartTagQueryAttempt(string tagFilter)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write(NativeStartTagQueryMarker);
writer.Write(NativeStartTagQueryVersion);
WriteHistorianString(writer, tagFilter);
byte[] request = stream.ToArray();
return new HistorianTagQueryAttempt(
"native-start-tag-query-version1",
request,
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
}
public static HistorianTagQueryAttempt CreateStartTagQueryHeaderOnlyAttempt()
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write(NativeStartTagQueryMarker);
writer.Write(NativeStartTagQueryVersion);
byte[] request = stream.ToArray();
return new HistorianTagQueryAttempt(
"native-start-tag-query-header-only",
request,
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
}
public static HistorianTagQueryStartResponse ParseStartTagQueryResponse(ReadOnlySpan<byte> response)
{
if (response.Length != 8)
{
throw new InvalidDataException("StartTagQuery response must be exactly 8 bytes.");
}
return new HistorianTagQueryStartResponse(
BitConverter.ToUInt32(response[..4]),
BitConverter.ToUInt32(response[4..8]));
}
public static IReadOnlyList<HistorianTagInfoResponse> ParseGetTagInfoResponse(ReadOnlySpan<byte> response)
{
if (response.Length < 4)
{
throw new InvalidDataException("GetTagInfo response is missing the tag count.");
}
int cursor = 0;
uint count = ReadUInt32(response, ref cursor);
List<HistorianTagInfoResponse> tags = new(checked((int)count));
for (uint index = 0; index < count; index++)
{
tags.Add(ParseTagInfoRecord(response, ref cursor));
}
return tags;
}
public static HistorianTagInfoResponse ParseGetTagInfoFromNameResponse(ReadOnlySpan<byte> response)
{
int cursor = 0;
return ParseTagInfoRecord(response, ref cursor);
}
public static IReadOnlyList<string> ParseGetLikeTagNamesResponse(ReadOnlySpan<byte> response)
{
if (response.Length < 4)
{
throw new InvalidDataException("GetLikeTagnames response is missing the tag count.");
}
int cursor = 0;
uint count = ReadUInt32(response, ref cursor);
List<string> tagNames = new(checked((int)count));
for (uint index = 0; index < count; index++)
{
uint charLength = ReadUInt32(response, ref cursor);
int byteLength = checked((int)charLength * 2);
EnsureAvailable(response, cursor, byteLength);
tagNames.Add(Encoding.Unicode.GetString(response.Slice(cursor, byteLength)));
cursor += byteLength;
}
if (cursor != response.Length)
{
throw new InvalidDataException("GetLikeTagnames response has trailing bytes.");
}
return tagNames;
}
private static void WriteHistorianString(BinaryWriter writer, string value)
{
writer.Write((uint)value.Length);
if (value.Length > 0)
{
writer.Write(Encoding.Unicode.GetBytes(value));
}
}
private static string ReadCompactAsciiString(ReadOnlySpan<byte> response, ref int cursor)
{
EnsureAvailable(response, cursor, 3);
byte marker = response[cursor++];
if (marker != 0x09)
{
throw new InvalidDataException($"Expected compact string marker 0x09, found 0x{marker:X2}.");
}
ushort byteLength = ReadUInt16(response, ref cursor);
EnsureAvailable(response, cursor, byteLength);
string value = Encoding.UTF8.GetString(response.Slice(cursor, byteLength));
cursor += byteLength;
return value;
}
private static HistorianTagInfoResponse ParseTagInfoRecord(ReadOnlySpan<byte> response, ref int cursor)
{
EnsureAvailable(response, cursor, 24);
byte[] nativeDataTypeDescriptor = response.Slice(cursor, 4).ToArray();
cursor += 4;
Guid typeId = new(response.Slice(cursor, 16));
cursor += 16;
uint tagKey = ReadUInt32(response, ref cursor);
string tagName = ReadCompactAsciiString(response, ref cursor);
string metadataProvider = ReadCompactAsciiString(response, ref cursor);
EnsureAvailable(response, cursor, 4);
byte nativeTagClass = response[cursor++];
byte storageType = response[cursor++];
byte deadbandType = response[cursor++];
byte interpolationType = response[cursor++];
return new HistorianTagInfoResponse(
tagName,
tagKey,
typeId,
nativeDataTypeDescriptor,
metadataProvider,
nativeTagClass,
storageType,
deadbandType,
interpolationType);
}
private static ushort ReadUInt16(ReadOnlySpan<byte> response, ref int cursor)
{
EnsureAvailable(response, cursor, 2);
ushort value = BitConverter.ToUInt16(response.Slice(cursor, 2));
cursor += 2;
return value;
}
private static uint ReadUInt32(ReadOnlySpan<byte> response, ref int cursor)
{
EnsureAvailable(response, cursor, 4);
uint value = BitConverter.ToUInt32(response.Slice(cursor, 4));
cursor += 4;
return value;
}
private static void EnsureAvailable(ReadOnlySpan<byte> response, int cursor, int byteCount)
{
if (cursor < 0 || byteCount < 0 || cursor > response.Length - byteCount)
{
throw new InvalidDataException("GetTagInfo response ended unexpectedly.");
}
}
}
internal sealed record HistorianTagQueryAttempt(string Name, byte[] RequestBuffer, string RequestSha256);
internal sealed record HistorianTagQueryStartResponse(uint QueryHandle, uint TagCount);
internal sealed record HistorianTagInfoResponse(
string TagName,
uint TagKey,
Guid TypeId,
byte[] NativeDataTypeDescriptor,
string MetadataProvider,
byte NativeTagClass,
byte StorageType,
byte DeadbandType,
byte InterpolationType);
@@ -0,0 +1,201 @@
using System.Buffers.Binary;
using System.Diagnostics;
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
using AVEVA.Historian.Client.Wcf.Contracts;
namespace AVEVA.Historian.Client.Wcf;
[SupportedOSPlatform("windows")]
internal static class HistorianWcfAuthChainHelper
{
private const int OpenConnection3MinResponseLength = 5;
private const int CredentialBlockSizeBytes = 1026;
private const int MaxValClRounds = 8;
private const string ClientNodeNameFallback = "AVEVA.Historian.Client";
private const string ClientDataSourceId = "2020.406.2652.2";
private const string ClientDllVersionString = "2020.406.2652.2";
private const byte NativeClientType = 4;
public const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
public const uint NativeIntegratedEventConnectionMode = 0x501;
private const byte NativeClientCommonInfoFormatVersion = 4;
private const ushort NativeHcalVersion = 17;
private const uint NativeClientVersionInt = 999_999;
private const ushort NativeOpen2ClientVersion = 9;
/// <summary>
/// Runs Hist.GetV → Hist.ValCl × N → Hist.Open2 against the configured /Hist endpoint and
/// returns the transient /Retr client handle decoded from the OpenConnection3 response.
/// Caller is responsible for opening the matching /Retr channel.
/// </summary>
public static uint OpenAuthenticatedConnection(
HistorianClientOptions options,
Binding historyBinding,
EndpointAddress historyEndpoint,
Guid contextKey,
CancellationToken cancellationToken,
uint connectionMode = NativeIntegratedReadOnlyConnectionMode,
Action<IHistoryServiceContract2, OpenConnectionContext>? additionalSetup = null)
{
ChannelFactory<IHistoryServiceContract2> historyFactory = new(historyBinding, historyEndpoint);
try
{
IHistoryServiceContract2 historyChannel = historyFactory.CreateChannel();
ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel;
try
{
historyChannel.GetInterfaceVersion(out _);
RunValClRounds(historyChannel, contextKey, options, cancellationToken);
byte[] open2Request = BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
bool open2Success = historyChannel.OpenConnection2(ref open2Request, out byte[] open2Response, out byte[] open2Error);
open2Response ??= [];
open2Error ??= [];
if (!open2Success || open2Response.Length < OpenConnection3MinResponseLength)
{
throw new InvalidOperationException(
$"Open2 failed (Success={open2Success}, ResponseLen={open2Response.Length}, ErrorLen={open2Error.Length}).");
}
uint clientHandle = BinaryPrimitives.ReadUInt32LittleEndian(open2Response.AsSpan(1, 4));
Guid storageSessionId = open2Response.Length >= 21
? new Guid(open2Response.AsSpan(5, 16))
: Guid.Empty;
if (additionalSetup is not null)
{
additionalSetup(historyChannel, new OpenConnectionContext(contextKey, clientHandle, storageSessionId));
}
return clientHandle;
}
finally
{
CloseChannelSafely(historyChannelCo);
}
}
finally
{
CloseFactorySafely(historyFactory);
}
}
public readonly record struct OpenConnectionContext(Guid ContextKey, uint ClientHandle, Guid StorageSessionId);
private static void RunValClRounds(IHistoryServiceContract2 channel, Guid contextKey, HistorianClientOptions options, CancellationToken cancellationToken)
{
using HistorianSspiClient sspi = options.IntegratedSecurity
? new HistorianSspiClient(options.TargetSpn)
: new HistorianSspiClient(options.TargetSpn, ParseDomain(options.UserName), ParseUserName(options.UserName), options.Password);
string handle = contextKey.ToString("D").ToUpperInvariant();
byte[] incoming = [];
for (int round = 0; round < MaxValClRounds; round++)
{
cancellationToken.ThrowIfCancellationRequested();
HistorianSspiStepResult step = sspi.Next(incoming);
byte[] outgoing = step.Token;
HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(outgoing);
byte[] wrapped = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken(round == 0, outgoing);
bool serverSuccess = channel.ValidateClientCredential(handle, wrapped, out byte[] serverOutput, out byte[] errorBuffer);
serverOutput ??= [];
errorBuffer ??= [];
if (!serverSuccess)
{
throw new InvalidOperationException($"ValCl round {round} rejected (errorLen={errorBuffer.Length}).");
}
ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput);
if (response is null || !response.Continue)
{
return;
}
incoming = response.Token;
if (step.IsCompleted && incoming.Length == 0)
{
return;
}
}
throw new InvalidOperationException($"ValCl exceeded {MaxValClRounds} rounds without terminal success.");
}
private static string ParseDomain(string userName)
{
if (string.IsNullOrEmpty(userName)) return string.Empty;
int slash = userName.IndexOf('\\');
return slash > 0 ? userName[..slash] : string.Empty;
}
private static string ParseUserName(string userName)
{
if (string.IsNullOrEmpty(userName)) return string.Empty;
int slash = userName.IndexOf('\\');
return slash > 0 ? userName[(slash + 1)..] : userName;
}
private static byte[] BuildOpenConnection3Request(string host, Guid contextKey, uint connectionMode)
{
Process current = Process.GetCurrentProcess();
string machineName = Environment.MachineName;
string processName = string.IsNullOrEmpty(current.ProcessName) ? ClientNodeNameFallback : current.ProcessName;
_ = host; // host reserved for remote-orchestrator extension
HistorianOpen2Request open2 = new(
HostName: machineName,
ProcessName: string.Empty,
ProcessId: checked((uint)current.Id),
UserName: string.Empty,
Password: [],
ClientType: NativeClientType,
ClientVersion: NativeOpen2ClientVersion,
ConnectionMode: connectionMode,
MetadataNamespace: HistorianMetadataNamespace.Empty);
HistorianClientCommonInfo commonInfo = new(
FormatVersion: NativeClientCommonInfoFormatVersion,
ServerNodeName: machineName,
ClientNodeName: processName,
ProcessId: checked((uint)current.Id),
HcalVersion: NativeHcalVersion,
ProcessName: string.Empty,
Proxy: string.Empty,
DataSourceId: ClientDataSourceId,
ShardId: Guid.Empty,
ClientVersion: NativeClientVersionInt,
ClientTimestamp: (ulong)DateTime.UtcNow.ToFileTimeUtc(),
ClientDllVersion: ClientDllVersionString);
return HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6(
open2,
commonInfo,
contextKey,
credentialBlock: new byte[CredentialBlockSizeBytes]);
}
private static void CloseChannelSafely(ICommunicationObject channel)
{
try
{
if (channel.State == CommunicationState.Faulted) channel.Abort();
else channel.Close();
}
catch { try { channel.Abort(); } catch { } }
}
private static void CloseFactorySafely<TChannel>(ChannelFactory<TChannel> factory)
{
try
{
if (factory.State == CommunicationState.Faulted) factory.Abort();
else factory.Close();
}
catch { try { factory.Abort(); } catch { } }
}
}
@@ -0,0 +1,63 @@
using System.Buffers.Binary;
namespace AVEVA.Historian.Client.Wcf;
internal static class HistorianWcfAuthenticationProtocol
{
private const uint NativeNtlmNegotiateVersionFlag = 0x0010_0000;
public static byte[] WrapValidateClientCredentialToken(bool isFirstRound, ReadOnlySpan<byte> token)
{
byte[] buffer = new byte[checked(1 + sizeof(uint) + token.Length)];
buffer[0] = isFirstRound ? (byte)1 : (byte)0;
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(1, sizeof(uint)), checked((uint)token.Length));
token.CopyTo(buffer.AsSpan(1 + sizeof(uint)));
return buffer;
}
public static bool TryApplyNativeNtlmNegotiateVersionFlag(Span<byte> token)
{
ReadOnlySpan<byte> ntlmSignature = "NTLMSSP\0"u8;
if (token.Length < 16
|| !token[..ntlmSignature.Length].SequenceEqual(ntlmSignature)
|| BinaryPrimitives.ReadUInt32LittleEndian(token.Slice(8, sizeof(uint))) != 1)
{
return false;
}
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(token.Slice(12, sizeof(uint)));
BinaryPrimitives.WriteUInt32LittleEndian(
token.Slice(12, sizeof(uint)),
flags | NativeNtlmNegotiateVersionFlag);
return true;
}
public static ValidateClientCredentialToken? TryReadWrappedValidateClientCredentialToken(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < 1 + sizeof(uint))
{
return null;
}
uint tokenLength = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(1, sizeof(uint)));
if (tokenLength > int.MaxValue || buffer.Length != 1 + sizeof(uint) + (int)tokenLength)
{
return null;
}
return new ValidateClientCredentialToken(buffer[0] != 0, buffer[(1 + sizeof(uint))..].ToArray());
}
public static ValidateClientCredentialResponse? TryReadValidateClientCredentialResponse(ReadOnlySpan<byte> buffer)
{
if (buffer.Length == 0)
{
return null;
}
return new ValidateClientCredentialResponse(buffer[0] != 0, buffer[1..].ToArray());
}
}
internal sealed record ValidateClientCredentialToken(bool IsFirstRound, byte[] Token);
internal sealed record ValidateClientCredentialResponse(bool Continue, byte[] Token);
@@ -0,0 +1,167 @@
using System.Net.Security;
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
namespace AVEVA.Historian.Client.Wcf;
internal static class HistorianWcfBindingFactory
{
public const string Scheme = "net.tcp";
public const int DefaultPort = 32568;
public static Binding CreateMdasNetTcpBinding(TimeSpan timeout, long maxReceivedMessageSize = 64 * 1024 * 1024)
{
var encoding = new MdasMessageEncodingBindingElement(
new BinaryMessageEncodingBindingElement
{
MessageVersion = MessageVersion.Soap12WSAddressing10
});
var transport = new TcpTransportBindingElement
{
MaxReceivedMessageSize = maxReceivedMessageSize,
TransferMode = TransferMode.Buffered
};
return new CustomBinding(encoding, transport)
{
CloseTimeout = timeout,
OpenTimeout = timeout,
ReceiveTimeout = timeout,
SendTimeout = timeout
};
}
public static Binding CreateMdasNetTcpWindowsBinding(TimeSpan timeout, long maxReceivedMessageSize = 64 * 1024 * 1024)
{
NetTcpBinding nativeShape = new(SecurityMode.Transport)
{
MaxReceivedMessageSize = maxReceivedMessageSize,
MaxBufferSize = checked((int)Math.Min(maxReceivedMessageSize, int.MaxValue))
};
nativeShape.ReaderQuotas.MaxArrayLength = nativeShape.MaxBufferSize;
nativeShape.Security.Transport.ClientCredentialType = TcpClientCredentialType.Windows;
nativeShape.Security.Transport.ProtectionLevel = ProtectionLevel.None;
BindingElementCollection elements = nativeShape.CreateBindingElements();
for (int i = 0; i < elements.Count; i++)
{
if (elements[i] is MessageEncodingBindingElement encoding)
{
elements[i] = new MdasMessageEncodingBindingElement(encoding);
break;
}
}
return new CustomBinding(elements)
{
CloseTimeout = timeout,
OpenTimeout = timeout,
ReceiveTimeout = timeout,
SendTimeout = timeout
};
}
public static Binding CreateMdasNetTcpCertificateBinding(TimeSpan timeout, long maxReceivedMessageSize = 64 * 1024 * 1024)
{
NetTcpBinding nativeShape = new(SecurityMode.Transport)
{
MaxReceivedMessageSize = maxReceivedMessageSize,
MaxBufferSize = checked((int)Math.Min(maxReceivedMessageSize, int.MaxValue))
};
nativeShape.ReaderQuotas.MaxArrayLength = nativeShape.MaxBufferSize;
nativeShape.Security.Transport.ClientCredentialType = TcpClientCredentialType.None;
BindingElementCollection elements = nativeShape.CreateBindingElements();
for (int i = 0; i < elements.Count; i++)
{
if (elements[i] is MessageEncodingBindingElement encoding)
{
elements[i] = new MdasMessageEncodingBindingElement(encoding);
break;
}
}
return new CustomBinding(elements)
{
CloseTimeout = timeout,
OpenTimeout = timeout,
ReceiveTimeout = timeout,
SendTimeout = timeout
};
}
[SupportedOSPlatform("windows")]
public static Binding CreateMdasNetNamedPipeBinding(TimeSpan timeout, int maxBufferSize = 64 * 1024 * 1024)
{
NetNamedPipeBinding nativeShape = new()
{
MaxBufferSize = maxBufferSize,
MaxReceivedMessageSize = maxBufferSize
};
nativeShape.Security.Mode = NetNamedPipeSecurityMode.None;
nativeShape.ReaderQuotas.MaxArrayLength = maxBufferSize;
BindingElementCollection elements = nativeShape.CreateBindingElements();
for (int i = 0; i < elements.Count; i++)
{
if (elements[i] is MessageEncodingBindingElement encoding)
{
elements[i] = new MdasMessageEncodingBindingElement(encoding);
break;
}
}
return new CustomBinding(elements)
{
CloseTimeout = timeout,
OpenTimeout = timeout,
ReceiveTimeout = timeout,
SendTimeout = timeout
};
}
[SupportedOSPlatform("windows")]
public static (Binding HistoryBinding, EndpointAddress HistoryEndpoint, Binding RetrievalBinding, EndpointAddress RetrievalEndpoint) CreateBindingPair(
HistorianClientOptions options)
{
TimeSpan timeout = options.RequestTimeout;
return options.Transport switch
{
HistorianTransport.LocalPipe => (
CreateMdasNetNamedPipeBinding(timeout),
CreatePipeEndpointAddress(options.Host, HistorianWcfServiceNames.History),
CreateMdasNetNamedPipeBinding(timeout),
CreatePipeEndpointAddress(options.Host, HistorianWcfServiceNames.Retrieval)),
HistorianTransport.RemoteTcpIntegrated => (
CreateMdasNetTcpWindowsBinding(timeout),
CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.HistoryIntegrated),
CreateMdasNetTcpBinding(timeout),
CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.Retrieval)),
HistorianTransport.RemoteTcpCertificate => (
CreateMdasNetTcpCertificateBinding(timeout),
CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.HistoryCertificate),
CreateMdasNetTcpBinding(timeout),
CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.Retrieval)),
_ => throw new NotSupportedException($"Transport {options.Transport} is not supported.")
};
}
public static EndpointAddress CreateEndpointAddress(string host, int port, string serviceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
return new EndpointAddress($"{Scheme}://{host}:{port}/{serviceName}");
}
public static EndpointAddress CreatePipeEndpointAddress(string host, string serviceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
return new EndpointAddress($"net.pipe://{host}/{serviceName}");
}
}
@@ -0,0 +1,448 @@
using System.Buffers.Binary;
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf.Contracts;
namespace AVEVA.Historian.Client.Wcf;
/// <remarks>
/// Mirrors HistorianWcfReadOrchestrator but targets IRetrievalServiceContract4 for the event flow.
/// Event row buffer layout is undecoded as of this pass — when StartEventQuery succeeds, this
/// orchestrator returns an empty enumeration but logs the row-buffer length via the
/// <see cref="LastResultBufferLength"/> diagnostic so a follow-up capture can decode the wire shape.
/// </remarks>
[SupportedOSPlatform("windows")]
internal sealed class HistorianWcfEventOrchestrator
{
private const int OpenConnection3MinResponseLength = 5;
private const int CredentialBlockSizeBytes = 1026;
private const int MaxValClRounds = 8;
private const string ClientNodeNameFallback = "AVEVA.Historian.Client";
private const string ClientDataSourceId = "2020.406.2652.2";
private const string ClientDllVersionString = "2020.406.2652.2";
private const byte NativeClientType = 4;
private const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
private const byte NativeClientCommonInfoFormatVersion = 4;
private const ushort NativeHcalVersion = 17;
private const uint NativeClientVersionInt = 999_999;
private const ushort NativeOpen2ClientVersion = 9;
/// <summary>
/// Documented native CM_EVENT default tag id used by aahClientManaged.dll
/// CreateDefaultEventTag → ConvertEventTagToTagMetadata. Registering this tag via
/// IHistoryServiceContract2.RegisterTags2 before StartEventQuery causes the server
/// to subscribe the session to CM_EVENT events; without it,
/// GetNextEventQueryResultBuffer returns native error type=4 code=85 (0x55).
/// </summary>
private static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321");
private readonly HistorianClientOptions _options;
public HistorianWcfEventOrchestrator(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>Diagnostic: length of the most recent event-row result buffer the server sent.</summary>
public int LastResultBufferLength { get; private set; }
/// <summary>Diagnostic: type+code description of the most recent error/terminal buffer.</summary>
public string LastErrorBufferDescription { get; private set; } = string.Empty;
/// <summary>Diagnostic: handle string passed to EnsT2.</summary>
public static string LastEnsT2Handle { get; private set; } = string.Empty;
/// <summary>Diagnostic: SHA256 of the CTagMetadata payload sent to EnsT2.</summary>
public static string LastEnsT2PayloadSha256 { get; private set; } = string.Empty;
/// <summary>Diagnostic: native return code from the prerequisite UpdC3 call.</summary>
public static uint LastUpdC3ReturnCode { get; private set; }
/// <summary>Diagnostic: native return code from the prerequisite RTag2 call.</summary>
public static uint LastRTag2ReturnCode { get; private set; }
public async IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
DateTime startUtc,
DateTime endUtc,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
{
throw new ProtocolEvidenceMissingException(
"Managed event flow currently requires IntegratedSecurity or an explicit UserName + Password.");
}
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<HistorianEvent> events = await Task.Run(
() => RunEventChain(startUtc, endUtc, cancellationToken),
cancellationToken).ConfigureAwait(false);
foreach (HistorianEvent evt in events)
{
cancellationToken.ThrowIfCancellationRequested();
yield return evt;
}
}
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Status);
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Transaction);
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, cancellationToken,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
additionalSetup: (historyChannel, context) =>
AddCmEventTagViaAddT(historyChannel, context, histBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, cancellationToken);
}
private List<HistorianEvent> RunEventQuery(
Binding binding,
EndpointAddress retrievalEndpoint,
uint clientHandle,
DateTime startUtc,
DateTime endUtc,
CancellationToken cancellationToken)
{
ChannelFactory<IRetrievalServiceContract4> factory = new(binding, retrievalEndpoint);
try
{
IRetrievalServiceContract4 channel = factory.CreateChannel();
ICommunicationObject channelCo = (ICommunicationObject)channel;
try
{
channel.GetInterfaceVersion(out _);
uint isAllowedReturn = channel.IsOriginalAllowed(clientHandle, out bool isAllowed);
if (isAllowedReturn != 0 || !isAllowed)
{
throw new InvalidOperationException(
$"Retr.IsOriginalAllowed denied the connection (return={isAllowedReturn}, isAllowed={isAllowed}).");
}
IReadOnlyList<HistorianEventQueryAttempt> attempts = HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
startUtc.ToUniversalTime(),
endUtc.ToUniversalTime(),
eventCount: 5);
byte[] requestBuffer = attempts[0].RequestBuffer;
uint queryHandle = 0;
bool startSuccess = channel.StartEventQuery(
clientHandle,
HistorianEventQueryProtocol.QueryRequestTypeEvent,
checked((uint)requestBuffer.Length),
requestBuffer,
out _,
out _,
ref queryHandle,
out _,
out byte[] startError);
startError ??= [];
if (!startSuccess)
{
throw new InvalidOperationException(
$"Retr.StartEventQuery failed (errorLen={startError.Length}, error5={DescribeNativeError(startError)}).");
}
List<HistorianEvent> events = [];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
bool nextSuccess = channel.GetNextEventQueryResultBuffer(
clientHandle,
queryHandle,
out _,
out byte[] resultBuffer,
out _,
out byte[] errorBuffer);
resultBuffer ??= [];
errorBuffer ??= [];
LastResultBufferLength = resultBuffer.Length;
LastErrorBufferDescription = DescribeNativeError(errorBuffer);
// Any 5-byte type=4 error is treated as a soft terminal so the chain can
// surface evidence even when an unfamiliar code (e.g. 85 / 0x55 observed
// on first end-to-end runs without an event-tag registration step) blocks
// row enumeration. Code 30 (NoMoreData) is the canonical terminal; other
// codes mean "stop reading and let the caller see the diagnostic". When
// nextSuccess is false the server signaled hard failure; if there is also
// a 5-byte type=4 error buffer we still return the buffer length as
// evidence and surface via LastErrorBufferDescription rather than throw.
if (errorBuffer.Length == 5 && errorBuffer[0] == 4)
{
return events;
}
if (!nextSuccess)
{
throw new InvalidOperationException(
$"Retr.GetNextEventQueryResultBuffer failed (errorLen={errorBuffer.Length}, error5={DescribeNativeError(errorBuffer)}).");
}
if (resultBuffer.Length > 0)
{
events.AddRange(HistorianEventRowProtocol.Parse(resultBuffer));
}
if (resultBuffer.Length == 0 && errorBuffer.Length == 0)
{
return events;
}
}
}
finally
{
CloseChannelSafely(channelCo);
}
}
finally
{
CloseFactorySafely(factory);
}
}
/// <summary>Diagnostic: native return code from the last AddT(CM_EVENT) call.</summary>
public static uint LastAddReturnCode { get; private set; }
/// <summary>Diagnostic: byte length of the AddT response output buffer.</summary>
public static int LastAddOutputLength { get; private set; }
/// <remarks>
/// Calls <c>IHistoryServiceContract.AddTags</c> with the documented CM_EVENT CTagMetadata
/// payload. The chain now reaches the server's AddT handler (a real WCF response is
/// returned rather than the previous parameter-binding failure) but currently receives
/// native return code 76 against this Historian. Combined with code 85 from
/// <c>GetNextEventQueryResultBuffer</c>, two specific server rejections remain to decode
/// before live event reads return rows. The orchestrator continues regardless so the
/// caller can see the chain outcome via <see cref="LastAddReturnCode"/>,
/// <see cref="LastResultBufferLength"/>, and <see cref="LastErrorBufferDescription"/>.
/// Next concrete step: instrument <c>Wcf.AddT.Request</c> on a successful native event
/// run and compare byte-for-byte against this serialiser's output.
/// </remarks>
/// <remarks>
/// Replays the native event-tag registration sequence captured via the
/// instrument-wcf-writemessage IL-rewrite tool: UpdC3 (UpdateClientStatus3) → RTag2
/// (RegisterTags2 with the CM_EVENT tag id) → EnsT2 (EnsureTags2 with the full
/// CTagMetadata blob). The 81-byte UpdC3 status blob and 24-byte RTag2 buffer are
/// captured byte-for-byte from a successful native event read; the EnsT2 payload is
/// regenerated by <see cref="HistorianAddTagsProtocol.SerializeCmEventCTagMetadata"/>.
/// The Stat-service queries the native client also issues (Stat/GetV, Stat/GETHI,
/// Stat/GetSystemParameter for AllowOriginals/HistorianPartner/HistorianVersion/
/// MaxCyclicStorageTimeout/RealTimeWindow/FutureTimeThreshold/AllowRenameTags) appear
/// informational and are skipped here.
/// </remarks>
private static void AddCmEventTagViaAddT(
IHistoryServiceContract2 historyChannel,
HistorianWcfAuthChainHelper.OpenConnectionContext context,
Binding statusBinding,
EndpointAddress statusEndpoint,
EndpointAddress transactionEndpoint,
Binding retrievalBinding,
EndpointAddress retrievalEndpoint)
{
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
LastEnsT2Handle = handle;
ChannelFactory<IStatusServiceContract2> statusFactory = new(statusBinding, statusEndpoint);
IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
ICommunicationObject statusCo = (ICommunicationObject)statusChannel;
ChannelFactory<ITransactionServiceContract> transactionFactory = new(statusBinding, transactionEndpoint);
ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
ICommunicationObject transactionCo = (ICommunicationObject)transactionChannel;
ChannelFactory<IRetrievalServiceContract4> retrievalFactory = new(retrievalBinding, retrievalEndpoint);
IRetrievalServiceContract4 retrievalChannel = retrievalFactory.CreateChannel();
ICommunicationObject retrievalCo = (ICommunicationObject)retrievalChannel;
try
{
// Replays the discovery dance the native event flow runs between Open2 and EnsT2,
// captured byte-for-byte via instrument-wcf-{write,read}message. Best-effort —
// individual calls may fail on this server; the chain continues regardless because
// the goal is to put the server-side session into the state EnsT2 expects.
TryRun(() => statusChannel.GetInterfaceVersion(out _));
TryRun(() => statusChannel.GetInterfaceVersion(out _));
byte[] historianVersionRequest = BuildGetHistorianInfoRequest("HistorianVersion");
TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
byte[] clientStatus = BuildUpdC3ClientStatusBlob();
bool updSuccess = historyChannel.UpdateClientStatus3(
handle: handle,
clientStatusSize: (uint)clientStatus.Length,
clientStatus: ref clientStatus,
serverStatusSize: out _,
serverStatus: out _,
errorSize: out _,
errorBuffer: out _);
LastUpdC3ReturnCode = updSuccess ? 0u : 1u;
// Records 11-16: 6 system-parameter queries before RTag2.
foreach (string parameterName in NativeStatusParametersBeforeRTag2)
{
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _));
}
byte[] registerBuffer = BuildRTag2CmEventInputBuffer();
bool registerSuccess = historyChannel.RegisterTags2(
handle: handle,
elementCount: 1,
inputBuffer: registerBuffer,
outputBuffer: out _,
errorBuffer: out _);
LastRTag2ReturnCode = registerSuccess ? 0u : 1u;
// Record 18: one more system-parameter query after RTag2 before EnsT2.
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, "AllowRenameTags", out _, out _, out _));
// Records 19-21: cross-service version probes the native client makes between
// RTag2 and EnsT2. They likely register the client with each service's session
// table; without them EnsT2 may reject the session.
TryRun(() => transactionChannel.GetInterfaceVersion(out _));
TryRun(() => statusChannel.GetInterfaceVersion(out _));
TryRun(() => retrievalChannel.GetInterfaceVersion(out _));
byte[] payload = HistorianAddTagsProtocol.SerializeCmEventCTagMetadata(DateTime.UtcNow);
using (var sha = System.Security.Cryptography.SHA256.Create())
{
byte[] hash = sha.ComputeHash(payload);
LastEnsT2PayloadSha256 = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
}
bool ensureSuccess = historyChannel.EnsureTags2(
handle: handle,
elementCount: 1,
inputBuffer: payload,
outputBuffer: out byte[] addOutput,
errorBuffer: out _);
LastAddReturnCode = ensureSuccess ? 0u : 1u;
LastAddOutputLength = addOutput?.Length ?? 0;
}
catch (Exception ex)
{
LastAddReturnCode = 0xFFFFFFFFu;
LastAddOutputLength = 0;
_ = ex;
}
finally
{
CloseChannelSafely(retrievalCo);
CloseFactorySafely(retrievalFactory);
CloseChannelSafely(transactionCo);
CloseFactorySafely(transactionFactory);
CloseChannelSafely(statusCo);
CloseFactorySafely(statusFactory);
}
}
private static readonly string[] NativeStatusParametersBeforeRTag2 =
[
"AllowOriginals",
"HistorianPartner",
"HistorianVersion",
"MaxCyclicStorageTimeout",
"RealTimeWindow",
"FutureTimeThreshold",
];
private static void TryRun(Action action)
{
try { action(); }
catch { }
}
/// <summary>
/// Native GETHI pRequestBuff layout for a parameter-name query: 8-byte header
/// (UInt16 0x6753 + UInt16 0x0002 + UInt32 nameLength) + UTF-16 LE chars (no
/// trailing null byte — observed truncated by 1 byte vs full UTF-16 in the
/// captured native bytes). Layout taken from
/// writemessage-capture-event-latest.ndjson record 8.
/// </summary>
private static byte[] BuildGetHistorianInfoRequest(string parameterName)
{
byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName);
// Native truncates the trailing high byte of the last UTF-16 char.
int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0;
byte[] buffer = new byte[8 + payloadLength];
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753);
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002);
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length);
Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength);
return buffer;
}
/// <summary>
/// 81-byte UpdC3 clientStatus blob captured from a native event read (record 10 of
/// writemessage-capture-event-latest.ndjson). Layout: 0x02 0x01 + 76 zero bytes +
/// uint32(0x0000001E). The trailing 30 is likely an interval / timeout in seconds; all
/// other observed fields are zero for a fresh session.
/// </summary>
private static byte[] BuildUpdC3ClientStatusBlob()
{
byte[] blob = new byte[81];
blob[0] = 0x02;
blob[1] = 0x01;
blob[77] = 0x1E;
return blob;
}
/// <summary>
/// 24-byte RTag2 pInBuff captured from a native event read (record 17). Layout:
/// 8-byte header (0x50 0x67 0x02 0x00 + uint32 element count = 1) + 16-byte tag id GUID.
/// </summary>
private static byte[] BuildRTag2CmEventInputBuffer()
{
byte[] buffer = new byte[24];
buffer[0] = 0x50;
buffer[1] = 0x67;
buffer[2] = 0x02;
buffer[3] = 0x00;
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), 1u);
CmEventTagId.ToByteArray().CopyTo(buffer.AsSpan(8, 16));
return buffer;
}
private static string DescribeNativeError(byte[] errorBuffer)
{
if (errorBuffer.Length < 5)
{
return "<short>";
}
byte type = errorBuffer[0];
uint code = BinaryPrimitives.ReadUInt32LittleEndian(errorBuffer.AsSpan(1, 4));
return $"type={type} code={code} (0x{code:X})";
}
private static void CloseChannelSafely(ICommunicationObject channel)
{
try
{
if (channel.State == CommunicationState.Faulted) channel.Abort();
else channel.Close();
}
catch { try { channel.Abort(); } catch { } }
}
private static void CloseFactorySafely<TChannel>(ChannelFactory<TChannel> factory)
{
try
{
if (factory.State == CommunicationState.Faulted) factory.Abort();
else factory.Close();
}
catch { try { factory.Abort(); } catch { } }
}
}
@@ -0,0 +1,115 @@
using System.ServiceModel;
using AVEVA.Historian.Client.Wcf.Contracts;
namespace AVEVA.Historian.Client.Wcf;
internal static class HistorianWcfProbe
{
public static async Task<bool> ProbeAsync(HistorianClientOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
TimeSpan timeout = options.ConnectTimeout > TimeSpan.Zero
? options.ConnectTimeout
: TimeSpan.FromSeconds(5);
return await Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
WcfServiceVersion history = ProbeService<IHistoryServiceContract>(
options,
HistorianWcfServiceNames.History,
static channel =>
{
uint returnCode = channel.GetInterfaceVersion(out uint version);
return new WcfServiceVersion(returnCode, version);
},
timeout);
WcfServiceVersion retrieval = ProbeService<IRetrievalServiceContract>(
options,
HistorianWcfServiceNames.Retrieval,
static channel =>
{
uint returnCode = channel.GetInterfaceVersion(out uint version);
return new WcfServiceVersion(returnCode, version);
},
timeout);
WcfServiceVersion status = ProbeService<IStatusServiceContract>(
options,
HistorianWcfServiceNames.Status,
static channel =>
{
uint returnCode = channel.GetInterfaceVersion(out uint version);
return new WcfServiceVersion(returnCode, version);
},
timeout);
return history.ReturnCode == 0
&& history.InterfaceVersion > 0
&& retrieval.ReturnCode == 0
&& retrieval.InterfaceVersion > 0
&& status.ReturnCode == 0;
}, cancellationToken).ConfigureAwait(false);
}
private static WcfServiceVersion ProbeService<TContract>(
HistorianClientOptions options,
string serviceName,
Func<TContract, WcfServiceVersion> call,
TimeSpan timeout)
where TContract : class
{
ChannelFactory<TContract>? factory = null;
TContract? channel = null;
try
{
factory = new ChannelFactory<TContract>(
HistorianWcfBindingFactory.CreateMdasNetTcpBinding(timeout),
HistorianWcfBindingFactory.CreateEndpointAddress(options.Host, options.Port, serviceName));
factory.Open();
channel = factory.CreateChannel();
if (channel is IClientChannel clientChannel)
{
clientChannel.Open();
}
return call(channel);
}
finally
{
AbortOrClose(channel);
AbortOrClose(factory);
}
}
private static void AbortOrClose(object? communicationObject)
{
if (communicationObject is not ICommunicationObject clientChannel)
{
return;
}
try
{
if (clientChannel.State == CommunicationState.Faulted)
{
clientChannel.Abort();
}
else
{
clientChannel.Close();
}
}
catch
{
clientChannel.Abort();
}
}
private readonly record struct WcfServiceVersion(uint ReturnCode, uint InterfaceVersion);
}
@@ -0,0 +1,459 @@
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf.Contracts;
namespace AVEVA.Historian.Client.Wcf;
[SupportedOSPlatform("windows")]
internal sealed class HistorianWcfReadOrchestrator
{
private const ushort StartQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData;
private const int CredentialBlockSizeBytes = 1026;
private const int OpenConnection3MinResponseLength = 5;
private const string ClientNodeNameFallback = "AVEVA.Historian.Client";
private const string ClientDataSourceId = "2020.406.2652.2";
private const string ClientDllVersionString = "2020.406.2652.2";
private const byte NativeClientType = 4;
private const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
private const byte NativeClientCommonInfoFormatVersion = 4;
private const ushort NativeHcalVersion = 17;
private const uint NativeClientVersionInt = 999_999;
private const ushort NativeOpen2ClientVersion = 9;
private const int MaxValClRounds = 8;
private readonly HistorianClientOptions _options;
public HistorianWcfReadOrchestrator(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public async IAsyncEnumerable<HistorianSample> ReadRawAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
int maxValues,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ValidateTransportAndAuth();
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<HistorianSample> rows = await Task.Run(() => RunRawChain(tag, startUtc, endUtc, maxValues, cancellationToken), cancellationToken).ConfigureAwait(false);
foreach (HistorianSample sample in rows)
{
cancellationToken.ThrowIfCancellationRequested();
yield return sample;
}
}
public async IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
Models.RetrievalMode mode,
TimeSpan interval,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ValidateTransportAndAuth();
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<HistorianAggregateSample> rows = await Task.Run(
() => RunAggregateChain(tag, startUtc, endUtc, mode, interval, cancellationToken),
cancellationToken).ConfigureAwait(false);
foreach (HistorianAggregateSample sample in rows)
{
cancellationToken.ThrowIfCancellationRequested();
yield return sample;
}
}
public async Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
string tag,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken)
{
ValidateTransportAndAuth();
cancellationToken.ThrowIfCancellationRequested();
return await Task.Run(() => RunAtTimeChain(tag, timestampsUtc, cancellationToken), cancellationToken).ConfigureAwait(false);
}
private void ValidateTransportAndAuth()
{
if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
{
throw new ProtocolEvidenceMissingException(
"Managed read flow currently requires IntegratedSecurity or an explicit UserName + Password.");
}
}
private List<HistorianSample> RunRawChain(
string tag,
DateTime startUtc,
DateTime endUtc,
int maxValues,
CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(_options, histBinding, histEndpoint, contextKey, cancellationToken);
return RunQuery(retrBinding, retrEndpoint, clientHandle, tag, startUtc, endUtc, maxValues, cancellationToken);
}
private List<HistorianAggregateSample> RunAggregateChain(
string tag,
DateTime startUtc,
DateTime endUtc,
Models.RetrievalMode mode,
TimeSpan interval,
CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(_options, histBinding, histEndpoint, contextKey, cancellationToken);
return RunAggregateQuery(retrBinding, retrEndpoint, clientHandle, tag, startUtc, endUtc, mode, interval, cancellationToken);
}
private List<HistorianSample> RunAtTimeChain(
string tag,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken)
{
if (timestampsUtc.Count == 0)
{
return [];
}
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(_options, histBinding, histEndpoint, contextKey, cancellationToken);
List<HistorianSample> results = new(timestampsUtc.Count);
foreach (DateTime ts in timestampsUtc)
{
cancellationToken.ThrowIfCancellationRequested();
DateTime tsUtc = ts.ToUniversalTime();
DateTime windowStart = tsUtc - TimeSpan.FromTicks(1);
DateTime windowEnd = tsUtc + TimeSpan.FromTicks(1);
List<HistorianAggregateSample> aggregates = RunAggregateQuery(
retrBinding,
retrEndpoint,
clientHandle,
tag,
windowStart,
windowEnd,
Models.RetrievalMode.Interpolated,
TimeSpan.FromTicks(2),
cancellationToken);
if (aggregates.Count == 0)
{
continue;
}
HistorianAggregateSample chosen = aggregates[0];
results.Add(new HistorianSample(
TagName: chosen.TagName,
TimestampUtc: tsUtc,
NumericValue: chosen.Value,
StringValue: null,
Quality: chosen.Quality,
QualityDetail: chosen.QualityDetail,
OpcQuality: chosen.OpcQuality,
PercentGood: 100));
}
return results;
}
private List<HistorianSample> RunQuery(
Binding binding,
EndpointAddress retrievalEndpoint,
uint clientHandle,
string tag,
DateTime startUtc,
DateTime endUtc,
int maxValues,
CancellationToken cancellationToken)
{
ChannelFactory<IRetrievalServiceContract2> retrievalFactory = new(binding, retrievalEndpoint);
try
{
IRetrievalServiceContract2 retrievalChannel = retrievalFactory.CreateChannel();
ICommunicationObject retrievalChannelCo = (ICommunicationObject)retrievalChannel;
try
{
retrievalChannel.GetInterfaceVersion(out _);
uint isAllowedReturn = retrievalChannel.IsOriginalAllowed(clientHandle, out bool isAllowed);
if (isAllowedReturn != 0 || !isAllowed)
{
throw new InvalidOperationException(
$"Retr.IsOriginalAllowed denied the connection (return={isAllowedReturn}, isAllowed={isAllowed}).");
}
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(BuildDataQueryRequest(tag, startUtc, endUtc, maxValues));
uint queryHandle = 0;
bool startSuccess = retrievalChannel.StartQuery2(
clientHandle,
StartQueryRequestType,
checked((uint)requestBuffer.Length),
requestBuffer,
out _,
out _,
ref queryHandle,
out _,
out byte[] startError);
startError ??= [];
if (!startSuccess)
{
throw new InvalidOperationException(
$"Retr.StartQuery2 failed (errorLen={startError.Length}).");
}
List<HistorianSample> samples = [];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
bool nextSuccess = retrievalChannel.GetNextQueryResultBuffer2(
clientHandle,
queryHandle,
out _,
out byte[] resultBuffer,
out _,
out byte[] errorBuffer);
resultBuffer ??= [];
errorBuffer ??= [];
if (!nextSuccess)
{
throw new InvalidOperationException(
$"Retr.GetNextQueryResultBuffer2 failed (errorLen={errorBuffer.Length}).");
}
if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(resultBuffer, errorBuffer, out IReadOnlyList<HistorianSample> rows, out bool hasMoreData))
{
throw new InvalidOperationException(
$"Retr.GetNextQueryResultBuffer2 returned an unparsable result buffer (length={resultBuffer.Length}).");
}
foreach (HistorianSample sample in rows)
{
samples.Add(sample);
if (samples.Count >= maxValues)
{
return samples;
}
}
if (!hasMoreData)
{
return samples;
}
}
}
finally
{
CloseChannelSafely(retrievalChannelCo);
}
}
finally
{
CloseFactorySafely(retrievalFactory);
}
}
private List<HistorianAggregateSample> RunAggregateQuery(
Binding binding,
EndpointAddress retrievalEndpoint,
uint clientHandle,
string tag,
DateTime startUtc,
DateTime endUtc,
Models.RetrievalMode mode,
TimeSpan interval,
CancellationToken cancellationToken)
{
ChannelFactory<IRetrievalServiceContract2> retrievalFactory = new(binding, retrievalEndpoint);
try
{
IRetrievalServiceContract2 retrievalChannel = retrievalFactory.CreateChannel();
ICommunicationObject retrievalChannelCo = (ICommunicationObject)retrievalChannel;
try
{
retrievalChannel.GetInterfaceVersion(out _);
uint isAllowedReturn = retrievalChannel.IsOriginalAllowed(clientHandle, out bool isAllowed);
if (isAllowedReturn != 0 || !isAllowed)
{
throw new InvalidOperationException(
$"Retr.IsOriginalAllowed denied the connection (return={isAllowedReturn}, isAllowed={isAllowed}).");
}
HistorianDataQueryRequest request = BuildAggregateQueryRequest(tag, startUtc, endUtc, mode, interval);
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
uint queryHandle = 0;
bool startSuccess = retrievalChannel.StartQuery2(
clientHandle,
StartQueryRequestType,
checked((uint)requestBuffer.Length),
requestBuffer,
out _,
out _,
ref queryHandle,
out _,
out byte[] startError);
startError ??= [];
if (!startSuccess)
{
throw new InvalidOperationException(
$"Retr.StartQuery2 (aggregate {mode}) failed (errorLen={startError.Length}).");
}
List<HistorianAggregateSample> samples = [];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
bool nextSuccess = retrievalChannel.GetNextQueryResultBuffer2(
clientHandle,
queryHandle,
out _,
out byte[] resultBuffer,
out _,
out byte[] errorBuffer);
resultBuffer ??= [];
errorBuffer ??= [];
if (!nextSuccess)
{
throw new InvalidOperationException(
$"Retr.GetNextQueryResultBuffer2 (aggregate {mode}) failed (errorLen={errorBuffer.Length}).");
}
if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferAggregateRows(
resultBuffer,
errorBuffer,
mode,
interval,
out IReadOnlyList<HistorianAggregateSample> rows,
out bool hasMoreData))
{
throw new InvalidOperationException(
$"Retr.GetNextQueryResultBuffer2 (aggregate {mode}) returned an unparsable buffer (length={resultBuffer.Length}).");
}
samples.AddRange(rows);
if (!hasMoreData)
{
return samples;
}
}
}
finally
{
CloseChannelSafely(retrievalChannelCo);
}
}
finally
{
CloseFactorySafely(retrievalFactory);
}
}
private static HistorianDataQueryRequest BuildDataQueryRequest(string tag, DateTime startUtc, DateTime endUtc, int maxValues)
{
return new HistorianDataQueryRequest(
TagNames: [tag],
StartUtc: startUtc.ToUniversalTime(),
EndUtc: endUtc.ToUniversalTime(),
MaxStates: checked((ushort)Math.Min(maxValues, ushort.MaxValue)),
BatchSize: 1,
Option: string.Empty);
}
private static HistorianDataQueryRequest BuildAggregateQueryRequest(
string tag,
DateTime startUtc,
DateTime endUtc,
Models.RetrievalMode mode,
TimeSpan interval)
{
uint queryType = MapRetrievalModeToQueryType(mode);
return new HistorianDataQueryRequest(
TagNames: [tag],
StartUtc: startUtc.ToUniversalTime(),
EndUtc: endUtc.ToUniversalTime(),
MaxStates: 0,
BatchSize: 1,
Option: string.Empty)
{
QueryType = queryType,
Resolution = interval,
AggregationType = MapRetrievalModeToAggregationType(mode)
};
}
private static uint MapRetrievalModeToQueryType(Models.RetrievalMode mode) => mode switch
{
Models.RetrievalMode.Full => 2,
Models.RetrievalMode.Interpolated => 3,
Models.RetrievalMode.TimeWeightedAverage => 5,
Models.RetrievalMode.Cyclic => 4,
_ => throw new ProtocolEvidenceMissingException($"Retrieval mode {mode} not yet mapped to a Historian QueryType.")
};
private static uint MapRetrievalModeToAggregationType(Models.RetrievalMode mode) => mode switch
{
Models.RetrievalMode.TimeWeightedAverage => 0,
Models.RetrievalMode.Interpolated => 3,
_ => 3
};
private static void CloseChannelSafely(ICommunicationObject channel)
{
try
{
if (channel.State == CommunicationState.Faulted)
{
channel.Abort();
}
else
{
channel.Close();
}
}
catch
{
try { channel.Abort(); } catch { /* swallow */ }
}
}
private static void CloseFactorySafely<TChannel>(ChannelFactory<TChannel> factory)
{
try
{
if (factory.State == CommunicationState.Faulted)
{
factory.Abort();
}
else
{
factory.Close();
}
}
catch
{
try { factory.Abort(); } catch { /* swallow */ }
}
}
}
@@ -0,0 +1,20 @@
namespace AVEVA.Historian.Client.Wcf;
internal static class HistorianWcfServiceNames
{
public const string Namespace = "aa";
public const string History = "Hist";
public const string HistoryCertificate = "HistCert";
public const string HistoryIntegrated = "Hist-Integrated";
public const string Retrieval = "Retr";
public const string Storage = "Storage";
public const string Status = "Stat";
public const string Transaction = "Trx";
}
@@ -0,0 +1,118 @@
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf.Contracts;
namespace AVEVA.Historian.Client.Wcf;
[SupportedOSPlatform("windows")]
internal static class HistorianWcfStatusClient
{
public static Task<string?> GetSystemParameterAsync(
HistorianClientOptions options,
string parameterName,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
return Task.Run(() => GetSystemParameter(options, parameterName), cancellationToken);
}
public static Task<HistorianConnectionStatus> GetConnectionStatusAsync(
HistorianClientOptions options,
CancellationToken cancellationToken)
{
return Task.Run(() => SynthesizeConnectionStatus(options), cancellationToken);
}
public static Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(
HistorianClientOptions options,
CancellationToken cancellationToken)
{
return Task.Run(() => SynthesizeStoreForwardStatus(options), cancellationToken);
}
private static string? GetSystemParameter(HistorianClientOptions options, string parameterName)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(options);
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(options.Host, HistorianWcfServiceNames.Status);
string? value = null;
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
options, histBinding, histEndpoint, contextKey, CancellationToken.None,
additionalSetup: (_, context) => value = QuerySystemParameter(histBinding, statusEndpoint, context.ClientHandle, parameterName));
return value;
}
private static string? QuerySystemParameter(Binding statusBinding, EndpointAddress statusEndpoint, uint clientHandle, string parameterName)
{
ChannelFactory<IStatusServiceContract2> factory = new(statusBinding, statusEndpoint);
IStatusServiceContract2 channel = factory.CreateChannel();
ICommunicationObject co = (ICommunicationObject)channel;
try
{
bool ok = channel.GetSystemParameter(clientHandle, parameterName, out string parameterValue, out _, out _);
return ok ? parameterValue : null;
}
finally
{
try { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } catch { try { co.Abort(); } catch { } }
try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { try { factory.Abort(); } catch { } }
}
}
/// <remarks>
/// AVEVA's native <c>HistorianAccess.GetConnectionStatus</c> reads local C++
/// <c>HistorianClient</c> state (no WCF op exists for it). We synthesize an equivalent
/// by attempting an authenticated session open: a successful auth+open implies
/// <c>ConnectedToServer = true</c>. Store-forward and partner-connection state are not
/// observable from a single client probe and remain false.
/// </remarks>
private static HistorianConnectionStatus SynthesizeConnectionStatus(HistorianClientOptions options)
{
bool connected;
string? error = null;
try
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(options);
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
options, histBinding, histEndpoint, contextKey, CancellationToken.None);
connected = true;
}
catch (Exception ex)
{
connected = false;
error = $"{ex.GetType().Name}: {ex.Message}";
}
return new HistorianConnectionStatus(
ServerName: options.Host,
Pending: false,
ErrorOccurred: !connected,
Error: error,
ConnectedToServer: connected,
ConnectedToServerStorage: connected,
ConnectedToStoreForward: false,
ConnectionKind: HistorianConnectionKind.Process);
}
/// <remarks>
/// Native <c>HistorianAccess.GetStoreForwardStatus</c> is also client-side state.
/// Without a local store-forward sidecar to probe, we report defaults: not pending,
/// no error, no data stored, not actively storing. Connection kind is Process by
/// convention (event-only sessions are uncommon for this status helper).
/// </remarks>
private static HistorianStoreForwardStatus SynthesizeStoreForwardStatus(HistorianClientOptions options)
{
return new HistorianStoreForwardStatus(
ServerName: options.Host,
Pending: false,
ErrorOccurred: false,
Error: null,
DataStored: false,
Storing: false,
ConnectionKind: HistorianConnectionKind.Process);
}
}
@@ -0,0 +1,399 @@
using System.Net;
using System.Runtime.CompilerServices;
using System.ServiceModel;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf.Contracts;
namespace AVEVA.Historian.Client.Wcf;
internal static class HistorianWcfTagClient
{
public static async IAsyncEnumerable<string> BrowseTagNamesAsync(
HistorianClientOptions options,
string filter,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
IReadOnlyList<string> tagNames = await Task.Run(
() => BrowseTagNames(options, filter),
cancellationToken).ConfigureAwait(false);
foreach (string tagName in tagNames)
{
cancellationToken.ThrowIfCancellationRequested();
yield return tagName;
}
}
public static Task<HistorianTagMetadata?> GetTagMetadataAsync(
HistorianClientOptions options,
string tag,
CancellationToken cancellationToken)
{
return Task.Run(() => GetTagMetadata(options, tag), cancellationToken);
}
private static IReadOnlyList<string> BrowseTagNames(HistorianClientOptions options, string filter)
{
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
uint startReturnCode = session.RetrievalChannel.StartLikeTagNameSearch(
session.Handle,
NormalizeLikeFilter(filter),
(uint)InsqlTagType.All,
isNotLike: false);
if (startReturnCode != 0)
{
throw new InvalidOperationException($"StartLikeTagNameSearch failed with return code {startReturnCode}.");
}
List<string> tagNames = [];
bool isMore;
do
{
uint getReturnCode = session.RetrievalChannel.GetLikeTagnames(
session.Handle,
out byte[] tagNameBuffer,
out uint tagNameBufferSize,
out isMore);
if (getReturnCode != 0)
{
throw new InvalidOperationException($"GetLikeTagnames failed with return code {getReturnCode}.");
}
if (tagNameBuffer.Length != tagNameBufferSize)
{
throw new InvalidDataException("GetLikeTagnames returned a buffer size that does not match the byte array length.");
}
tagNames.AddRange(HistorianTagQueryProtocol.ParseGetLikeTagNamesResponse(tagNameBuffer));
}
while (isMore);
return tagNames;
}
private static HistorianTagMetadata? GetTagMetadata(HistorianClientOptions options, string tag)
{
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
uint returnCode = session.RetrievalChannel.GetTagInfoFromName(
session.Handle,
tag,
out _,
out byte[] tagMetadata);
if (returnCode != 0)
{
return null;
}
if (tagMetadata.Length == 0)
{
return null;
}
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(tagMetadata);
return new HistorianTagMetadata(
parsed.TagName,
parsed.TagKey,
MapDataType(parsed.NativeDataTypeDescriptor));
}
/// <summary>
/// Reverse-engineering helper: returns the parsed tag-info response (including the raw
/// 4-byte native data-type descriptor) without dispatching through <see cref="MapDataType"/>.
/// Used by <c>TagMetadataDescriptorProbeTests</c> to discover descriptors for new tag
/// types so they can be added to the dispatch table.
/// </summary>
internal static HistorianTagInfoResponse GetTagInfoForDescriptorProbe(HistorianClientOptions options, string tag)
{
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
return GetTagInfoForDescriptorProbe(session, tag);
}
/// <summary>Bulk variant: probes many tags through a single session.</summary>
internal static IReadOnlyDictionary<string, HistorianTagInfoResponse?> GetTagInfosForDescriptorProbe(
HistorianClientOptions options,
IEnumerable<string> tags)
{
Dictionary<string, HistorianTagInfoResponse?> results = new(StringComparer.Ordinal);
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
foreach (string tag in tags)
{
try { results[tag] = GetTagInfoForDescriptorProbe(session, tag); }
catch { results[tag] = null; }
}
return results;
}
private static HistorianTagInfoResponse GetTagInfoForDescriptorProbe(WcfRetrievalSession session, string tag)
{
uint returnCode = session.RetrievalChannel.GetTagInfoFromName(
session.Handle,
tag,
out _,
out byte[] tagMetadata);
if (returnCode != 0 || tagMetadata.Length == 0)
{
throw new InvalidOperationException($"GetTagInfoFromName({tag}) returned code {returnCode}, {tagMetadata.Length} bytes.");
}
return HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(tagMetadata);
}
internal static string NormalizeLikeFilter(string filter)
{
return filter == "*" ? "%" : filter.Replace('*', '%');
}
/// <summary>
/// Decodes the 4-byte native data-type descriptor returned by <c>GetTagInfoFromName</c>.
/// Layout determined by probing live tags + reading the <c>CDataType</c> predicate IL
/// (<c>IsAnalog</c>, <c>IsDiscrete</c>, <c>IsString</c>, <c>IsWideString</c>,
/// <c>IsEvent</c>, <c>IsStruct</c>, <c>IsBoolean</c>, <c>IsConvertableToInt64</c>,
/// <c>IsConvertableToUInt64</c>, <c>IsConvertableToDouble</c>) in
/// <c>current/aahClientManaged.dll</c>:
/// <list type="bullet">
/// <item>byte 0 = 0x03 (descriptor format version)</item>
/// <item>byte 1 = tag-origin marker — observed 0xCF (system / built-in) and 0xC3 (user-created).</item>
/// <item>byte 2 = storage attribute byte — varies per tag (0x00 vs 0x04 observed for the same data type).</item>
/// <item><b>byte 3 = data-type code</b> (the load-bearing field; matches the native <c>CDataType</c> byte 0).</item>
/// </list>
/// Bit pattern of byte 3 (deduced from the predicate IL):
/// <list type="bullet">
/// <item>bit 0x80: extended/reserved marker — when set the type is treated specially (e.g., 0x81 = Boolean).</item>
/// <item>bit 0x40: wide-string variant (set for <see cref="HistorianDataType.DoubleByteString"/>, clear for <see cref="HistorianDataType.SingleByteString"/>).</item>
/// <item>bit 0x20: integer signed flag (UInt16=0x09 → Int16=0x29; UInt32=0x11 → Int32=0x31).</item>
/// <item>low 3 bits: type class — 1=numeric, 2=discrete/bool, 3=string, 4=event, 5=structure, 7=fixed-string.</item>
/// </list>
/// Type-code dispatch:
/// <list type="table">
/// <item><term>0x01</term><description><see cref="HistorianDataType.Float"/> — probed: SysDataAcqOverallItemsPerSec → 03 CF 00 01</description></item>
/// <item><term>0x02</term><description><see cref="HistorianDataType.Int1"/> (Discrete/Bool) — probed: SysClassicDataRedirector → 03 CF 00 02</description></item>
/// <item><term>0x03</term><description><see cref="HistorianDataType.SingleByteString"/> — IL inference (string class without bit 0x40)</description></item>
/// <item><term>0x04</term><description><see cref="HistorianDataType.Event"/> — IL inference (IsEvent low 3 bits == 4)</description></item>
/// <item><term>0x05</term><description><see cref="HistorianDataType.Structure"/> — IL inference (IsStruct low 3 bits == 5)</description></item>
/// <item><term>0x09</term><description><see cref="HistorianDataType.UInt2"/> — probed: SysCritErrCnt → 03 CF 00 09, SysTimeSec → 03 CF 04 09</description></item>
/// <item><term>0x11</term><description><see cref="HistorianDataType.UInt4"/> — probed: SysConfigStatus → 03 CF 04 11</description></item>
/// <item><term>0x21</term><description><see cref="HistorianDataType.Double"/> — IL inference (IsConvertableToDouble matches 33)</description></item>
/// <item><term>0x29</term><description><see cref="HistorianDataType.Int2"/> — IL inference (IsConvertableToInt64 matches 41 = signed UInt16 bit pattern)</description></item>
/// <item><term>0x31</term><description><see cref="HistorianDataType.Int4"/> — probed: OtOpcUaParityTest_001.Counter → 03 C3 00 31</description></item>
/// <item><term>0x43</term><description><see cref="HistorianDataType.DoubleByteString"/> — probed: SysString → 03 CF 00 43</description></item>
/// </list>
/// Extended dispatch (recovered from the same IL):
/// <list type="table">
/// <item><term>0x08</term><description><see cref="HistorianDataType.UInt1"/> — 1-byte unsigned (in IsConvertableToUInt64 list)</description></item>
/// <item><term>0x10</term><description><see cref="HistorianDataType.Guid"/> — 16-byte GUID (matches IsGuid)</description></item>
/// <item><term>0x18</term><description><see cref="HistorianDataType.FileTime"/> — Windows FILETIME (matches IsFileTime)</description></item>
/// <item><term>0x19</term><description><see cref="HistorianDataType.Int8"/> — 8-byte signed (in IsConvertableToInt64 list, follows Int16=0x29 / Int32=0x31)</description></item>
/// <item><term>0x39</term><description><see cref="HistorianDataType.UInt8"/> — 8-byte unsigned (in IsConvertableToUInt64 list, follows UInt16=0x09 / UInt32=0x11 with signed-bit set)</description></item>
/// <item><term>0x81</term><description><see cref="HistorianDataType.Int1"/> — Boolean extended form (matches IsBoolean's literal byte=129 check; same semantic as 0x02 Discrete)</description></item>
/// </list>
/// Code 0x38 also appears in <c>CDataType.IsConvertableToUInt64</c>'s allow-list but is
/// NEVER produced by any tag-creation path (verified by reading the IL of
/// <c>CDataType.InitializeAnalog</c>/<c>InitializeDiscrete</c>/<c>InitializeStruct</c>/
/// <c>InitializeString</c>, and by probing all 198 tags in a sample Runtime DB via the
/// <c>EnumerateAllTagDescriptorsAcrossOneSession</c> probe — 0x38 does not appear).
/// It is a value-side type used during data conversion / query result decoding, never a
/// tag descriptor; intentionally left unmapped so an unexpected 0x38 in a tag descriptor
/// throws <see cref="ProtocolEvidenceMissingException"/> rather than being silently
/// treated as <see cref="HistorianDataType.UInt8"/>.
/// </summary>
internal static HistorianDataType MapDataType(byte[] nativeDataTypeDescriptor)
{
if (nativeDataTypeDescriptor is not [0x03, 0xCF or 0xC3, _, _])
{
throw new ProtocolEvidenceMissingException(
$"GetTagInfoFromName data type descriptor {Convert.ToHexString(nativeDataTypeDescriptor)}");
}
return nativeDataTypeDescriptor[3] switch
{
0x01 => HistorianDataType.Float,
0x02 => HistorianDataType.Int1,
0x03 => HistorianDataType.SingleByteString,
0x04 => HistorianDataType.Event,
0x05 => HistorianDataType.Structure,
0x08 => HistorianDataType.UInt1,
0x09 => HistorianDataType.UInt2,
0x10 => HistorianDataType.Guid,
0x11 => HistorianDataType.UInt4,
0x18 => HistorianDataType.FileTime,
0x19 => HistorianDataType.Int8,
0x21 => HistorianDataType.Double,
0x29 => HistorianDataType.Int2,
0x31 => HistorianDataType.Int4,
0x39 => HistorianDataType.UInt8,
0x43 => HistorianDataType.DoubleByteString,
0x81 => HistorianDataType.Int1,
_ => throw new ProtocolEvidenceMissingException(
$"GetTagInfoFromName data type descriptor {Convert.ToHexString(nativeDataTypeDescriptor)}")
};
}
private sealed class WcfRetrievalSession : IDisposable
{
private readonly ChannelFactory<IHistoryServiceContract2> _historyFactory;
private readonly IHistoryServiceContract2 _historyChannel;
private readonly ChannelFactory<IRetrievalServiceContract2> _retrievalFactory;
private WcfRetrievalSession(
ChannelFactory<IHistoryServiceContract2> historyFactory,
IHistoryServiceContract2 historyChannel,
ChannelFactory<IRetrievalServiceContract2> retrievalFactory,
IRetrievalServiceContract2 retrievalChannel,
uint handle)
{
_historyFactory = historyFactory;
_historyChannel = historyChannel;
_retrievalFactory = retrievalFactory;
RetrievalChannel = retrievalChannel;
Handle = handle;
}
public IRetrievalServiceContract2 RetrievalChannel { get; }
public uint Handle { get; }
public static WcfRetrievalSession Open(HistorianClientOptions options)
{
ValidateSupportedAuth(options);
ChannelFactory<IHistoryServiceContract2>? historyFactory = null;
IHistoryServiceContract2? historyChannel = null;
ChannelFactory<IRetrievalServiceContract2>? retrievalFactory = null;
IRetrievalServiceContract2? retrievalChannel = null;
try
{
historyFactory = new ChannelFactory<IHistoryServiceContract2>(
HistorianWcfBindingFactory.CreateMdasNetTcpWindowsBinding(options.RequestTimeout),
HistorianWcfBindingFactory.CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.HistoryIntegrated));
historyFactory.Credentials.Windows.AllowedImpersonationLevel = System.Security.Principal.TokenImpersonationLevel.Impersonation;
ApplyWindowsCredential(historyFactory, options);
historyFactory.Open();
historyChannel = historyFactory.CreateChannel();
((IClientChannel)historyChannel).Open();
byte[] openBuffer = BuildOpen2Buffer(options);
bool openSuccess = historyChannel.OpenConnection2(ref openBuffer, out byte[] openOut, out byte[] openError);
HistorianLegacyOpen2Output? openOutput = HistorianOpen2Protocol.TryReadLegacyOpen2Output(openOut);
if (!openSuccess || openOutput is null)
{
HistorianNativeError? nativeError = HistorianOpen2Protocol.TryReadNativeError(openError);
string code = nativeError is null ? "unknown" : nativeError.Code.ToString(System.Globalization.CultureInfo.InvariantCulture);
throw new InvalidOperationException($"OpenConnection2 failed for tag browse; native error code {code}.");
}
retrievalFactory = new ChannelFactory<IRetrievalServiceContract2>(
HistorianWcfBindingFactory.CreateMdasNetTcpBinding(options.RequestTimeout),
HistorianWcfBindingFactory.CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.Retrieval));
retrievalFactory.Open();
retrievalChannel = retrievalFactory.CreateChannel();
((IClientChannel)retrievalChannel).Open();
return new WcfRetrievalSession(
historyFactory,
historyChannel,
retrievalFactory,
retrievalChannel,
openOutput.Handle);
}
catch
{
AbortOrClose(retrievalChannel);
AbortOrClose(retrievalFactory);
AbortOrClose(historyChannel);
AbortOrClose(historyFactory);
throw;
}
}
public void Dispose()
{
try
{
_historyChannel.CloseConnection(Handle);
}
catch
{
// Close best-effort; channel cleanup below still runs.
}
AbortOrClose(RetrievalChannel);
AbortOrClose(_retrievalFactory);
AbortOrClose(_historyChannel);
AbortOrClose(_historyFactory);
}
private static void ValidateSupportedAuth(HistorianClientOptions options)
{
if (!options.IntegratedSecurity
&& (!string.IsNullOrEmpty(options.UserName) || !string.IsNullOrEmpty(options.Password)))
{
throw new ProtocolEvidenceMissingException("Open2 explicit username/password tag browse");
}
}
private static void ApplyWindowsCredential(ChannelFactory<IHistoryServiceContract2> factory, HistorianClientOptions options)
{
if (string.IsNullOrWhiteSpace(options.UserName))
{
return;
}
NetworkCredential credential = new();
int slash = options.UserName.IndexOf('\\');
if (slash > 0 && slash < options.UserName.Length - 1)
{
credential.Domain = options.UserName[..slash];
credential.UserName = options.UserName[(slash + 1)..];
}
else
{
credential.UserName = options.UserName;
}
credential.Password = options.Password;
factory.Credentials.Windows.ClientCredential = credential;
}
private static byte[] BuildOpen2Buffer(HistorianClientOptions options)
{
string processName = Path.GetFileNameWithoutExtension(Environment.ProcessPath) ?? "AVEVA.Historian.Client";
HistorianOpen2Request request = new(
options.Host,
processName,
(uint)Environment.ProcessId,
string.Empty,
[],
4,
11,
1026,
HistorianMetadataNamespace.Empty);
return HistorianOpen2Protocol.SerializeLegacyVersion1(request);
}
private static void AbortOrClose(object? communicationObject)
{
if (communicationObject is not ICommunicationObject channel)
{
return;
}
try
{
if (channel.State == CommunicationState.Faulted)
{
channel.Abort();
}
else
{
channel.Close();
}
}
catch
{
channel.Abort();
}
}
}
}
@@ -0,0 +1,51 @@
using System.ServiceModel.Channels;
namespace AVEVA.Historian.Client.Wcf;
internal sealed class MdasMessageEncoder : MessageEncoder
{
public const string MdasContentType = "application/x-mdas";
private readonly MessageEncoder inner;
public MdasMessageEncoder(MessageEncoder inner)
{
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public override string ContentType => MdasContentType;
public override string MediaType => MdasContentType;
public override MessageVersion MessageVersion => inner.MessageVersion;
public override bool IsContentTypeSupported(string contentType)
{
return contentType.StartsWith(MdasContentType, StringComparison.OrdinalIgnoreCase)
|| inner.IsContentTypeSupported(contentType);
}
public override Message ReadMessage(ArraySegment<byte> buffer, BufferManager bufferManager, string contentType)
{
return inner.ReadMessage(buffer, bufferManager, inner.ContentType);
}
public override Message ReadMessage(Stream stream, int maxSizeOfHeaders, string contentType)
{
return inner.ReadMessage(stream, maxSizeOfHeaders, inner.ContentType);
}
public override void WriteMessage(Message message, Stream stream)
{
inner.WriteMessage(message, stream);
}
public override ArraySegment<byte> WriteMessage(
Message message,
int maxMessageSize,
BufferManager bufferManager,
int messageOffset)
{
return inner.WriteMessage(message, maxMessageSize, bufferManager, messageOffset);
}
}
@@ -0,0 +1,19 @@
using System.ServiceModel.Channels;
namespace AVEVA.Historian.Client.Wcf;
internal sealed class MdasMessageEncoderFactory : MessageEncoderFactory
{
private readonly MessageEncoderFactory inner;
private readonly MessageEncoder encoder;
public MdasMessageEncoderFactory(MessageEncoderFactory inner)
{
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
encoder = new MdasMessageEncoder(inner.Encoder);
}
public override MessageEncoder Encoder => encoder;
public override MessageVersion MessageVersion => inner.MessageVersion;
}
@@ -0,0 +1,55 @@
using System.ServiceModel.Channels;
using System.Xml;
namespace AVEVA.Historian.Client.Wcf;
internal sealed class MdasMessageEncodingBindingElement : MessageEncodingBindingElement
{
private readonly MessageEncodingBindingElement inner;
public MdasMessageEncodingBindingElement(MessageEncodingBindingElement inner)
{
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
private MdasMessageEncodingBindingElement(MdasMessageEncodingBindingElement source)
{
inner = (MessageEncodingBindingElement)source.inner.Clone();
}
public override MessageVersion MessageVersion
{
get => inner.MessageVersion;
set => inner.MessageVersion = value;
}
public override MessageEncoderFactory CreateMessageEncoderFactory()
{
return new MdasMessageEncoderFactory(inner.CreateMessageEncoderFactory());
}
public override BindingElement Clone()
{
return new MdasMessageEncodingBindingElement(this);
}
public override IChannelFactory<TChannel> BuildChannelFactory<TChannel>(BindingContext context)
{
ArgumentNullException.ThrowIfNull(context);
context.BindingParameters.Add(this);
return context.BuildInnerChannelFactory<TChannel>();
}
public override bool CanBuildChannelFactory<TChannel>(BindingContext context)
{
ArgumentNullException.ThrowIfNull(context);
context.BindingParameters.Add(this);
return context.CanBuildInnerChannelFactory<TChannel>();
}
public override T? GetProperty<T>(BindingContext context) where T : class
{
ArgumentNullException.ThrowIfNull(context);
return inner.GetProperty<T>(context) ?? context.GetInnerProperty<T>();
}
}