Wire SDK for remote-TCP end to end; live-verify RemoteTcpIntegrated
Executes docs/plans/tcp-connection-validation.md. Full read-only SDK
surface now works against a remote AVEVA Historian over Net.TCP with
Windows transport authentication. 124/124 tests pass; the +10 new live
integration tests in RemoteTcpIntegrationTests.cs are gated by
HISTORIAN_REMOTE_TCP_HOST + HISTORIAN_REMOTE_TCP_TAG.
Two SDK bugs found while executing the plan:
1. Historian2020ProtocolDialect.ReadRawAsync / ReadAggregateAsync /
ReadAtTimeAsync / ReadEventsAsync had explicit
`if (_options.Transport != HistorianTransport.LocalPipe) return Missing<T>`
guards. These were a guardrail from before the orchestrators handled
TCP; the orchestrators have always used CreateBindingPair(options)
which dispatches on transport correctly. Gates removed.
2. HistorianWcfStatusClient and HistorianWcfEventOrchestrator hardcoded
HistorianWcfBindingFactory.CreatePipeEndpointAddress for the auxiliary
services (Stat, Trx, Retr). Worked for LocalPipe; for TCP it produced
an EndpointAddress with scheme net.pipe attached to a TCP binding
(channel factory rejected the URI). Worse, when only the endpoint was
transport-aware, the binding still requested a Windows-transport-
security upgrade that the Stat endpoint over TCP doesn't support
(auxiliaries don't repeat the auth — the Hist session is already
authenticated). Added two helpers:
- HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(options, name)
-> net.pipe for LocalPipe, net.tcp for remote
- HistorianWcfBindingFactory.CreateAuxiliaryBinding(options)
-> NamedPipe for LocalPipe, plain MdasNetTcpBinding for remote
Both call sites updated.
Live verification against the remote (probed previously in prior
sessions; reachability re-confirmed today):
- ProbeAsync over RemoteTcpIntegrated and RemoteTcpCertificate
- ReadRawAsync (8 samples returned for SysTimeSec)
- ReadAggregateAsync (TimeWeightedAverage, 1-min cycle, 10-min window)
- ReadAtTimeAsync (3 timestamps)
- BrowseTagNamesAsync (finds the test tag)
- GetTagMetadataAsync (full metadata populated)
- ReadEventsAsync (chain runs without throwing)
- GetConnectionStatusAsync (ConnectedToServer=true)
- GetSystemParameterAsync (HistorianVersion="20,0,000,000")
The default 'NT SERVICE\aahClientAccessPoint' SPN turned out to work
for the remote too — discovery workstream A (SPN-finding) was not
needed in practice.
README and the TCP plan doc updated to reflect the executed status.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,8 +46,10 @@ await foreach (HistorianSample sample in client.ReadRawAsync(
|
|||||||
```
|
```
|
||||||
|
|
||||||
For a remote Historian over Net.TCP set `Transport = HistorianTransport.RemoteTcpIntegrated`
|
For a remote Historian over Net.TCP set `Transport = HistorianTransport.RemoteTcpIntegrated`
|
||||||
and `Host` to the server hostname. Remote-TCP plumbing exists but only `LocalPipe`
|
and `Host` to the server hostname. Both `RemoteTcpIntegrated` (Windows transport
|
||||||
has live verification in this checkout.
|
auth) and `RemoteTcpCertificate` (server-cert TLS) are now live-verified for
|
||||||
|
`ProbeAsync`; `RemoteTcpIntegrated` is additionally live-verified for the full
|
||||||
|
read / browse / metadata / event / status surface.
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
|
|
||||||
@@ -158,6 +160,9 @@ property dictionary → Retr.EndEventQuery → Hist.Close2
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
108 unit + live integration tests pass (`dotnet test --logger "console;verbosity=minimal"`).
|
124 unit + live integration tests pass (`dotnet test --logger "console;verbosity=minimal"`).
|
||||||
Full read-only SDK surface verified end-to-end against a local Historian. Remote-TCP
|
Full read-only SDK surface verified end-to-end against both a local Historian
|
||||||
transports and explicit-credentials path are wired but await live verification.
|
(`LocalPipe`) and a remote Historian (`RemoteTcpIntegrated` over Net.TCP with
|
||||||
|
Windows transport auth). `RemoteTcpCertificate` ProbeAsync is live-verified;
|
||||||
|
the other ops over the certificate transport plus the explicit-credentials
|
||||||
|
path await live verification.
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
# TCP Connection Validation Plan
|
# TCP Connection Validation Plan
|
||||||
|
|
||||||
Status: PLAN ONLY (no implementation yet). Scope is **live verification of
|
Status: **EXECUTED on 2026-05-04**. RemoteTcpIntegrated transport is now
|
||||||
the existing remote-TCP transport plumbing**, not new wire-protocol
|
live-verified end-to-end against `10.100.0.48` (Historian InterfaceVersion=11)
|
||||||
reverse-engineering — the wire format itself is the same MDAS-encoded SOAP
|
for ProbeAsync, ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync,
|
||||||
already verified end-to-end over `LocalPipe`.
|
ReadEventsAsync, BrowseTagNamesAsync, GetTagMetadataAsync,
|
||||||
|
GetConnectionStatusAsync, GetSystemParameterAsync. RemoteTcpCertificate
|
||||||
|
verified for ProbeAsync only (full surface awaits a non-current-user
|
||||||
|
credential probe). Test count 114 → 124 (+10) per success criteria.
|
||||||
|
|
||||||
|
Two SDK bugs were uncovered and fixed during execution:
|
||||||
|
1. `Historian2020ProtocolDialect` had explicit `if (Transport != LocalPipe)
|
||||||
|
return Missing` gates on Read/Aggregate/AtTime/ReadEvents that were a
|
||||||
|
leftover guardrail from before the orchestrators handled TCP. Removed —
|
||||||
|
the orchestrators already used `CreateBindingPair(options)` correctly.
|
||||||
|
2. `HistorianWcfStatusClient` and `HistorianWcfEventOrchestrator` hardcoded
|
||||||
|
`CreatePipeEndpointAddress` for auxiliary services (Stat, Trx, Retr).
|
||||||
|
Added `HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress` and
|
||||||
|
`CreateAuxiliaryBinding` helpers that dispatch on `Transport`; for TCP
|
||||||
|
the auxiliaries use plain `MdasNetTcpBinding` (no transport upgrade —
|
||||||
|
the Hist endpoint already authenticated the session).
|
||||||
|
|
||||||
|
Original scope is **live verification of the existing remote-TCP transport
|
||||||
|
plumbing**, not new wire-protocol reverse-engineering — the wire format
|
||||||
|
itself is the same MDAS-encoded SOAP already verified end-to-end over
|
||||||
|
`LocalPipe`.
|
||||||
|
|
||||||
Read together with:
|
Read together with:
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,9 @@ internal sealed class Historian2020ProtocolDialect
|
|||||||
|
|
||||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken)
|
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))
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
{
|
{
|
||||||
return Missing<HistorianSample>("StartDataRetrievalQuery/Full requires Windows for the LocalPipe + SSPI path", cancellationToken);
|
return Missing<HistorianSample>("StartDataRetrievalQuery/Full requires Windows for the SSPI path", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ReadRawWindowsAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
|
return ReadRawWindowsAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
|
||||||
@@ -38,14 +33,9 @@ internal sealed class Historian2020ProtocolDialect
|
|||||||
|
|
||||||
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken)
|
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))
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
{
|
{
|
||||||
return Missing<HistorianAggregateSample>($"StartDataRetrievalQuery/{mode} requires Windows for the LocalPipe + SSPI path", cancellationToken);
|
return Missing<HistorianAggregateSample>($"StartDataRetrievalQuery/{mode} requires Windows for the SSPI path", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ReadAggregateWindowsAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
|
return ReadAggregateWindowsAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
|
||||||
@@ -63,14 +53,9 @@ internal sealed class Historian2020ProtocolDialect
|
|||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (_options.Transport != HistorianTransport.LocalPipe)
|
|
||||||
{
|
|
||||||
throw new ProtocolEvidenceMissingException($"StartDataRetrievalQuery/Interpolated at-time over {_options.Transport}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
{
|
{
|
||||||
throw new ProtocolEvidenceMissingException("StartDataRetrievalQuery/Interpolated at-time requires Windows for the LocalPipe + SSPI path");
|
throw new ProtocolEvidenceMissingException("StartDataRetrievalQuery/Interpolated at-time requires Windows for the SSPI path");
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma warning disable CA1416
|
#pragma warning disable CA1416
|
||||||
@@ -86,14 +71,9 @@ internal sealed class Historian2020ProtocolDialect
|
|||||||
|
|
||||||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken 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))
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
{
|
{
|
||||||
return Missing<HistorianEvent>("StartEventDataRetrievalQuery requires Windows for the LocalPipe + SSPI path", cancellationToken);
|
return Missing<HistorianEvent>("StartEventDataRetrievalQuery requires Windows for the SSPI path", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ReadEventsWindowsAsync(startUtc, endUtc, cancellationToken);
|
return ReadEventsWindowsAsync(startUtc, endUtc, cancellationToken);
|
||||||
|
|||||||
@@ -164,4 +164,42 @@ internal static class HistorianWcfBindingFactory
|
|||||||
|
|
||||||
return new EndpointAddress($"net.pipe://{host}/{serviceName}");
|
return new EndpointAddress($"net.pipe://{host}/{serviceName}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the appropriate endpoint address for an auxiliary service (Stat, Trx, etc.)
|
||||||
|
/// based on the transport — net.pipe for LocalPipe, net.tcp for the remote variants.
|
||||||
|
/// Use this rather than <see cref="CreatePipeEndpointAddress"/> directly when the calling
|
||||||
|
/// code may run under any transport.
|
||||||
|
/// </summary>
|
||||||
|
public static EndpointAddress CreateAuxiliaryEndpointAddress(HistorianClientOptions options, string serviceName)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
|
||||||
|
|
||||||
|
return options.Transport == HistorianTransport.LocalPipe
|
||||||
|
? CreatePipeEndpointAddress(options.Host, serviceName)
|
||||||
|
: CreateEndpointAddress(options.Host, options.Port, serviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the appropriate binding for an auxiliary service (Stat, Trx, etc.) given the
|
||||||
|
/// transport. For LocalPipe, same NamedPipe binding as History. For remote TCP variants,
|
||||||
|
/// plain <see cref="CreateMdasNetTcpBinding"/> — auxiliaries don't repeat the Windows-
|
||||||
|
/// transport-security upgrade that the History service negotiates; the established session
|
||||||
|
/// authenticates the client already.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public static Binding CreateAuxiliaryBinding(HistorianClientOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
TimeSpan timeout = options.RequestTimeout;
|
||||||
|
return options.Transport switch
|
||||||
|
{
|
||||||
|
HistorianTransport.LocalPipe => CreateMdasNetNamedPipeBinding(timeout),
|
||||||
|
HistorianTransport.RemoteTcpIntegrated => CreateMdasNetTcpBinding(timeout),
|
||||||
|
HistorianTransport.RemoteTcpCertificate => CreateMdasNetTcpBinding(timeout),
|
||||||
|
_ => throw new NotSupportedException($"Transport {options.Transport} is not supported.")
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,13 +92,14 @@ internal sealed class HistorianWcfEventOrchestrator
|
|||||||
{
|
{
|
||||||
Guid contextKey = Guid.NewGuid();
|
Guid contextKey = Guid.NewGuid();
|
||||||
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
|
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
|
||||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Status);
|
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
|
||||||
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Transaction);
|
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
|
||||||
|
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
|
||||||
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||||
_options, histBinding, histEndpoint, contextKey, cancellationToken,
|
_options, histBinding, histEndpoint, contextKey, cancellationToken,
|
||||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
|
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
|
||||||
additionalSetup: (historyChannel, context) =>
|
additionalSetup: (historyChannel, context) =>
|
||||||
AddCmEventTagViaAddT(historyChannel, context, histBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
|
AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
|
||||||
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, cancellationToken);
|
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ internal static class HistorianWcfStatusClient
|
|||||||
{
|
{
|
||||||
Guid contextKey = Guid.NewGuid();
|
Guid contextKey = Guid.NewGuid();
|
||||||
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(options);
|
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(options);
|
||||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(options.Host, HistorianWcfServiceNames.Status);
|
Binding statusBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(options);
|
||||||
|
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(options, HistorianWcfServiceNames.Status);
|
||||||
|
|
||||||
string? value = null;
|
string? value = null;
|
||||||
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||||
options, histBinding, histEndpoint, contextKey, CancellationToken.None,
|
options, histBinding, histEndpoint, contextKey, CancellationToken.None,
|
||||||
additionalSetup: (_, context) => value = QuerySystemParameter(histBinding, statusEndpoint, context.ClientHandle, parameterName));
|
additionalSetup: (_, context) => value = QuerySystemParameter(statusBinding, statusEndpoint, context.ClientHandle, parameterName));
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using AVEVA.Historian.Client.Models;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Live verification of the RemoteTcpIntegrated and RemoteTcpCertificate transports
|
||||||
|
/// per <c>docs/plans/tcp-connection-validation.md</c>. Gated by env vars:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>HISTORIAN_REMOTE_TCP_HOST</c> — hostname or IP of a reachable remote Historian.</item>
|
||||||
|
/// <item><c>HISTORIAN_REMOTE_TCP_TAG</c> — tag with non-zero history rows.</item>
|
||||||
|
/// <item><c>HISTORIAN_REMOTE_TCP_SPN</c> — optional Kerberos SPN override (default per <c>HistorianClientOptions.TargetSpn</c>).</item>
|
||||||
|
/// <item><c>HISTORIAN_REMOTE_TCPCERT_HOST</c> + <c>HISTORIAN_REMOTE_TCPCERT_DNS</c> — for the certificate transport variant.</item>
|
||||||
|
/// </list>
|
||||||
|
/// All tests skip cleanly if the gating env var isn't set.
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class RemoteTcpIntegrationTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public RemoteTcpIntegrationTests(ITestOutputHelper output)
|
||||||
|
{
|
||||||
|
_output = output;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeAsync_RemoteTcpIntegrated_ReturnsTrue()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildIntegratedOptions(host));
|
||||||
|
bool reachable = await client.ProbeAsync(CancellationToken.None);
|
||||||
|
Assert.True(reachable, "ProbeAsync against remote-TCP host returned false");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadRawAsync_RemoteTcpIntegrated_ReturnsAtLeastOneRow()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
|
||||||
|
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildIntegratedOptions(host));
|
||||||
|
DateTime endUtc = DateTime.UtcNow;
|
||||||
|
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
|
||||||
|
|
||||||
|
List<HistorianSample> samples = [];
|
||||||
|
await foreach (HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None))
|
||||||
|
{
|
||||||
|
samples.Add(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
_output.WriteLine($"Returned {samples.Count} samples for {testTag}");
|
||||||
|
Assert.NotEmpty(samples);
|
||||||
|
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTagMetadataAsync_RemoteTcpIntegrated_PopulatesFields()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
|
||||||
|
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildIntegratedOptions(host));
|
||||||
|
HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(testTag, CancellationToken.None);
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
Assert.Equal(testTag, metadata.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSystemParameterAsync_RemoteTcpIntegrated_ReturnsHistorianVersion()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildIntegratedOptions(host));
|
||||||
|
string? value = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None);
|
||||||
|
_output.WriteLine($"HistorianVersion: {value}");
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAggregateAsync_RemoteTcpIntegrated_ReturnsTimeWeightedRows()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
|
||||||
|
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildIntegratedOptions(host));
|
||||||
|
DateTime endUtc = DateTime.UtcNow;
|
||||||
|
DateTime startUtc = endUtc - TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
List<HistorianAggregateSample> samples = [];
|
||||||
|
await foreach (HistorianAggregateSample sample in client.ReadAggregateAsync(
|
||||||
|
testTag, startUtc, endUtc, RetrievalMode.TimeWeightedAverage, TimeSpan.FromMinutes(1), CancellationToken.None))
|
||||||
|
{
|
||||||
|
samples.Add(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotEmpty(samples);
|
||||||
|
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAtTimeAsync_RemoteTcpIntegrated_ReturnsTimestamps()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
|
||||||
|
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildIntegratedOptions(host));
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
DateTime[] timestamps = [now - TimeSpan.FromMinutes(5), now - TimeSpan.FromMinutes(2), now - TimeSpan.FromMinutes(1)];
|
||||||
|
IReadOnlyList<HistorianSample> samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None);
|
||||||
|
Assert.NotEmpty(samples);
|
||||||
|
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BrowseTagNamesAsync_RemoteTcpIntegrated_FindsTestTag()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
|
||||||
|
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildIntegratedOptions(host));
|
||||||
|
List<string> names = [];
|
||||||
|
await foreach (string name in client.BrowseTagNamesAsync(testTag, CancellationToken.None))
|
||||||
|
{
|
||||||
|
names.Add(name);
|
||||||
|
}
|
||||||
|
Assert.Contains(testTag, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadEventsAsync_RemoteTcpIntegrated_DoesNotThrow()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildIntegratedOptions(host));
|
||||||
|
DateTime endUtc = DateTime.UtcNow;
|
||||||
|
DateTime startUtc = endUtc - TimeSpan.FromDays(1);
|
||||||
|
|
||||||
|
// Empty result is acceptable — we're just verifying the chain doesn't throw over TCP.
|
||||||
|
List<HistorianEvent> events = [];
|
||||||
|
await foreach (HistorianEvent evt in client.ReadEventsAsync(startUtc, endUtc, CancellationToken.None))
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
Assert.NotNull(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetConnectionStatusAsync_RemoteTcpIntegrated_ReportsConnectedToServer()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildIntegratedOptions(host));
|
||||||
|
HistorianConnectionStatus status = await client.GetConnectionStatusAsync(CancellationToken.None);
|
||||||
|
Assert.True(status.ConnectedToServer);
|
||||||
|
Assert.False(status.ErrorOccurred);
|
||||||
|
Assert.Equal(host, status.ServerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeAsync_RemoteTcpCertificate_ReturnsTrue()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCPCERT_HOST");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(new HistorianClientOptions
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
Port = HistorianClientOptions.DefaultPort,
|
||||||
|
IntegratedSecurity = false,
|
||||||
|
Transport = HistorianTransport.RemoteTcpCertificate,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool reachable = await client.ProbeAsync(CancellationToken.None);
|
||||||
|
Assert.True(reachable, "ProbeAsync over RemoteTcpCertificate returned false");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HistorianClientOptions BuildIntegratedOptions(string host)
|
||||||
|
{
|
||||||
|
string? spn = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_SPN");
|
||||||
|
return new HistorianClientOptions
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
Port = HistorianClientOptions.DefaultPort,
|
||||||
|
IntegratedSecurity = true,
|
||||||
|
Transport = HistorianTransport.RemoteTcpIntegrated,
|
||||||
|
// SPN default in HistorianClientOptions is "NT SERVICE\aahClientAccessPoint" which is the
|
||||||
|
// LocalPipe service identity; for remote TCP, override via env var if needed.
|
||||||
|
TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user