feat(sphistorianclient): port SDK source + tests, rebrand namespace to ZB.MOM.WW.SPHistorianClient
This commit is contained in:
+92
@@ -0,0 +1,92 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Grpc.Net.Client.Web;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="GrpcChannel"/> for the 2023 R2 Historian Client Access Point,
|
||||
/// replicating the stock <c>Archestra.Historian.GrpcClient.GrpcClientBase.InitializeBase</c>
|
||||
/// transport shape: gRPC-Web (binary) over HTTP/1.1, optional TLS with an
|
||||
/// untrusted-certificate bypass, and gzip request encoding.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcChannelFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the effective gRPC port: when the caller left <see cref="HistorianClientOptions.Port"/>
|
||||
/// at the WCF default (32568), the 2023 R2 gRPC default (32565) is substituted; otherwise the
|
||||
/// explicit value is honoured.
|
||||
/// </summary>
|
||||
internal static int ResolvePort(HistorianClientOptions options) =>
|
||||
options.Port == HistorianClientOptions.DefaultPort ? HistorianClientOptions.DefaultGrpcPort : options.Port;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the channel address. TLS uses <c>https://{ServerDnsIdentity|Host}:{port}</c> (the
|
||||
/// DNS-identity override lets the URL match the server certificate name when connecting by IP);
|
||||
/// plaintext uses <c>http://{Host}:{port}</c>.
|
||||
/// </summary>
|
||||
internal static string ResolveAddress(HistorianClientOptions options)
|
||||
{
|
||||
int port = ResolvePort(options);
|
||||
if (options.GrpcUseTls)
|
||||
{
|
||||
string tlsHost = !string.IsNullOrEmpty(options.ServerDnsIdentity) ? options.ServerDnsIdentity! : options.Host;
|
||||
return $"https://{tlsHost}:{port}";
|
||||
}
|
||||
|
||||
return $"http://{options.Host}:{port}";
|
||||
}
|
||||
|
||||
public static HistorianGrpcConnection Create(HistorianClientOptions options)
|
||||
{
|
||||
string address = ResolveAddress(options);
|
||||
|
||||
var httpHandler = new HttpClientHandler();
|
||||
if (options.AllowUntrustedServerCertificate)
|
||||
{
|
||||
httpHandler.ServerCertificateCustomValidationCallback = AcceptAnyCertificate;
|
||||
}
|
||||
|
||||
// gRPC-Web binary mode over HTTP/1.1 — matches the stock client (GrpcWebMode.GrpcWeb,
|
||||
// HttpVersion 1.1). The 2023 R2 HCAP endpoint speaks gRPC-Web, not bare HTTP/2 gRPC.
|
||||
var webHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, httpHandler)
|
||||
{
|
||||
HttpVersion = new Version(1, 1)
|
||||
};
|
||||
|
||||
var channelOptions = new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = webHandler
|
||||
};
|
||||
|
||||
GrpcChannel channel = GrpcChannel.ForAddress(address, channelOptions);
|
||||
|
||||
// The stock client always advertises gzip request encoding; honour the option so
|
||||
// bandwidth-limited links can disable it.
|
||||
var metadata = new Metadata();
|
||||
if (options.Compression)
|
||||
{
|
||||
metadata.Add("grpc-internal-encoding-request", "gzip");
|
||||
}
|
||||
|
||||
return new HistorianGrpcConnection(channel, metadata);
|
||||
}
|
||||
|
||||
private static bool AcceptAnyCertificate(
|
||||
HttpRequestMessage request,
|
||||
X509Certificate2? certificate,
|
||||
X509Chain? chain,
|
||||
SslPolicyErrors errors) => true;
|
||||
}
|
||||
|
||||
/// <summary>A live gRPC channel plus the per-call metadata header set.</summary>
|
||||
internal sealed class HistorianGrpcConnection(GrpcChannel channel, Metadata metadata) : IDisposable
|
||||
{
|
||||
public GrpcChannel Channel { get; } = channel;
|
||||
|
||||
public Metadata Metadata { get; } = metadata;
|
||||
|
||||
public void Dispose() => Channel.Dispose();
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC read orchestrator. Mirrors <see cref="HistorianWcfReadOrchestrator"/> over the
|
||||
/// gRPC transport: the same native binary buffers travel inside protobuf <c>bytes</c> fields,
|
||||
/// and the same serializers/parsers (<see cref="HistorianNativeHandshake"/>,
|
||||
/// <see cref="HistorianDataQueryProtocol"/>) are reused unchanged.
|
||||
///
|
||||
/// Operation mapping (2020 WCF → 2023 R2 gRPC):
|
||||
/// Hist.GetInterfaceVersion → HistoryService.GetInterfaceVersion
|
||||
/// Hist.ValidateClientCredential (loop) → HistoryService.ExchangeKey (loop)
|
||||
/// Hist.OpenConnection2 → HistoryService.OpenConnection
|
||||
/// Retr.StartQuery2 → RetrievalService.StartQuery
|
||||
/// Retr.GetNextQueryResultBuffer2 (loop) → RetrievalService.GetNextQueryResultBuffer (loop)
|
||||
/// Retr.EndQuery2 → RetrievalService.EndQuery
|
||||
///
|
||||
/// NOTE: not yet live-verified against a 2023 R2 server. The auth handshake uses
|
||||
/// HistoryService.ExchangeKey because the gRPC HistoryService dropped ValidateClientCredential
|
||||
/// (it now lives only on StorageService) and gained ExchangeKey with the identical
|
||||
/// handle+token→token shape. If a live server rejects this, the handshake op is the first thing
|
||||
/// to revisit — everything else is the proven 2020 byte protocol.
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcReadOrchestrator
|
||||
{
|
||||
private const ushort StartQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData;
|
||||
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianGrpcReadOrchestrator(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)
|
||||
{
|
||||
ValidateAuth();
|
||||
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,
|
||||
RetrievalMode mode,
|
||||
TimeSpan interval,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateAuth();
|
||||
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 Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
|
||||
string tag,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateAuth();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.Run<IReadOnlyList<HistorianSample>>(() => RunAtTimeChain(tag, timestampsUtc, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private void ValidateAuth()
|
||||
{
|
||||
if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"Managed gRPC read flow currently requires IntegratedSecurity or an explicit UserName + Password.");
|
||||
}
|
||||
}
|
||||
|
||||
private List<HistorianSample> RunRawChain(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
||||
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest(tag, startUtc, endUtc, maxValues);
|
||||
return RunQuery(connection, clientHandle, request, maxValues, cancellationToken);
|
||||
}
|
||||
|
||||
private List<HistorianAggregateSample> RunAggregateChain(
|
||||
string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
||||
return RunAggregateQuery(connection, clientHandle, tag, startUtc, endUtc, mode, interval, cancellationToken);
|
||||
}
|
||||
|
||||
private List<HistorianSample> RunAtTimeChain(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
if (timestampsUtc.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
||||
|
||||
List<HistorianSample> results = new(timestampsUtc.Count);
|
||||
foreach (DateTime ts in timestampsUtc)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
DateTime tsUtc = ts.ToUniversalTime();
|
||||
List<HistorianAggregateSample> aggregates = RunAggregateQuery(
|
||||
connection,
|
||||
clientHandle,
|
||||
tag,
|
||||
tsUtc - TimeSpan.FromTicks(1),
|
||||
tsUtc + TimeSpan.FromTicks(1),
|
||||
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 uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
|
||||
historyClient.GetInterfaceVersion(new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
HistorianNativeHandshake.RunTokenRounds(
|
||||
(handle, wrapped, _) =>
|
||||
{
|
||||
GrpcHistory.ExchangeKeyResponse response = historyClient.ExchangeKey(
|
||||
new GrpcHistory.ExchangeKeyRequest { StrHandle = handle, BtInput = ByteString.CopyFrom(wrapped) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
byte[] serverOutput = response.BtOutput?.ToByteArray() ?? [];
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
bool success = response.Status?.BSuccess ?? false;
|
||||
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
|
||||
},
|
||||
contextKey,
|
||||
_options,
|
||||
cancellationToken);
|
||||
|
||||
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
|
||||
_options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
|
||||
|
||||
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
||||
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
|
||||
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
|
||||
if (!(open2.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}).");
|
||||
}
|
||||
|
||||
(uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
||||
return clientHandle;
|
||||
}
|
||||
|
||||
private List<HistorianSample> RunQuery(
|
||||
HistorianGrpcConnection connection,
|
||||
uint clientHandle,
|
||||
HistorianDataQueryRequest request,
|
||||
int maxValues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
|
||||
|
||||
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
|
||||
uint queryHandle = StartQuery(retrievalClient, clientHandle, requestBuffer, "raw", cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
List<HistorianSample> samples = [];
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
(byte[] resultBuffer, byte[] errorBuffer) = GetNextResultBuffer(retrievalClient, clientHandle, queryHandle, "raw", cancellationToken);
|
||||
|
||||
if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(resultBuffer, errorBuffer, out IReadOnlyList<HistorianSample> rows, out bool hasMoreData))
|
||||
{
|
||||
throw new InvalidOperationException($"gRPC GetNextQueryResultBuffer 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
|
||||
{
|
||||
EndQuerySafely(retrievalClient, clientHandle, queryHandle);
|
||||
}
|
||||
}
|
||||
|
||||
private List<HistorianAggregateSample> RunAggregateQuery(
|
||||
HistorianGrpcConnection connection,
|
||||
uint clientHandle,
|
||||
string tag,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
RetrievalMode mode,
|
||||
TimeSpan interval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
|
||||
|
||||
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildAggregateQueryRequest(tag, startUtc, endUtc, mode, interval);
|
||||
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
|
||||
uint queryHandle = StartQuery(retrievalClient, clientHandle, requestBuffer, $"aggregate {mode}", cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
List<HistorianAggregateSample> samples = [];
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
(byte[] resultBuffer, byte[] errorBuffer) = GetNextResultBuffer(retrievalClient, clientHandle, queryHandle, $"aggregate {mode}", cancellationToken);
|
||||
|
||||
if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferAggregateRows(
|
||||
resultBuffer, errorBuffer, mode, interval, out IReadOnlyList<HistorianAggregateSample> rows, out bool hasMoreData))
|
||||
{
|
||||
throw new InvalidOperationException($"gRPC GetNextQueryResultBuffer (aggregate {mode}) returned an unparsable buffer (length={resultBuffer.Length}).");
|
||||
}
|
||||
|
||||
samples.AddRange(rows);
|
||||
if (!hasMoreData)
|
||||
{
|
||||
return samples;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndQuerySafely(retrievalClient, clientHandle, queryHandle);
|
||||
}
|
||||
}
|
||||
|
||||
private uint StartQuery(
|
||||
GrpcRetrieval.RetrievalService.RetrievalServiceClient client,
|
||||
uint clientHandle,
|
||||
byte[] requestBuffer,
|
||||
string label,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
GrpcRetrieval.StartQueryResponse response = client.StartQuery(
|
||||
new GrpcRetrieval.StartQueryRequest
|
||||
{
|
||||
UiHandle = clientHandle,
|
||||
UiQueryRequestType = StartQueryRequestType,
|
||||
BtRequestBuffer = ByteString.CopyFrom(requestBuffer)
|
||||
},
|
||||
null,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
|
||||
if (!(response.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] err = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC StartQuery ({label}) failed (errorLen={err.Length}).");
|
||||
}
|
||||
|
||||
return response.UiQueryHandle;
|
||||
}
|
||||
|
||||
private (byte[] ResultBuffer, byte[] ErrorBuffer) GetNextResultBuffer(
|
||||
GrpcRetrieval.RetrievalService.RetrievalServiceClient client,
|
||||
uint clientHandle,
|
||||
uint queryHandle,
|
||||
string label,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
GrpcRetrieval.GetNextQueryResultBufferResponse response = client.GetNextQueryResultBuffer(
|
||||
new GrpcRetrieval.GetNextQueryResultBufferRequest { UiHandle = clientHandle, UiQueryHandle = queryHandle },
|
||||
null,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
|
||||
byte[] errorBuffer = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
if (!(response.Status?.BSuccess ?? false))
|
||||
{
|
||||
throw new InvalidOperationException($"gRPC GetNextQueryResultBuffer ({label}) failed (errorLen={errorBuffer.Length}).");
|
||||
}
|
||||
|
||||
byte[] resultBuffer = response.BtQueryResult?.ToByteArray() ?? [];
|
||||
return (resultBuffer, errorBuffer);
|
||||
}
|
||||
|
||||
private void EndQuerySafely(GrpcRetrieval.RetrievalService.RetrievalServiceClient client, uint clientHandle, uint queryHandle)
|
||||
{
|
||||
try
|
||||
{
|
||||
client.EndQuery(
|
||||
new GrpcRetrieval.EndQueryRequest { UiHandle = clientHandle, UiQueryHandle = queryHandle },
|
||||
null,
|
||||
Deadline(),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup; the read result is already collected.
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
// Recovered from HistoryService.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
|
||||
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
|
||||
syntax = "proto3";
|
||||
|
||||
import "Status.proto";
|
||||
|
||||
option csharp_namespace = "ArchestrA.Grpc.Contract.History";
|
||||
|
||||
message CreateTagResponse {
|
||||
bool bSuccess = 1;
|
||||
bytes tagid = 2;
|
||||
}
|
||||
|
||||
message GetInterfaceVersionRequest {
|
||||
}
|
||||
|
||||
message GetInterfaceVersionResponse {
|
||||
uint32 uiError = 1;
|
||||
uint32 uiVersion = 2;
|
||||
}
|
||||
|
||||
message OpenConnectionRequest {
|
||||
bytes btConnectionRequest = 1;
|
||||
}
|
||||
|
||||
message OpenConnectionResponse {
|
||||
.Status status = 1;
|
||||
bytes btConnectionResponse = 2;
|
||||
}
|
||||
|
||||
message CloseConnectionRequest {
|
||||
string strHandle = 1;
|
||||
}
|
||||
|
||||
message CloseConnectionResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message UpdateClientStatusRequest {
|
||||
string strHandle = 1;
|
||||
bytes btClientStatus = 2;
|
||||
}
|
||||
|
||||
message UpdateClientStatusResponse {
|
||||
.Status status = 1;
|
||||
bytes btServerStatus = 2;
|
||||
}
|
||||
|
||||
message RegisterTagsRequest {
|
||||
string strHandle = 1;
|
||||
bytes btTagInfos = 2;
|
||||
}
|
||||
|
||||
message RegisterTagsResponse {
|
||||
.Status status = 1;
|
||||
bytes btTagStatus = 2;
|
||||
}
|
||||
|
||||
message EnsureTagsRequest {
|
||||
string strHandle = 1;
|
||||
bytes btTagInfos = 2;
|
||||
uint32 elementCount = 3;
|
||||
}
|
||||
|
||||
message EnsureTagsResponse {
|
||||
.Status status = 1;
|
||||
bytes btTagStatus = 2;
|
||||
}
|
||||
|
||||
message AddStreamValuesRequest {
|
||||
string strHandle = 1;
|
||||
bytes btValues = 2;
|
||||
}
|
||||
|
||||
message AddStreamValuesResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message TagExtendedProperty {
|
||||
enum TagExtendedPropertyDataType {
|
||||
String = 0;
|
||||
Int16 = 1;
|
||||
Int32 = 2;
|
||||
Int64 = 3;
|
||||
Double = 4;
|
||||
Boolean = 5;
|
||||
DateTimeOffset = 6;
|
||||
Guid = 7;
|
||||
Geography = 8;
|
||||
Geometry = 9;
|
||||
}
|
||||
|
||||
string PropertyName = 1;
|
||||
.TagExtendedProperty.TagExtendedPropertyDataType type = 2;
|
||||
bytes value = 3;
|
||||
bool Facetable = 4;
|
||||
bool Searchable = 5;
|
||||
bool SubstringSearchable = 6;
|
||||
}
|
||||
|
||||
message TagExtendedPropertyGroup {
|
||||
string tagname = 1;
|
||||
repeated .TagExtendedProperty TagExtendedProperties = 2;
|
||||
}
|
||||
|
||||
message AddTagExtendedPropertyRequest {
|
||||
string strHandle = 1;
|
||||
repeated .TagExtendedPropertyGroup TagExtendedPropertyGroups = 2;
|
||||
}
|
||||
|
||||
message AddTagExtendedPropertyResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message ExchangeKeyRequest {
|
||||
string strHandle = 1;
|
||||
bytes btInput = 2;
|
||||
}
|
||||
|
||||
message ExchangeKeyResponse {
|
||||
.Status status = 1;
|
||||
bytes btOutput = 2;
|
||||
}
|
||||
|
||||
message StartJobRequest {
|
||||
string strHandle = 1;
|
||||
bytes btInput = 2;
|
||||
}
|
||||
|
||||
message StartJobResponse {
|
||||
.Status status = 1;
|
||||
string strJobid = 2;
|
||||
}
|
||||
|
||||
message GetJobStatusRequest {
|
||||
string strHandle = 1;
|
||||
string strJobid = 2;
|
||||
}
|
||||
|
||||
message GetJobStatusResponse {
|
||||
.Status status = 1;
|
||||
bytes btJobStatus = 2;
|
||||
}
|
||||
|
||||
message AddTagExtendedPropertiesRequest {
|
||||
string strHandle = 1;
|
||||
bytes btTeps = 2;
|
||||
}
|
||||
|
||||
message AddTagExtendedPropertiesResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message DeleteTagExtendedPropertiesRequest {
|
||||
string strHandle = 1;
|
||||
bytes btInput = 2;
|
||||
}
|
||||
|
||||
message DeleteTagExtendedPropertiesResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message DeleteTagsRequest {
|
||||
uint32 uiHandle = 1;
|
||||
bytes btTagnames = 2;
|
||||
}
|
||||
|
||||
message DeleteTagsResponse {
|
||||
.Status status = 1;
|
||||
bytes btDeleteTagStatus = 2;
|
||||
}
|
||||
|
||||
message AddTagLocalizedPropertiesRequest {
|
||||
string strHandle = 1;
|
||||
bytes btInput = 2;
|
||||
}
|
||||
|
||||
message AddTagLocalizedPropertiesResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message DeleteTagLocalizedPropertiesRequest {
|
||||
string strHandle = 1;
|
||||
bytes btInput = 2;
|
||||
}
|
||||
|
||||
message DeleteTagLocalizedPropertiesResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
service HistoryService {
|
||||
rpc GetInterfaceVersion (.GetInterfaceVersionRequest) returns (.GetInterfaceVersionResponse);
|
||||
rpc ExchangeKey (.ExchangeKeyRequest) returns (.ExchangeKeyResponse);
|
||||
rpc OpenConnection (.OpenConnectionRequest) returns (.OpenConnectionResponse);
|
||||
rpc CloseConnection (.CloseConnectionRequest) returns (.CloseConnectionResponse);
|
||||
rpc UpdateClientStatus (.UpdateClientStatusRequest) returns (.UpdateClientStatusResponse);
|
||||
rpc RegisterTags (.RegisterTagsRequest) returns (.RegisterTagsResponse);
|
||||
rpc EnsureTags (.EnsureTagsRequest) returns (.EnsureTagsResponse);
|
||||
rpc AddStreamValues (.AddStreamValuesRequest) returns (.AddStreamValuesResponse);
|
||||
rpc AddTagExtendedPropertyGroups (.AddTagExtendedPropertyRequest) returns (.AddTagExtendedPropertyResponse);
|
||||
rpc AddTagExtendedProperties (.AddTagExtendedPropertiesRequest) returns (.AddTagExtendedPropertiesResponse);
|
||||
rpc StartJob (.StartJobRequest) returns (.StartJobResponse);
|
||||
rpc GetJobStatus (.GetJobStatusRequest) returns (.GetJobStatusResponse);
|
||||
rpc DeleteTagExtendedProperties (.DeleteTagExtendedPropertiesRequest) returns (.DeleteTagExtendedPropertiesResponse);
|
||||
rpc DeleteTags (.DeleteTagsRequest) returns (.DeleteTagsResponse);
|
||||
rpc AddTagLocalizedProperties (.AddTagLocalizedPropertiesRequest) returns (.AddTagLocalizedPropertiesResponse);
|
||||
rpc DeleteTagLocalizedProperties (.DeleteTagLocalizedPropertiesRequest) returns (.DeleteTagLocalizedPropertiesResponse);
|
||||
}
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// Recovered from RetrievalService.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
|
||||
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
|
||||
syntax = "proto3";
|
||||
|
||||
import "Status.proto";
|
||||
|
||||
option csharp_namespace = "ArchestrA.Grpc.Contract.Retrieval";
|
||||
|
||||
message GetRetrievalInterfaceVersionRequest {
|
||||
}
|
||||
|
||||
message GetRetrievalInterfaceVersionResponse {
|
||||
uint32 uiError = 1;
|
||||
uint32 uiVersion = 2;
|
||||
}
|
||||
|
||||
message StartQueryRequest {
|
||||
uint32 uiHandle = 1;
|
||||
uint32 uiQueryRequestType = 2;
|
||||
bytes btRequestBuffer = 3;
|
||||
}
|
||||
|
||||
message StartQueryResponse {
|
||||
.Status status = 1;
|
||||
uint32 uiQueryHandle = 2;
|
||||
bytes btResponseBuffer = 3;
|
||||
}
|
||||
|
||||
message GetNextQueryResultBufferRequest {
|
||||
uint32 uiHandle = 1;
|
||||
uint32 uiQueryHandle = 2;
|
||||
}
|
||||
|
||||
message GetNextQueryResultBufferResponse {
|
||||
.Status status = 1;
|
||||
bytes btQueryResult = 2;
|
||||
}
|
||||
|
||||
message EndQueryRequest {
|
||||
uint32 uiHandle = 1;
|
||||
uint32 uiQueryHandle = 2;
|
||||
}
|
||||
|
||||
message EndQueryResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message GetShardTagidsByTagnameAndSourceRequest {
|
||||
string strHandle = 1;
|
||||
bytes btTagnameAndSource = 2;
|
||||
}
|
||||
|
||||
message GetShardTagidsByTagnameAndSourceResponse {
|
||||
.Status status = 1;
|
||||
bytes btShardTagids = 2;
|
||||
}
|
||||
|
||||
message GetTagInfosFromNameRequest {
|
||||
string strHandle = 1;
|
||||
bytes btTagNames = 2;
|
||||
uint32 uiSequence = 3;
|
||||
}
|
||||
|
||||
message GetTagInfosFromNameResponse {
|
||||
.Status status = 1;
|
||||
bytes btTagInfos = 2;
|
||||
uint32 uiSequence = 3;
|
||||
}
|
||||
|
||||
message GetTagExtendedPropertiesFromNameRequest {
|
||||
string strHandle = 1;
|
||||
bytes btTagNames = 2;
|
||||
uint32 uiSequence = 3;
|
||||
}
|
||||
|
||||
message GetTagExtendedPropertiesFromNameResponse {
|
||||
.Status status = 1;
|
||||
bytes btTeps = 2;
|
||||
uint32 uiSequence = 3;
|
||||
}
|
||||
|
||||
message ExecuteSqlCommandRequest {
|
||||
string strHandle = 1;
|
||||
string StrCommand = 2;
|
||||
uint32 uiOption = 3;
|
||||
uint32 uiQueryHandle = 4;
|
||||
}
|
||||
|
||||
message ExecuteSqlCommandResponse {
|
||||
.Status status = 1;
|
||||
int32 iRetValue = 2;
|
||||
uint32 uiQueryHandle = 3;
|
||||
}
|
||||
|
||||
message StartEventQueryRequest {
|
||||
uint32 uiHandle = 1;
|
||||
uint32 uiQueryRequestType = 2;
|
||||
bytes btRequest = 3;
|
||||
uint32 uiQueryHandle = 4;
|
||||
}
|
||||
|
||||
message StartEventQueryResponse {
|
||||
.Status status = 1;
|
||||
uint32 uiQueryHandle = 2;
|
||||
bytes btResonse = 3;
|
||||
}
|
||||
|
||||
message GetNextEventQueryResultBufferRequest {
|
||||
uint32 uiHandle = 1;
|
||||
uint32 uiQueryHandle = 2;
|
||||
}
|
||||
|
||||
message GetNextEventQueryResultBufferResponse {
|
||||
.Status status = 1;
|
||||
bytes btResult = 2;
|
||||
}
|
||||
|
||||
message EndEventQueryRequest {
|
||||
uint32 uiHandle = 1;
|
||||
uint32 uiQueryHandle = 2;
|
||||
}
|
||||
|
||||
message EndEventQueryResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message StartTagQueryRequest {
|
||||
string strHandle = 1;
|
||||
bytes btRequest = 2;
|
||||
}
|
||||
|
||||
message StartTagQueryResponse {
|
||||
.Status status = 1;
|
||||
bytes btResponse = 2;
|
||||
}
|
||||
|
||||
message QueryTagRequest {
|
||||
string strHandle = 1;
|
||||
uint32 uiQueryHandle = 2;
|
||||
bytes btRequest = 3;
|
||||
}
|
||||
|
||||
message QueryTagResponse {
|
||||
.Status status = 1;
|
||||
bytes btResonse = 2;
|
||||
}
|
||||
|
||||
message EndTagQueryRequest {
|
||||
string strHandle = 1;
|
||||
uint32 uiQueryHandle = 2;
|
||||
}
|
||||
|
||||
message EndTagQueryResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message GetTagLocalizedPropertiesFromNameRequest {
|
||||
string strHandle = 1;
|
||||
bytes btTagNames = 2;
|
||||
uint32 uiSequence = 3;
|
||||
}
|
||||
|
||||
message GetTagLocalizedPropertiesFromNameResponse {
|
||||
.Status status = 1;
|
||||
uint32 uiSequence = 2;
|
||||
bytes btOutBuffer = 3;
|
||||
}
|
||||
|
||||
service RetrievalService {
|
||||
rpc GetRetrievalInterfaceVersion (.GetRetrievalInterfaceVersionRequest) returns (.GetRetrievalInterfaceVersionResponse);
|
||||
rpc StartQuery (.StartQueryRequest) returns (.StartQueryResponse);
|
||||
rpc GetNextQueryResultBuffer (.GetNextQueryResultBufferRequest) returns (.GetNextQueryResultBufferResponse);
|
||||
rpc EndQuery (.EndQueryRequest) returns (.EndQueryResponse);
|
||||
rpc GetShardTagidsByTagnameAndSource (.GetShardTagidsByTagnameAndSourceRequest) returns (.GetShardTagidsByTagnameAndSourceResponse);
|
||||
rpc GetTagInfosFromName (.GetTagInfosFromNameRequest) returns (.GetTagInfosFromNameResponse);
|
||||
rpc GetTagExtendedPropertiesFromName (.GetTagExtendedPropertiesFromNameRequest) returns (.GetTagExtendedPropertiesFromNameResponse);
|
||||
rpc ExecuteSqlCommand (.ExecuteSqlCommandRequest) returns (.ExecuteSqlCommandResponse);
|
||||
rpc StartEventQuery (.StartEventQueryRequest) returns (.StartEventQueryResponse);
|
||||
rpc GetNextEventQueryResultBuffer (.GetNextEventQueryResultBufferRequest) returns (.GetNextEventQueryResultBufferResponse);
|
||||
rpc EndEventQuery (.EndEventQueryRequest) returns (.EndEventQueryResponse);
|
||||
rpc StartTagQuery (.StartTagQueryRequest) returns (.StartTagQueryResponse);
|
||||
rpc QueryTag (.QueryTagRequest) returns (.QueryTagResponse);
|
||||
rpc EndTagQuery (.EndTagQueryRequest) returns (.EndTagQueryResponse);
|
||||
rpc GetTagLocalizedPropertiesFromName (.GetTagLocalizedPropertiesFromNameRequest) returns (.GetTagLocalizedPropertiesFromNameResponse);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Recovered from Status.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
|
||||
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
|
||||
syntax = "proto3";
|
||||
|
||||
|
||||
option csharp_namespace = "ArchestrA.Grpc.Contract.RequestStatus";
|
||||
|
||||
message Status {
|
||||
bool bSuccess = 1;
|
||||
bytes btError = 2;
|
||||
}
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
// Recovered from StatusService.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
|
||||
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
|
||||
syntax = "proto3";
|
||||
|
||||
import "Status.proto";
|
||||
|
||||
option csharp_namespace = "ArchestrA.Grpc.Contract.Status";
|
||||
|
||||
message GetStatusInterfaceVersionRequest {
|
||||
}
|
||||
|
||||
message GetStatusInterfaceVersionResponse {
|
||||
uint32 uiError = 1;
|
||||
uint32 uiVersion = 2;
|
||||
}
|
||||
|
||||
message GetSystemParameterRequest {
|
||||
uint32 uiHandle = 1;
|
||||
string strParameterName = 2;
|
||||
}
|
||||
|
||||
message GetSystemParameterResponse {
|
||||
.Status status = 1;
|
||||
string strParameterValue = 2;
|
||||
}
|
||||
|
||||
message SendInfoRequest {
|
||||
string strHandle = 1;
|
||||
string strPipeName = 2;
|
||||
uint32 uiOption = 3;
|
||||
bytes btReqBuff = 4;
|
||||
string strInfoID = 5;
|
||||
}
|
||||
|
||||
message SendInfoResponse {
|
||||
.Status status = 1;
|
||||
string strInfoID = 2;
|
||||
bytes btRespBuff = 3;
|
||||
}
|
||||
|
||||
message RequestInfoRequest {
|
||||
string strHandle = 1;
|
||||
string strInfoID = 2;
|
||||
uint32 uiOffset = 3;
|
||||
}
|
||||
|
||||
message RequestInfoResponse {
|
||||
.Status status = 1;
|
||||
bytes btRespBuff = 2;
|
||||
}
|
||||
|
||||
message DeleteInfoRequest {
|
||||
string strHandle = 1;
|
||||
string strInfoID = 2;
|
||||
}
|
||||
|
||||
message DeleteInfoResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message GetHistorianInfoRequest {
|
||||
string strHandle = 1;
|
||||
bytes btRequest = 2;
|
||||
}
|
||||
|
||||
message GetHistorianInfoResponse {
|
||||
.Status status = 1;
|
||||
bytes btHistorianInfo = 2;
|
||||
}
|
||||
|
||||
message StartProcessRequest {
|
||||
string strHandle = 1;
|
||||
string strPipeName = 2;
|
||||
string strPath = 3;
|
||||
string strAuguments = 4;
|
||||
uint32 uiKeepAliveInterval = 5;
|
||||
uint32 uiKeepAliveMethod = 6;
|
||||
}
|
||||
|
||||
message StartProcessResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message StopProcessRequest {
|
||||
string strHandle = 1;
|
||||
string StrPipeName = 2;
|
||||
}
|
||||
|
||||
message StopProcessResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message PingServerRequest {
|
||||
string strHandle = 1;
|
||||
string strPipeName = 2;
|
||||
uint32 uiTimeout = 3;
|
||||
}
|
||||
|
||||
message PingServerResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message PingPipeRequest {
|
||||
string strHandle = 1;
|
||||
string strPipeName = 2;
|
||||
}
|
||||
|
||||
message PingPipeResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message ConfigureAutoStartProcessRequest {
|
||||
string strHandle = 1;
|
||||
string strPipeName = 2;
|
||||
string strPath = 3;
|
||||
string strAuguments = 4;
|
||||
uint32 uiKeepAliveInterval = 5;
|
||||
uint32 uiKeepAliveMethod = 6;
|
||||
uint32 uiStartupFlags = 7;
|
||||
}
|
||||
|
||||
message ConfigureAutoStartProcessResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message GetHistorianConsoleStatusRequest {
|
||||
string strHandle = 1;
|
||||
}
|
||||
|
||||
message GetHistorianConsoleStatusResponse {
|
||||
.Status status = 1;
|
||||
uint32 uiConsoleStatus = 2;
|
||||
}
|
||||
|
||||
message GetRuntimeParameterRequest {
|
||||
string strHandle = 1;
|
||||
bytes btRequest = 2;
|
||||
}
|
||||
|
||||
message GetRuntimeParameterResponse {
|
||||
.Status status = 1;
|
||||
bytes btResponse = 2;
|
||||
}
|
||||
|
||||
message GetSystemTimeZoneNameRequest {
|
||||
uint32 uiHandle = 1;
|
||||
}
|
||||
|
||||
message GetSystemTimeZoneNameResponse {
|
||||
.Status status = 1;
|
||||
string strSystemTimeZoneName = 2;
|
||||
}
|
||||
|
||||
message SetHistorianConsoleStatusRequest {
|
||||
string strHandle = 1;
|
||||
uint32 uiStatus = 2;
|
||||
uint32 uiOption = 3;
|
||||
}
|
||||
|
||||
message SetHistorianConsoleStatusResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message CanUpdateAreaHierarchyRequest {
|
||||
uint32 uiHandle = 1;
|
||||
}
|
||||
|
||||
message CanUpdateAreaHierarchyResponse {
|
||||
.Status status = 1;
|
||||
bool canUpdate = 2;
|
||||
}
|
||||
|
||||
message UpdateAreaHierarchyRequest {
|
||||
uint32 uiHandle = 1;
|
||||
string guid = 2;
|
||||
uint32 sequence = 3;
|
||||
bytes buffer = 4;
|
||||
}
|
||||
|
||||
message UpdateAreaHierarchyResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message UpdateObjectHierarchyRequest {
|
||||
uint32 uiHandle = 1;
|
||||
string guid = 2;
|
||||
uint32 sequence = 3;
|
||||
bytes buffer = 4;
|
||||
}
|
||||
|
||||
message UpdateObjectHierarchyResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
service StatusService {
|
||||
rpc GetStatusInterfaceVersion (.GetStatusInterfaceVersionRequest) returns (.GetStatusInterfaceVersionResponse);
|
||||
rpc GetSystemParameter (.GetSystemParameterRequest) returns (.GetSystemParameterResponse);
|
||||
rpc SendInfo (.SendInfoRequest) returns (.SendInfoResponse);
|
||||
rpc RequestInfo (.RequestInfoRequest) returns (.RequestInfoResponse);
|
||||
rpc DeleteInfo (.DeleteInfoRequest) returns (.DeleteInfoResponse);
|
||||
rpc GetHistorianInfo (.GetHistorianInfoRequest) returns (.GetHistorianInfoResponse);
|
||||
rpc StartProcess (.StartProcessRequest) returns (.StartProcessResponse);
|
||||
rpc StopProcess (.StopProcessRequest) returns (.StopProcessResponse);
|
||||
rpc PingServer (.PingServerRequest) returns (.PingServerResponse);
|
||||
rpc PingPipe (.PingPipeRequest) returns (.PingPipeResponse);
|
||||
rpc ConfigureAutoStartProcess (.ConfigureAutoStartProcessRequest) returns (.ConfigureAutoStartProcessResponse);
|
||||
rpc GetHistorianConsoleStatus (.GetHistorianConsoleStatusRequest) returns (.GetHistorianConsoleStatusResponse);
|
||||
rpc GetRuntimeParameter (.GetRuntimeParameterRequest) returns (.GetRuntimeParameterResponse);
|
||||
rpc GetSystemTimeZoneName (.GetSystemTimeZoneNameRequest) returns (.GetSystemTimeZoneNameResponse);
|
||||
rpc SetHistorianConsoleStatus (.SetHistorianConsoleStatusRequest) returns (.SetHistorianConsoleStatusResponse);
|
||||
rpc CanUpdateAreaHierarchy (.CanUpdateAreaHierarchyRequest) returns (.CanUpdateAreaHierarchyResponse);
|
||||
rpc UpdateAreaHierarchy (.UpdateAreaHierarchyRequest) returns (.UpdateAreaHierarchyResponse);
|
||||
rpc UpdateObjectHierarchy (.UpdateObjectHierarchyRequest) returns (.UpdateObjectHierarchyResponse);
|
||||
}
|
||||
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
// Recovered from StorageService.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
|
||||
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
|
||||
syntax = "proto3";
|
||||
|
||||
import "Status.proto";
|
||||
|
||||
option csharp_namespace = "ArchestrA.Grpc.Contract.Storage";
|
||||
|
||||
message GetInterfaceVersionRequest {
|
||||
}
|
||||
|
||||
message GetInterfaceVersionResponse {
|
||||
uint32 uiError = 1;
|
||||
uint32 uiVersion = 2;
|
||||
}
|
||||
|
||||
message OpenStorageConnectionRequest {
|
||||
string HostName = 1;
|
||||
string EnginePath = 2;
|
||||
uint32 FreeDiskSpace = 3;
|
||||
string ProcessName = 4;
|
||||
uint32 ProcessId = 5;
|
||||
string UserName = 6;
|
||||
bytes Password = 7;
|
||||
uint32 PwdLength = 8;
|
||||
uint32 ClientType = 9;
|
||||
uint32 ClientVersion = 10;
|
||||
uint32 ConnectionMode = 11;
|
||||
uint32 ConnectionTimeout = 12;
|
||||
string StorageSessionId = 13;
|
||||
}
|
||||
|
||||
message OpenStorageConnectionResponse {
|
||||
.Status status = 1;
|
||||
string StorageSessionId = 2;
|
||||
uint32 Handle = 3;
|
||||
uint64 ConnectionTime = 4;
|
||||
uint32 ServerStatus = 5;
|
||||
}
|
||||
|
||||
message CloseStorageConnectionRequest {
|
||||
uint32 Handle = 1;
|
||||
}
|
||||
|
||||
message CloseStorageConnectionResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message PingRequest {
|
||||
uint32 Handle = 1;
|
||||
}
|
||||
|
||||
message PingResponse {
|
||||
.Status status = 1;
|
||||
uint32 OutByteCount = 2;
|
||||
bytes OutBuff = 3;
|
||||
}
|
||||
|
||||
message AddTagsRequest {
|
||||
uint32 Handle = 1;
|
||||
uint32 ElementCount = 2;
|
||||
uint32 InByteCount = 3;
|
||||
bytes InBuff = 4;
|
||||
}
|
||||
|
||||
message AddTagsResponse {
|
||||
.Status status = 1;
|
||||
uint32 OutByteCount = 2;
|
||||
bytes OutBuff = 3;
|
||||
}
|
||||
|
||||
message RegisterTagsRequest {
|
||||
uint32 Handle = 1;
|
||||
uint32 ElementCount = 2;
|
||||
uint32 InByteCount = 3;
|
||||
bytes InBuff = 4;
|
||||
}
|
||||
|
||||
message RegisterTagsResponse {
|
||||
.Status status = 1;
|
||||
uint32 OutByteCount = 2;
|
||||
bytes OutBuff = 3;
|
||||
}
|
||||
|
||||
message AddStreamValuesRequest {
|
||||
uint32 Handle = 1;
|
||||
uint32 Size = 2;
|
||||
bytes Buffer = 3;
|
||||
}
|
||||
|
||||
message AddStreamValuesResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message GetTagIdsRequest {
|
||||
uint32 Handle = 1;
|
||||
uint32 Sequence = 2;
|
||||
}
|
||||
|
||||
message GetTagIdsResponse {
|
||||
.Status status = 1;
|
||||
uint32 Sequence = 2;
|
||||
uint32 Size = 3;
|
||||
bytes TagIds = 4;
|
||||
}
|
||||
|
||||
message GetTagsRequest {
|
||||
uint32 Handle = 1;
|
||||
uint32 TagIdsSize = 2;
|
||||
bytes TagIds = 3;
|
||||
uint32 Sequence = 4;
|
||||
}
|
||||
|
||||
message GetTagsResponse {
|
||||
.Status status = 1;
|
||||
uint32 Sequence = 2;
|
||||
uint32 TagInfosSize = 3;
|
||||
bytes TagInfos = 4;
|
||||
}
|
||||
|
||||
message FlushMetadataRequest {
|
||||
uint32 Handle = 1;
|
||||
uint32 TagIdsSize = 2;
|
||||
bytes TagIds = 3;
|
||||
}
|
||||
|
||||
message FlushMetadataResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message FlushDataRequest {
|
||||
uint32 Handle = 1;
|
||||
}
|
||||
|
||||
message FlushDataResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message LoadBlocksRequest {
|
||||
uint32 Handle = 1;
|
||||
uint32 Sequence = 2;
|
||||
}
|
||||
|
||||
message LoadBlocksResponse {
|
||||
.Status status = 1;
|
||||
uint32 Sequence = 2;
|
||||
uint32 HistoryBlockSize = 3;
|
||||
bytes HistoryBlocks = 4;
|
||||
}
|
||||
|
||||
message GetSnapshotsRequest {
|
||||
uint32 Handle = 1;
|
||||
uint64 BlockStartTime = 2;
|
||||
uint32 Sequence = 3;
|
||||
}
|
||||
|
||||
message GetSnapshotsResponse {
|
||||
.Status status = 1;
|
||||
uint32 Sequence = 2;
|
||||
uint32 SnapshotSize = 3;
|
||||
bytes Snapshot = 4;
|
||||
}
|
||||
|
||||
message StartQuerySnapshotRequest {
|
||||
uint32 Handle = 1;
|
||||
uint64 BlockStartTime = 2;
|
||||
uint32 SnapshotInfoSize = 3;
|
||||
bytes SnapshotInfo = 4;
|
||||
uint32 SnapshotQueryId = 5;
|
||||
}
|
||||
|
||||
message StartQuerySnapshotResponse {
|
||||
.Status status = 1;
|
||||
uint32 SnapshotQueryId = 2;
|
||||
}
|
||||
|
||||
message NextQuerySnapshotRequest {
|
||||
uint32 Handle = 1;
|
||||
uint32 SnapshotQueryId = 2;
|
||||
uint32 Sequence = 3;
|
||||
}
|
||||
|
||||
message NextQuerySnapshotResponse {
|
||||
.Status status = 1;
|
||||
uint32 Sequence = 2;
|
||||
uint32 SnapshotSize = 3;
|
||||
bytes Snapshot = 4;
|
||||
}
|
||||
|
||||
message EndSnapshotRequest {
|
||||
uint32 Handle = 1;
|
||||
uint32 SnapshotQueryId = 2;
|
||||
uint64 BlockStartTime = 3;
|
||||
uint32 SnapshotInfoSize = 4;
|
||||
bytes SnapshotInfo = 5;
|
||||
bool IsDeleteSnapshot = 6;
|
||||
}
|
||||
|
||||
message EndSnapshotResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message StopRequest {
|
||||
uint32 Handle = 1;
|
||||
}
|
||||
|
||||
message StopResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message ClearTagidPairsRequest {
|
||||
uint32 Handle = 1;
|
||||
}
|
||||
|
||||
message ClearTagidPairsResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message AddTagidPairsRequest {
|
||||
uint32 Handle = 1;
|
||||
uint32 ElementCount = 2;
|
||||
uint32 InByteCount = 3;
|
||||
bytes InBuff = 4;
|
||||
}
|
||||
|
||||
message AddTagidPairsResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message GetSFParameterRequest {
|
||||
uint32 Handle = 1;
|
||||
string ParameterName = 2;
|
||||
}
|
||||
|
||||
message GetSFParameterResponse {
|
||||
.Status status = 1;
|
||||
string ParamaterValue = 2;
|
||||
}
|
||||
|
||||
message SetSFParameterRequest {
|
||||
uint32 Handle = 1;
|
||||
string ParamaterName = 2;
|
||||
string ParamaterValue = 3;
|
||||
}
|
||||
|
||||
message SetSFParameterResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message SendSnapshotBeginRequest {
|
||||
uint32 Handle = 1;
|
||||
uint64 TotalSize = 2;
|
||||
uint64 StartTime = 3;
|
||||
uint64 EndTime = 4;
|
||||
string StorageSessionId = 5;
|
||||
}
|
||||
|
||||
message SendSnapshotBeginResponse {
|
||||
.Status status = 1;
|
||||
string StorageSessionId = 2;
|
||||
uint32 QueryId = 3;
|
||||
}
|
||||
|
||||
message SendSnapshotEndRequest {
|
||||
uint32 Handle = 1;
|
||||
string StorageSessionId = 2;
|
||||
uint32 QueryId = 3;
|
||||
uint32 TimeRangeSize = 4;
|
||||
bytes TimeRangeBytes = 5;
|
||||
}
|
||||
|
||||
message SendSnapshotEndResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message SendSnapshotRequest {
|
||||
uint32 Handle = 1;
|
||||
string StorageSessionId = 2;
|
||||
uint32 QueryId = 3;
|
||||
uint32 Size = 4;
|
||||
uint64 SnapShotChunkOffset = 5;
|
||||
bytes Buffer = 6;
|
||||
}
|
||||
|
||||
message SendSnapshotResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message DeleteSnapshotRequest {
|
||||
uint32 Handle = 1;
|
||||
uint64 StartTime = 2;
|
||||
uint32 SnapshotInfoSize = 3;
|
||||
bytes SnapshotInfo = 4;
|
||||
}
|
||||
|
||||
message DeleteSnapshotResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message AddStreamValues2Request {
|
||||
uint32 Handle = 1;
|
||||
string ShardId = 2;
|
||||
bytes Buffer = 3;
|
||||
}
|
||||
|
||||
message AddStreamValues2Response {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message ClearShardTagidsRequest {
|
||||
uint32 Handle = 1;
|
||||
}
|
||||
|
||||
message ClearShardTagidsResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message AddShardTagidsRequest {
|
||||
uint32 Handle = 1;
|
||||
bytes Buffer = 2;
|
||||
}
|
||||
|
||||
message AddShardTagidsResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message SplitUnknownShardsRequest {
|
||||
uint32 Handle = 1;
|
||||
}
|
||||
|
||||
message SplitUnknownShardsResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message GetRemainingSnapshotsSizeRequest {
|
||||
uint32 Handle = 1;
|
||||
}
|
||||
|
||||
message GetRemainingSnapshotsSizeResponse {
|
||||
.Status status = 1;
|
||||
uint64 SnapshotSize = 2;
|
||||
}
|
||||
|
||||
message DeleteTagsRequest {
|
||||
uint32 Handle = 1;
|
||||
bytes Buffer = 2;
|
||||
}
|
||||
|
||||
message DeleteTagsResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message OpenStorageConnection2Request {
|
||||
bytes InParameters = 1;
|
||||
}
|
||||
|
||||
message OpenStorageConnection2Response {
|
||||
.Status status = 1;
|
||||
bytes OutParmaters = 2;
|
||||
}
|
||||
|
||||
message ValidateClientCredentialRequest {
|
||||
string Handle = 1;
|
||||
bytes InBuff = 2;
|
||||
}
|
||||
|
||||
message ValidateClientCredentialResponse {
|
||||
.Status status = 1;
|
||||
bytes OutBuff = 2;
|
||||
}
|
||||
|
||||
message GetInfoRequest {
|
||||
string Request = 1;
|
||||
}
|
||||
|
||||
message GetInfoResponse {
|
||||
.Status status = 1;
|
||||
bytes info = 2;
|
||||
}
|
||||
|
||||
service StorageService {
|
||||
rpc GetInterfaceVersion (.GetInterfaceVersionRequest) returns (.GetInterfaceVersionResponse);
|
||||
rpc OpenStorageConnection (.OpenStorageConnectionRequest) returns (.OpenStorageConnectionResponse);
|
||||
rpc CloseStorageConnection (.CloseStorageConnectionRequest) returns (.CloseStorageConnectionResponse);
|
||||
rpc Ping (.PingRequest) returns (.PingResponse);
|
||||
rpc AddTags (.AddTagsRequest) returns (.AddTagsResponse);
|
||||
rpc RegisterTags (.RegisterTagsRequest) returns (.RegisterTagsResponse);
|
||||
rpc AddStreamValues (.AddStreamValuesRequest) returns (.AddStreamValuesResponse);
|
||||
rpc GetTagIds (.GetTagIdsRequest) returns (.GetTagIdsResponse);
|
||||
rpc GetTags (.GetTagsRequest) returns (.GetTagsResponse);
|
||||
rpc FlushMetadata (.FlushMetadataRequest) returns (.FlushMetadataResponse);
|
||||
rpc FlushData (.FlushDataRequest) returns (.FlushDataResponse);
|
||||
rpc LoadBlocks (.LoadBlocksRequest) returns (.LoadBlocksResponse);
|
||||
rpc GetSnapshots (.GetSnapshotsRequest) returns (.GetSnapshotsResponse);
|
||||
rpc StartQuerySnapshot (.StartQuerySnapshotRequest) returns (.StartQuerySnapshotResponse);
|
||||
rpc NextQuerySnapshot (.NextQuerySnapshotRequest) returns (.NextQuerySnapshotResponse);
|
||||
rpc EndSnapshot (.EndSnapshotRequest) returns (.EndSnapshotResponse);
|
||||
rpc Stop (.StopRequest) returns (.StopResponse);
|
||||
rpc ClearTagidPairs (.ClearTagidPairsRequest) returns (.ClearTagidPairsResponse);
|
||||
rpc AddTagidPairs (.AddTagidPairsRequest) returns (.AddTagidPairsResponse);
|
||||
rpc GetSFParameter (.GetSFParameterRequest) returns (.GetSFParameterResponse);
|
||||
rpc SetSFParameter (.SetSFParameterRequest) returns (.SetSFParameterResponse);
|
||||
rpc SendSnapshotBegin (.SendSnapshotBeginRequest) returns (.SendSnapshotBeginResponse);
|
||||
rpc SendSnapshotEnd (.SendSnapshotEndRequest) returns (.SendSnapshotEndResponse);
|
||||
rpc SendSnapshot (.SendSnapshotRequest) returns (.SendSnapshotResponse);
|
||||
rpc DeleteSnapshot (.DeleteSnapshotRequest) returns (.DeleteSnapshotResponse);
|
||||
rpc AddStreamValues2 (.AddStreamValues2Request) returns (.AddStreamValues2Response);
|
||||
rpc ClearShardTagids (.ClearShardTagidsRequest) returns (.ClearShardTagidsResponse);
|
||||
rpc AddShardTagids (.AddShardTagidsRequest) returns (.AddShardTagidsResponse);
|
||||
rpc SplitUnknownShards (.SplitUnknownShardsRequest) returns (.SplitUnknownShardsResponse);
|
||||
rpc GetRemainingSnapshotsSize (.GetRemainingSnapshotsSizeRequest) returns (.GetRemainingSnapshotsSizeResponse);
|
||||
rpc DeleteTags (.DeleteTagsRequest) returns (.DeleteTagsResponse);
|
||||
rpc OpenStorageConnection2 (.OpenStorageConnection2Request) returns (.OpenStorageConnection2Response);
|
||||
rpc ValidateClientCredential (.ValidateClientCredentialRequest) returns (.ValidateClientCredentialResponse);
|
||||
rpc GetInfo (.GetInfoRequest) returns (.GetInfoResponse);
|
||||
}
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
// Recovered from TransactionService.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
|
||||
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
|
||||
syntax = "proto3";
|
||||
|
||||
import "Status.proto";
|
||||
|
||||
option csharp_namespace = "ArchestrA.Grpc.Contract.Transaction";
|
||||
|
||||
message ForwardSnapshotRequest {
|
||||
string strHandle = 1;
|
||||
string strSessionID = 2;
|
||||
uint32 queryID = 3;
|
||||
uint64 snapShotChunkOffset = 4;
|
||||
bytes btInput = 5;
|
||||
}
|
||||
|
||||
message ForwardSnapshotResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message ForwardSnapshotBeginRequest {
|
||||
string strHandle = 1;
|
||||
uint64 totalSize = 2;
|
||||
uint64 startTime = 3;
|
||||
uint64 endTime = 4;
|
||||
}
|
||||
|
||||
message ForwardSnapshotBeginResponse {
|
||||
string strSessionID = 1;
|
||||
uint32 queryID = 2;
|
||||
.Status status = 3;
|
||||
}
|
||||
|
||||
message ForwardSnapshotEndRequest {
|
||||
string strHandle = 1;
|
||||
string strSessionID = 2;
|
||||
uint32 queryID = 3;
|
||||
bytes timeRange = 4;
|
||||
}
|
||||
|
||||
message ForwardSnapshotEndResponse {
|
||||
bytes tagIds = 1;
|
||||
.Status status = 2;
|
||||
}
|
||||
|
||||
message GetTransactionInterfaceVersionRequest {
|
||||
}
|
||||
|
||||
message GetTransactionInterfaceVersionResponse {
|
||||
uint32 error = 1;
|
||||
uint32 version = 2;
|
||||
}
|
||||
|
||||
message AddNonStreamValuesBeginRequest {
|
||||
string strHandle = 1;
|
||||
}
|
||||
|
||||
message AddNonStreamValuesBeginResponse {
|
||||
.Status status = 1;
|
||||
string strTransactionId = 2;
|
||||
}
|
||||
|
||||
message AddNonStreamValuesRequest {
|
||||
string strHandle = 1;
|
||||
string strTransactionId = 2;
|
||||
bytes btInput = 3;
|
||||
}
|
||||
|
||||
message AddNonStreamValuesResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
message AddNonStreamValuesEndRequest {
|
||||
string strHandle = 1;
|
||||
string strTransactionId = 2;
|
||||
bool bCommit = 3;
|
||||
}
|
||||
|
||||
message AddNonStreamValuesEndResponse {
|
||||
.Status status = 1;
|
||||
}
|
||||
|
||||
service TransactionService {
|
||||
rpc ForwardSnapshot (.ForwardSnapshotRequest) returns (.ForwardSnapshotResponse);
|
||||
rpc ForwardSnapshotBegin (.ForwardSnapshotBeginRequest) returns (.ForwardSnapshotBeginResponse);
|
||||
rpc ForwardSnapshotEnd (.ForwardSnapshotEndRequest) returns (.ForwardSnapshotEndResponse);
|
||||
rpc GetTransactionInterfaceVersion (.GetTransactionInterfaceVersionRequest) returns (.GetTransactionInterfaceVersionResponse);
|
||||
rpc AddNonStreamValuesBegin (.AddNonStreamValuesBeginRequest) returns (.AddNonStreamValuesBeginResponse);
|
||||
rpc AddNonStreamValues (.AddNonStreamValuesRequest) returns (.AddNonStreamValuesResponse);
|
||||
rpc AddNonStreamValuesEnd (.AddNonStreamValuesEndRequest) returns (.AddNonStreamValuesEndResponse);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Protocol;
|
||||
using ZB.MOM.WW.SPHistorianClient.Transport;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates the named tag in the Historian Runtime database via
|
||||
/// <c>EnsureTags2</c>. Currently only <see cref="HistorianDataType.Float"/> is
|
||||
/// live-verified. Note: writing data values to the new tag (via a separate
|
||||
/// AddStreamedValue/AddS2 path) is NOT supported by the SDK — see
|
||||
/// <c>docs/plans/write-commands-reverse-engineering.md</c> for the architectural
|
||||
/// finding.
|
||||
/// </summary>
|
||||
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the named tag via <c>DeleteTags</c>. **Known issue (2026-05-04):**
|
||||
/// the SDK's DelT call returns true but the server-side cascading deletion does
|
||||
/// not always complete (the row remains in <c>Runtime.dbo.Tag</c>). The
|
||||
/// captured native flow's DelT removes the tag cleanly, so additional priming
|
||||
/// or a side call between WCF DelT and server cascade is missing. Use the SMC
|
||||
/// fallback to clean up sandbox tags until this is resolved.
|
||||
/// </summary>
|
||||
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, 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,65 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient;
|
||||
|
||||
public sealed class HistorianClientOptions
|
||||
{
|
||||
public const int DefaultPort = 32568;
|
||||
|
||||
/// <summary>Default TCP port of the 2023 R2 Historian Client Access Point gRPC endpoint.</summary>
|
||||
public const int DefaultGrpcPort = 32565;
|
||||
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// When true, the WCF channel factories used by the SDK accept the server's
|
||||
/// X.509 certificate without chain validation. Useful when connecting to a
|
||||
/// development / on-prem Historian whose <c>/HistCert</c> endpoint presents an
|
||||
/// installer-generated self-signed cert that isn't in the local trust store
|
||||
/// (notably .NET WCF on Linux ignores the system CA bundle for its own
|
||||
/// X509Chain checks). Default false; do not enable in production where the
|
||||
/// server's identity matters.
|
||||
/// </summary>
|
||||
public bool AllowUntrustedServerCertificate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the expected DNS identity in the endpoint address — set this to
|
||||
/// whatever DNS name the server's certificate actually claims (often
|
||||
/// <c>localhost</c> on installer-generated AVEVA Historian certificates) when
|
||||
/// connecting via IP address or a hostname that doesn't match the cert SAN/CN.
|
||||
/// Without this override WCF rejects the channel with
|
||||
/// "Identity check failed for outgoing message". Has no effect on transports
|
||||
/// that don't validate a server certificate.
|
||||
/// </summary>
|
||||
public string? ServerDnsIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For <see cref="HistorianTransport.RemoteGrpc"/>: when true the channel uses TLS
|
||||
/// (<c>https://</c>); when false it uses plaintext (<c>http://</c>). Matches the stock
|
||||
/// 2023 R2 client's <c>securedConnection</c> flag. The TLS host is taken from
|
||||
/// <see cref="ServerDnsIdentity"/> when set (to match the server certificate's name),
|
||||
/// otherwise <see cref="Host"/>. When <see cref="AllowUntrustedServerCertificate"/> is
|
||||
/// true the server certificate chain is not validated. Default false.
|
||||
/// </summary>
|
||||
public bool GrpcUseTls { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient;
|
||||
|
||||
public enum HistorianTransport
|
||||
{
|
||||
LocalPipe = 0,
|
||||
RemoteTcpIntegrated = 1,
|
||||
RemoteTcpCertificate = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC transport (Historian Client Access Point gRPC-Web endpoint, default
|
||||
/// TCP port 32565). Carries the same native binary payloads as the WCF transports inside
|
||||
/// protobuf <c>bytes</c> fields. See <c>Grpc/HistorianGrpcReadOrchestrator</c>.
|
||||
/// </summary>
|
||||
RemoteGrpc = 3
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public enum AggregationType
|
||||
{
|
||||
Minimum,
|
||||
Maximum,
|
||||
Average,
|
||||
Total,
|
||||
Percent,
|
||||
MinContained,
|
||||
MaxContained,
|
||||
TotalContained,
|
||||
AverageContained,
|
||||
PercentContained
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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 ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public sealed record HistorianBlock(
|
||||
string TagName,
|
||||
DateTime StartTimeUtc,
|
||||
DateTime EndTimeUtc,
|
||||
IReadOnlyList<HistorianSample> Samples);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
[Flags]
|
||||
public enum HistorianConnectionKind
|
||||
{
|
||||
Process = 1,
|
||||
Event = 2
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public sealed record HistorianConnectionStatus(
|
||||
string ServerName,
|
||||
bool Pending,
|
||||
bool ErrorOccurred,
|
||||
string? Error,
|
||||
bool ConnectedToServer,
|
||||
bool ConnectedToServerStorage,
|
||||
bool ConnectedToStoreForward,
|
||||
HistorianConnectionKind ConnectionKind);
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public sealed record HistorianDataValue(
|
||||
string TagName,
|
||||
DateTime TimestampUtc,
|
||||
double? NumericValue,
|
||||
string? StringValue,
|
||||
ushort Quality = 192,
|
||||
uint QualityDetail = 0);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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 ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public sealed record HistorianSample(
|
||||
string TagName,
|
||||
DateTime TimestampUtc,
|
||||
double? NumericValue,
|
||||
string? StringValue,
|
||||
ushort Quality,
|
||||
uint QualityDetail,
|
||||
ushort OpcQuality,
|
||||
double PercentGood);
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Storage strategy for historized samples. Maps to <c>Tag.StorageType</c> in the
|
||||
/// Runtime DB. Values match the captured native enum and the server-persisted
|
||||
/// integer column.
|
||||
/// </summary>
|
||||
public enum HistorianStorageType
|
||||
{
|
||||
/// <summary>
|
||||
/// Sample on a fixed cadence (see <c>HistorianTagDefinition.StorageRateMs</c>).
|
||||
/// </summary>
|
||||
Cyclic = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Sample only on value change (with optional value/time/rate deadbands).
|
||||
/// </summary>
|
||||
Delta = 2,
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public sealed record HistorianStoreForwardStatus(
|
||||
string ServerName,
|
||||
bool Pending,
|
||||
bool ErrorOccurred,
|
||||
string? Error,
|
||||
bool DataStored,
|
||||
bool Storing,
|
||||
HistorianConnectionKind ConnectionKind);
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Input model for <see cref="HistorianClient.EnsureTagAsync"/>. Live-verified data
|
||||
/// types: Float, Double, Int2, Int4, UInt4 (probed 2026-05-04 via instrument-wcf-writemessage).
|
||||
/// String/Int1/Int8/UInt8 types failed at native AddTag — likely require a different
|
||||
/// path and are intentionally not supported. MinEU/MaxEU/MinRaw/MaxRaw are now encoded
|
||||
/// into the wire payload (see <c>HistorianTagWriteProtocol</c>).
|
||||
///
|
||||
/// Semantics: <c>EnsureTagAsync</c> is an upsert. Calling it twice on the same
|
||||
/// <see cref="TagName"/> with different fields succeeds both times; the second call
|
||||
/// updates Description, MinEU, MaxEU, MinRaw, MaxRaw, and AnalogTag.Scaling on the
|
||||
/// existing row (verified 2026-05-04 by direct SQL inspection after sequential calls).
|
||||
/// </summary>
|
||||
public sealed record HistorianTagDefinition
|
||||
{
|
||||
/// <summary>Tag name (ASCII; up to 255 chars per server limit).</summary>
|
||||
public required string TagName { get; init; }
|
||||
|
||||
/// <summary>Tag description (free text; up to 255 chars).</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Engineering unit label (e.g. "Seconds", "kPa"). Required for analog tags.</summary>
|
||||
public string? EngineeringUnit { get; init; }
|
||||
|
||||
/// <summary>Native data type. Float, Double, Int2, Int4, UInt4 are live-verified.</summary>
|
||||
public HistorianDataType DataType { get; init; } = HistorianDataType.Float;
|
||||
|
||||
/// <summary>Engineering-units lower bound. Default 0.</summary>
|
||||
public double MinEU { get; init; }
|
||||
|
||||
/// <summary>Engineering-units upper bound. Default 100.</summary>
|
||||
public double MaxEU { get; init; } = 100.0;
|
||||
|
||||
/// <summary>
|
||||
/// Raw lower bound (pre-scaling). Default 0. Persisted distinctly only when
|
||||
/// <see cref="ApplyScaling"/> is true; with ApplyScaling=false the server mirrors
|
||||
/// this to MinEU on EnsureTags2 (verified 2026-05-04 against both native and
|
||||
/// managed clients).
|
||||
/// </summary>
|
||||
public double MinRaw { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw upper bound (pre-scaling). Default 100. See <see cref="MinRaw"/> for the
|
||||
/// ApplyScaling caveat.
|
||||
/// </summary>
|
||||
public double MaxRaw { get; init; } = 100.0;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the server persists <see cref="MinRaw"/> / <see cref="MaxRaw"/> as
|
||||
/// distinct values from <see cref="MinEU"/> / <see cref="MaxEU"/> and sets
|
||||
/// <c>AnalogTag.Scaling</c> = 1. When false (default), the server mirrors MinRaw
|
||||
/// to MinEU and MaxRaw to MaxEU and sets <c>AnalogTag.Scaling</c> = 0.
|
||||
/// </summary>
|
||||
public bool ApplyScaling { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Storage rate in milliseconds. Default 1000ms. The server only accepts
|
||||
/// quantized values (observed valid set: 1000, 5000, 10000, 60000, 300000) —
|
||||
/// non-quantized values cause <see cref="HistorianClient.EnsureTagAsync"/> to
|
||||
/// return false.
|
||||
/// </summary>
|
||||
public uint StorageRateMs { get; init; } = 1000u;
|
||||
|
||||
/// <summary>
|
||||
/// Storage strategy. Default <see cref="HistorianStorageType.Cyclic"/> samples
|
||||
/// on the configured <see cref="StorageRateMs"/> cadence. <see cref="HistorianStorageType.Delta"/>
|
||||
/// samples only on value change. The server persists this to <c>Tag.StorageType</c>
|
||||
/// (Cyclic = 1, Delta = 2).
|
||||
/// </summary>
|
||||
public HistorianStorageType StorageType { get; init; } = HistorianStorageType.Cyclic;
|
||||
|
||||
/// <summary>
|
||||
/// Divisor applied when storing integral values for trend integration. Default 1.0.
|
||||
/// Wire bytes flip correctly per the captured native serializer, but live testing
|
||||
/// 2026-05-05 showed the server stores <c>IntegralDivisor</c> on
|
||||
/// <c>EngineeringUnit</c> (shared across all tags using that EU) rather than
|
||||
/// per-tag — so a non-default value sent here is accepted on the wire but does
|
||||
/// not visibly persist in <c>EngineeringUnit.IntegralDivisor</c> for the test
|
||||
/// EU. Exposed for completeness and forward-compatibility; check your server's
|
||||
/// behavior before relying on it.
|
||||
/// </summary>
|
||||
public double IntegralDivisor { get; init; } = 1.0;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public sealed record HistorianTagMetadata(
|
||||
string Name,
|
||||
uint? Key,
|
||||
HistorianDataType DataType,
|
||||
string? Description = null,
|
||||
string? EngineeringUnit = null,
|
||||
double? MinRaw = null,
|
||||
double? MaxRaw = null);
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public enum InterpolationType
|
||||
{
|
||||
StairStep = 0,
|
||||
Linear = 1,
|
||||
SystemDefault = 254,
|
||||
None = 255
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public enum QualityRule
|
||||
{
|
||||
Extended,
|
||||
Good,
|
||||
None,
|
||||
Optimistic
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public enum RetrievalMode
|
||||
{
|
||||
Cyclic,
|
||||
Delta,
|
||||
Full,
|
||||
Interpolated,
|
||||
BestFit,
|
||||
TimeWeightedAverage,
|
||||
MinimumWithTime,
|
||||
MaximumWithTime,
|
||||
Integral,
|
||||
Slope,
|
||||
Counter,
|
||||
ValueState,
|
||||
RoundTrip,
|
||||
StartBound,
|
||||
EndBound
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public enum TimestampRule
|
||||
{
|
||||
Start,
|
||||
End,
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
public enum ValueSelector
|
||||
{
|
||||
Auto = 1,
|
||||
First,
|
||||
Last,
|
||||
Integral,
|
||||
StandardDeviation,
|
||||
Minimum,
|
||||
Maximum,
|
||||
Average
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
|
||||
|
||||
internal sealed class FrameFormatException : Exception
|
||||
{
|
||||
public FrameFormatException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Grpc;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
|
||||
|
||||
internal sealed class Historian2020ProtocolDialect
|
||||
{
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public Historian2020ProtocolDialect(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
private bool UseGrpc => _options.Transport == HistorianTransport.RemoteGrpc;
|
||||
|
||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken)
|
||||
{
|
||||
return UseGrpc
|
||||
? new HistorianGrpcReadOrchestrator(_options).ReadRawAsync(tag, startUtc, endUtc, maxValues, cancellationToken)
|
||||
: new HistorianWcfReadOrchestrator(_options).ReadRawAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken)
|
||||
{
|
||||
return UseGrpc
|
||||
? new HistorianGrpcReadOrchestrator(_options).ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, cancellationToken)
|
||||
: new HistorianWcfReadOrchestrator(_options).ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return UseGrpc
|
||||
? new HistorianGrpcReadOrchestrator(_options).ReadAtTimeAsync(tag, timestampsUtc, cancellationToken)
|
||||
: new HistorianWcfReadOrchestrator(_options).ReadAtTimeAsync(tag, timestampsUtc, cancellationToken);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
HistorianWcfEventOrchestrator orchestrator = new(_options);
|
||||
return orchestrator.ReadEventsAsync(startUtc, endUtc, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Wcf.HistorianWcfStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string?> GetSystemParameterAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
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
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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 ZB.MOM.WW.SPHistorianClient.Protocol;
|
||||
|
||||
internal readonly record struct HistorianFrame(
|
||||
HistorianMessageType MessageType,
|
||||
uint CorrelationId,
|
||||
ReadOnlyMemory<byte> Payload);
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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();
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
|
||||
|
||||
internal enum HistorianMessageType : ushort
|
||||
{
|
||||
Unknown = 0
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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";
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient;
|
||||
|
||||
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; }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient;
|
||||
|
||||
public sealed class ProtocolNotImplementedException : NotImplementedException
|
||||
{
|
||||
public ProtocolNotImplementedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Transport;
|
||||
|
||||
internal interface IHistorianTransportFactory
|
||||
{
|
||||
IHistorianTransport Create();
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.ServiceModel;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// V2 surface — discovered by inspecting CHistoryConnectionWCF.AddNonStreamValuesBegin's
|
||||
/// IL (token 0x06004051), which calls
|
||||
/// <c>ITransactionServiceContract2::AddNonStreamValuesBegin2(string, ref string, ref byte[])</c>
|
||||
/// before falling back to V1. The V2 ops use the GUID-string handle pattern matching
|
||||
/// other V2 ops on /Hist (EnsT2, AddS2, RTag2) plus an out-byte[] errorBuffer.
|
||||
/// </remarks>
|
||||
[ServiceContract(Name = HistorianWcfServiceNames.Transaction, Namespace = HistorianWcfServiceNames.Namespace)]
|
||||
internal interface ITransactionServiceContract2
|
||||
{
|
||||
[OperationContract(Name = "GetV")]
|
||||
uint GetInterfaceVersion(out uint version);
|
||||
|
||||
[OperationContract]
|
||||
[return: MarshalAs(UnmanagedType.U1)]
|
||||
bool AddNonStreamValuesBegin2(
|
||||
string handle,
|
||||
out string transactionId,
|
||||
out byte[] errorBuffer);
|
||||
|
||||
[OperationContract]
|
||||
[return: MarshalAs(UnmanagedType.U1)]
|
||||
bool AddNonStreamValues2(
|
||||
string handle,
|
||||
string transactionId,
|
||||
[MessageParameter(Name = "pBuf")] byte[] buffer,
|
||||
out byte[] errorBuffer);
|
||||
|
||||
[OperationContract]
|
||||
[return: MarshalAs(UnmanagedType.U1)]
|
||||
bool AddNonStreamValuesEnd2(
|
||||
string handle,
|
||||
string transactionId,
|
||||
[MarshalAs(UnmanagedType.U1)] bool commit,
|
||||
out byte[] errorBuffer);
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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 + 35-byte
|
||||
/// trailing block. 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.
|
||||
///
|
||||
/// Trailing 35 bytes (cross-tag verified 2026-05-04 against SysTimeSec — structure is
|
||||
/// tag-independent, server-internal sample metadata):
|
||||
/// bytes 0-2 constant 0x00 0x00 0x01 (sample-format marker)
|
||||
/// bytes 3-10 Int64 FILETIME UTC — duplicate of startTime for raw rows;
|
||||
/// aggregate parser reads it as the interval start (offset row+tail+43)
|
||||
/// bytes 11-18 zeros (reserved — likely end-time slot, populated by aggregate variants)
|
||||
/// bytes 19-26 varies row-to-row even for identical Quality/Value; likely a storage
|
||||
/// block sequence ID or snapshot offset. No user-facing meaning surfaced.
|
||||
/// bytes 27,29 flag bytes (0/1 and 0/4 observed); semantics undecoded
|
||||
/// bytes 28, 30-34 zeros (reserved)
|
||||
/// No public HistorianSample fields map to bytes 19-34 — they look like server-internal
|
||||
/// storage metadata. If a customer ever needs them surfaced, capture more rows with
|
||||
/// known-distinct properties (force-store, backfill, version-replace) to narrow down.
|
||||
/// </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);
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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));
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic pieces of the native Historian connect handshake: building the
|
||||
/// OpenConnection3 v6 request buffer, running the SSPI/NTLM token-exchange rounds, and
|
||||
/// decoding the OpenConnection response. Shared by the WCF/MDAS path
|
||||
/// (<see cref="HistorianWcfAuthChainHelper"/>) and the 2023 R2 gRPC path
|
||||
/// (<c>Grpc.HistorianGrpcReadOrchestrator</c>). The byte payloads are identical across
|
||||
/// transports — only the envelope (WCF operation vs gRPC method) differs.
|
||||
/// </summary>
|
||||
internal static class HistorianNativeHandshake
|
||||
{
|
||||
private const int CredentialBlockSizeBytes = 1026;
|
||||
private const int OpenConnectionMinResponseLength = 5;
|
||||
private const int MaxTokenRounds = 8;
|
||||
private const string ClientNodeNameFallback = "ZB.MOM.WW.SPHistorianClient";
|
||||
private const string ClientDataSourceId = "2020.406.2652.2";
|
||||
private const string ClientDllVersionString = "2020.406.2652.2";
|
||||
private const byte NativeClientType = 4;
|
||||
private const byte NativeClientCommonInfoFormatVersion = 4;
|
||||
private const ushort NativeHcalVersion = 17;
|
||||
private const uint NativeClientVersionInt = 999_999;
|
||||
private const ushort NativeOpen2ClientVersion = 9;
|
||||
|
||||
/// <summary>Result of one transport-level credential-token exchange.</summary>
|
||||
internal readonly record struct TokenExchangeResult(bool Success, byte[] ServerOutput, byte[] Error);
|
||||
|
||||
/// <summary>
|
||||
/// Performs a single credential-token round on the wire. <paramref name="handle"/> is the
|
||||
/// upper-case context-key GUID, <paramref name="wrappedToken"/> is the AVEVA-wrapped SSPI
|
||||
/// token (round byte + length + token). The WCF path maps this to
|
||||
/// <c>Hist.ValidateClientCredential</c>; the gRPC path maps it to
|
||||
/// <c>HistoryService.ExchangeKey</c> (the renamed handshake op).
|
||||
/// </summary>
|
||||
internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round);
|
||||
|
||||
/// <summary>
|
||||
/// Drives the SSPI/NTLM negotiate loop against the supplied <paramref name="exchange"/>
|
||||
/// delegate until the server signals terminal success. Mirrors the native two-round
|
||||
/// (69→239, 93→1) sequence.
|
||||
/// </summary>
|
||||
public static void RunTokenRounds(
|
||||
TokenExchange exchange,
|
||||
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 < MaxTokenRounds; round++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
HistorianSspiStepResult step = sspi.Next(incoming);
|
||||
byte[] outgoing = step.Token;
|
||||
HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(outgoing);
|
||||
byte[] wrapped = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken(round == 0, outgoing);
|
||||
|
||||
TokenExchangeResult result = exchange(handle, wrapped, round);
|
||||
byte[] serverOutput = result.ServerOutput ?? [];
|
||||
byte[] error = result.Error ?? [];
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"Credential token round {round} rejected (errorLen={error.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($"Credential token exchange exceeded {MaxTokenRounds} rounds without terminal success.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the native OpenConnection3 (Open2) version-6 request buffer. Identical bytes are
|
||||
/// sent over WCF (<c>Hist.OpenConnection2</c>) and gRPC
|
||||
/// (<c>HistoryService.OpenConnection.btConnectionRequest</c>).
|
||||
/// </summary>
|
||||
public 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]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the OpenConnection response blob: byte 0 = protocol version, bytes 1..4 =
|
||||
/// transient /Retr client handle (UInt32 LE), bytes 5..20 = storage session GUID.
|
||||
/// </summary>
|
||||
public static (uint ClientHandle, Guid StorageSessionId) ParseOpenConnectionResponse(ReadOnlySpan<byte> response)
|
||||
{
|
||||
if (response.Length < OpenConnectionMinResponseLength)
|
||||
{
|
||||
throw new InvalidOperationException($"OpenConnection response too short (ResponseLen={response.Length}).");
|
||||
}
|
||||
|
||||
uint clientHandle = BinaryPrimitives.ReadUInt32LittleEndian(response.Slice(1, 4));
|
||||
Guid storageSessionId = response.Length >= 21 ? new Guid(response.Slice(5, 16)) : Guid.Empty;
|
||||
return (clientHandle, storageSessionId);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication.ExtendedProtection;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
/// <remarks>
|
||||
/// Cross-platform Negotiate / NTLM token producer for the Historian's `Hist.ValCl`
|
||||
/// authentication exchange. Uses <see cref="NegotiateAuthentication"/> under the hood
|
||||
/// (Windows: SSPI; Linux/macOS: GSSAPI via <c>libgssapi_krb5</c> / <c>gss-ntlmssp</c>).
|
||||
///
|
||||
/// The native AVEVA wrapper passes specific request flags to
|
||||
/// <c>InitializeSecurityContextW</c>: <c>IDENTIFY | CONNECTION | CONFIDENTIALITY |
|
||||
/// SEQUENCE_DETECT | REPLAY_DETECT</c> on round 0 and the same minus IDENTIFY on
|
||||
/// rounds 1+. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation;
|
||||
/// without it AcceptSecurityContext rejects the type-3 token with
|
||||
/// SEC_E_INVALID_TOKEN. <c>RequiredProtectionLevel.EncryptAndSign</c> in
|
||||
/// NegotiateAuthentication implicitly requests SEQUENCE + REPLAY +
|
||||
/// CONFIDENTIALITY, and <c>AllowedImpersonationLevel = Identification</c> requests
|
||||
/// IDENTIFY — together these produce a request flag set that AcceptSecurityContext
|
||||
/// accepts on the server side.
|
||||
///
|
||||
/// The constants and request-flag selection helpers below are preserved for the
|
||||
/// existing unit tests in <c>HistorianSspiClientTests</c> — they document the
|
||||
/// captured native flag values rather than driving the underlying API today.
|
||||
/// </remarks>
|
||||
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 readonly NegotiateAuthentication _auth;
|
||||
private int _roundIndex;
|
||||
private bool _disposed;
|
||||
|
||||
public HistorianSspiClient(string targetName, string package = "Negotiate")
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(package);
|
||||
_auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
|
||||
{
|
||||
Package = package,
|
||||
TargetName = targetName,
|
||||
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
|
||||
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
|
||||
RequireMutualAuthentication = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Acquires Negotiate credentials for an explicit user/domain/password instead
|
||||
/// of the calling thread's identity. On Linux this routes through GSSAPI's
|
||||
/// credential acquisition; the supplied credential is wrapped in a
|
||||
/// <see cref="NetworkCredential"/>.
|
||||
/// </remarks>
|
||||
public HistorianSspiClient(string targetName, string? domain, string userName, string? password, string package = "Negotiate")
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(package);
|
||||
_auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
|
||||
{
|
||||
Package = package,
|
||||
TargetName = targetName,
|
||||
Credential = new NetworkCredential(userName, password ?? string.Empty, domain ?? string.Empty),
|
||||
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
|
||||
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
|
||||
RequireMutualAuthentication = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call corresponds to.</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);
|
||||
|
||||
byte[]? outgoing = _auth.GetOutgoingBlob(incoming.Length == 0 ? null : incoming, out NegotiateAuthenticationStatusCode status);
|
||||
_roundIndex++;
|
||||
|
||||
bool completed = status switch
|
||||
{
|
||||
NegotiateAuthenticationStatusCode.Completed => true,
|
||||
NegotiateAuthenticationStatusCode.ContinueNeeded => false,
|
||||
_ => throw new InvalidOperationException($"Negotiate handshake failed: {status}"),
|
||||
};
|
||||
|
||||
return new HistorianSspiStepResult(outgoing ?? [], completed);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_auth.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct HistorianSspiStepResult(byte[] Token, bool IsCompleted);
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
|
||||
// The compact-ASCII string slot count varies by tag origin (decoded from
|
||||
// GetTagInfoFromName captures across multiple tag types):
|
||||
// 1 string : TagName only (degenerate / unknown shape)
|
||||
// 2 strings : TagName + MetadataProvider (e.g., MDAS-routed external tags)
|
||||
// 4 strings : TagName + Description + ItemName + CreatedBy (local Sys tags)
|
||||
// Walk strings dynamically until the next byte isn't the 0x09 marker.
|
||||
List<string> strings = new(4);
|
||||
while (cursor < response.Length && response[cursor] == 0x09)
|
||||
{
|
||||
strings.Add(ReadCompactAsciiString(response, ref cursor));
|
||||
}
|
||||
|
||||
string tagName = strings.Count > 0 ? strings[0] : string.Empty;
|
||||
// String at position 1 is Description for full-shape tags or MetadataProvider
|
||||
// for MDAS-routed tags. Both are useful; expose under MetadataProvider for back-compat
|
||||
// and Description for new semantics.
|
||||
string metadataProvider = strings.Count > 1 ? strings[1] : string.Empty;
|
||||
string? description = strings.Count >= 4 ? strings[1] : null;
|
||||
|
||||
EnsureAvailable(response, cursor, 4);
|
||||
byte nativeTagClass = response[cursor++];
|
||||
byte storageType = response[cursor++];
|
||||
byte deadbandType = response[cursor++];
|
||||
byte interpolationType = response[cursor++];
|
||||
|
||||
// Trailing region after the fixed 4-byte block holds:
|
||||
// - some alignment / int32 fields (StorageRate, AcquisitionRate, TimeDeadband)
|
||||
// - Int64 FILETIME (DateCreated)
|
||||
// - For analog tags: pair of doubles (MinEU/MaxEU and/or MinRaw/MaxRaw)
|
||||
// - Optional compact-ASCII EngineeringUnit string
|
||||
// - Optional double RolloverValue
|
||||
// - Trailer marker (often FE 00 or 00)
|
||||
// The exact layout varies by tag type and storage mode; rather than commit fragile
|
||||
// positional parsing, scan the trailing region for the first two consecutive
|
||||
// 8-byte-aligned doubles and treat them as a (MinEU, MaxEU) pair. Both must be
|
||||
// finite and the EU range must be sane (Min ≤ Max).
|
||||
ReadOnlySpan<byte> trailing = response[cursor..];
|
||||
(double? min, double? max, string? engineeringUnit) = TryReadAnalogTrailing(trailing);
|
||||
cursor = response.Length;
|
||||
|
||||
return new HistorianTagInfoResponse(
|
||||
tagName,
|
||||
tagKey,
|
||||
typeId,
|
||||
nativeDataTypeDescriptor,
|
||||
metadataProvider,
|
||||
nativeTagClass,
|
||||
storageType,
|
||||
deadbandType,
|
||||
interpolationType,
|
||||
description,
|
||||
min,
|
||||
max,
|
||||
engineeringUnit);
|
||||
}
|
||||
|
||||
private static (double? min, double? max, string? engineeringUnit) TryReadAnalogTrailing(ReadOnlySpan<byte> trailing)
|
||||
{
|
||||
double? foundMin = null;
|
||||
double? foundMax = null;
|
||||
string? foundEu = null;
|
||||
|
||||
// Look for an EngineeringUnit compact-ASCII string anywhere in the trailing region.
|
||||
for (int i = 0; i < trailing.Length - 3; i++)
|
||||
{
|
||||
if (trailing[i] != 0x09) continue;
|
||||
ushort len = BitConverter.ToUInt16(trailing.Slice(i + 1, 2));
|
||||
// Accept 1-32 byte ASCII strings as plausible EUs. Range chosen to filter false
|
||||
// positives (most engineering units are short — "kPa", "Seconds", "RPM", etc.).
|
||||
if (len < 1 || len > 32) continue;
|
||||
int payloadStart = i + 3;
|
||||
if (payloadStart + len > trailing.Length) continue;
|
||||
// All bytes must be printable ASCII.
|
||||
ReadOnlySpan<byte> payload = trailing.Slice(payloadStart, len);
|
||||
bool allAscii = true;
|
||||
foreach (byte b in payload)
|
||||
{
|
||||
if (b < 0x20 || b > 0x7E) { allAscii = false; break; }
|
||||
}
|
||||
if (!allAscii) continue;
|
||||
string candidate = Encoding.ASCII.GetString(payload);
|
||||
// Skip implausible values (numerics, mostly-special-chars).
|
||||
if (double.TryParse(candidate, out _)) continue;
|
||||
foundEu = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
// Look for two consecutive 8-byte-aligned doubles forming a sane EU range.
|
||||
// Try each plausible alignment relative to the trailing-region start.
|
||||
for (int alignOffset = 0; alignOffset < 8; alignOffset++)
|
||||
{
|
||||
for (int i = alignOffset; i + 16 <= trailing.Length; i += 8)
|
||||
{
|
||||
if (!TryReadDouble(trailing, i, out double a)) continue;
|
||||
if (!TryReadDouble(trailing, i + 8, out double b)) continue;
|
||||
// Both finite, both within sane EU range, a ≤ b.
|
||||
if (!double.IsFinite(a) || !double.IsFinite(b)) continue;
|
||||
if (Math.Abs(a) > 1e15 || Math.Abs(b) > 1e15) continue;
|
||||
if (a > b) continue;
|
||||
// Reject the all-zeros pair (uninformative).
|
||||
if (a == 0 && b == 0) continue;
|
||||
foundMin = a;
|
||||
foundMax = b;
|
||||
return (foundMin, foundMax, foundEu);
|
||||
}
|
||||
}
|
||||
return (foundMin, foundMax, foundEu);
|
||||
}
|
||||
|
||||
private static bool TryReadDouble(ReadOnlySpan<byte> buffer, int offset, out double value)
|
||||
{
|
||||
if (offset + 8 > buffer.Length) { value = 0; return false; }
|
||||
value = BitConverter.ToDouble(buffer.Slice(offset, 8));
|
||||
return true;
|
||||
}
|
||||
|
||||
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,
|
||||
string? Description = null,
|
||||
double? MinEU = null,
|
||||
double? MaxEU = null,
|
||||
string? EngineeringUnit = null);
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
/// <remarks>
|
||||
/// Serializers for the EnsT2 (CTagMetadata) and DelT (tag-name list) write paths.
|
||||
/// Decoded from native captures landed in
|
||||
/// <c>artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/bothmessage-write-with-delt-latest.ndjson</c>
|
||||
/// — see <c>docs/plans/write-commands-reverse-engineering.md</c> Phase 2 findings.
|
||||
///
|
||||
/// Per the captured analog CTagMetadata, the layout is:
|
||||
/// <code>
|
||||
/// 1-byte leading marker = 4E (purpose unclear; observed constant — possibly "CTagMetadata" type tag)
|
||||
/// 10-byte fixed header = 67 03 00 01 00 00 00 04 C6 02
|
||||
/// 1-byte data-type code = 0x01 Float, 0x21 Double, 0x29 Int2, 0x31 Int4, 0x11 UInt4
|
||||
/// 16 zero bytes (placeholder GUID + 2 bytes; future server-assigned tag id)
|
||||
/// compact ASCII tag name
|
||||
/// 16 bytes of 0xFF (sentinel — likely common-event-type GUID equivalent unused for analog)
|
||||
/// compact ASCII description
|
||||
/// compact ASCII metadata provider ("MDAS")
|
||||
/// 7-byte flag block = 02 01 01 00 00 00 01
|
||||
/// uint32 storage rate (ms)
|
||||
/// int64 date-created FILETIME UTC
|
||||
/// scaling block either compact `1A 03` (default 0/100/0/100) OR
|
||||
/// `1F 00` + 4 doubles (MinEU, MaxEU, MinRaw, MaxRaw)
|
||||
/// compact ASCII engineering unit
|
||||
/// uint32 = 0x2710 (10000 — purpose unclear; observed constant)
|
||||
/// 8-byte double = 1.0 (likely IntegralDivisor)
|
||||
/// 2-byte trailer = `FE 00` for ApplyScaling=false; `FE 01` for ApplyScaling=true
|
||||
/// </code>
|
||||
/// The trailer's second byte is the ApplyScaling flag — verified 2026-05-04 by
|
||||
/// capturing native CTagMetadata bytes for both values with identical
|
||||
/// MinEU/MaxEU/MinRaw/MaxRaw inputs and observing that the server persists distinct
|
||||
/// MinRaw/MaxRaw (and sets AnalogTag.Scaling=1) only when this byte is 0x01.
|
||||
/// </remarks>
|
||||
internal static class HistorianTagWriteProtocol
|
||||
{
|
||||
private const byte CompactAsciiMarker = 0x09;
|
||||
|
||||
/// <summary>
|
||||
/// 11 bytes preceding the data-type discriminator. Byte 0 is the leading 0x4E
|
||||
/// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is the
|
||||
/// storage-type sub-marker (`0x02` for Cyclic, `0x06` for Delta — captured
|
||||
/// 2026-05-04 by toggling --write-storage-type on the harness).
|
||||
/// </summary>
|
||||
private static readonly byte[] AnalogHeaderUpToTypeCodeCyclic =
|
||||
[
|
||||
0x4E,
|
||||
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
|
||||
0x02,
|
||||
];
|
||||
|
||||
private static readonly byte[] AnalogHeaderUpToTypeCodeDelta =
|
||||
[
|
||||
0x4E,
|
||||
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
|
||||
0x06,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Native CDataType wire codes per data type — captured 2026-05-04 by probing
|
||||
/// every type via instrument-wcf-writemessage. Matches the codes already documented
|
||||
/// in <see cref="HistorianWcfTagClient"/> MapDataType for the read path.
|
||||
/// </summary>
|
||||
public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch
|
||||
{
|
||||
Models.HistorianDataType.Float => 0x01,
|
||||
Models.HistorianDataType.Double => 0x21,
|
||||
Models.HistorianDataType.UInt2 => 0x09,
|
||||
Models.HistorianDataType.UInt4 => 0x11,
|
||||
Models.HistorianDataType.Int2 => 0x29,
|
||||
Models.HistorianDataType.Int4 => 0x31,
|
||||
_ => throw new ProtocolEvidenceMissingException(
|
||||
$"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4."),
|
||||
};
|
||||
|
||||
private static readonly byte[] AnalogPadding16 = new byte[16];
|
||||
private static readonly byte[] AnalogPostNamePadding = new byte[16];
|
||||
|
||||
static HistorianTagWriteProtocol()
|
||||
{
|
||||
// 16 bytes of 0xFF observed between tag name and description.
|
||||
for (int i = 0; i < AnalogPostNamePadding.Length; i++)
|
||||
{
|
||||
AnalogPostNamePadding[i] = 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
// After MDAS, the captured layout is a 7-byte flag block followed by uint32
|
||||
// storage rate. The flag block's second byte is the StorageType (1 = Cyclic,
|
||||
// 2 = Delta — captured 2026-05-04). When StorageType=Delta, an additional
|
||||
// 4 zero bytes are inserted between the storage rate and the FILETIME (likely
|
||||
// a placeholder for Delta-specific deadband / threshold config).
|
||||
private static readonly byte[] AnalogFlagBlockCyclic = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
|
||||
private static readonly byte[] AnalogFlagBlockDelta = [0x02, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01];
|
||||
private static readonly byte[] AnalogDeltaPostStorageRatePadding = new byte[4];
|
||||
/// <summary>Compact "use defaults" scaling marker — emitted when MinEU/MaxEU/MinRaw/MaxRaw are 0/100/0/100.</summary>
|
||||
private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
|
||||
/// <summary>Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.</summary>
|
||||
private static readonly byte[] AnalogScalingExplicitMarker = [0x1F, 0x00];
|
||||
// 2-byte trailer: `FE` marker + ApplyScaling byte (0x00 = false, 0x01 = true). Verified
|
||||
// against native captures by toggling ApplyScaling on the harness and confirming that
|
||||
// the server persists distinct MinRaw/MaxRaw + sets AnalogTag.Scaling=1 only when the
|
||||
// second byte is 0x01. The WCF binary encoder may split InBuff across two
|
||||
// Bytes8Text chunks (e.g., `9E B7 ... 9F 01 00`) which can make the trailer look
|
||||
// 1-byte from the wire, but the semantic CTagMetadata content is always 2 bytes.
|
||||
private static readonly byte[] AnalogTrailerScalingDisabled = [0xFE, 0x00];
|
||||
private static readonly byte[] AnalogTrailerScalingEnabled = [0xFE, 0x01];
|
||||
|
||||
private const double DefaultMinEU = 0.0;
|
||||
private const double DefaultMaxEU = 100.0;
|
||||
private const double DefaultMinRaw = 0.0;
|
||||
private const double DefaultMaxRaw = 100.0;
|
||||
|
||||
private const string MetadataProvider = "MDAS";
|
||||
private const uint IntegralDivisorMagic = 0x2710u;
|
||||
private const uint DefaultStorageRateMs = 1000u;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float,
|
||||
/// Double, Int2, Int4, UInt4 — see <see cref="GetAnalogDataTypeCode"/> for the
|
||||
/// type-code mapping. Output matches the byte-for-byte capture for the same inputs.
|
||||
/// When MinEU/MaxEU/MinRaw/MaxRaw are all defaults (0/100/0/100) emits the compact
|
||||
/// `1A 03` scaling marker; otherwise emits `1F` + 4 doubles in order.
|
||||
/// </summary>
|
||||
/// <param name="tagName">Tag name (ASCII).</param>
|
||||
/// <param name="description">Tag description (ASCII; null/empty allowed).</param>
|
||||
/// <param name="engineeringUnit">EU label (ASCII; null/empty allowed).</param>
|
||||
/// <param name="dataType">Native data type — Float by default for backward compat.</param>
|
||||
/// <param name="dateCreatedUtc">DateCreated FILETIME (caller passes <see cref="DateTime.UtcNow"/>).</param>
|
||||
/// <param name="minEU">Engineering-units lower bound.</param>
|
||||
/// <param name="maxEU">Engineering-units upper bound.</param>
|
||||
/// <param name="minRaw">Raw lower bound.</param>
|
||||
/// <param name="maxRaw">Raw upper bound.</param>
|
||||
/// <param name="storageRateMs">StorageRate in milliseconds.</param>
|
||||
public static byte[] SerializeAnalogCTagMetadata(
|
||||
string tagName,
|
||||
string? description,
|
||||
string? engineeringUnit,
|
||||
DateTime dateCreatedUtc,
|
||||
Models.HistorianDataType dataType = Models.HistorianDataType.Float,
|
||||
double minEU = DefaultMinEU,
|
||||
double maxEU = DefaultMaxEU,
|
||||
double minRaw = DefaultMinRaw,
|
||||
double maxRaw = DefaultMaxRaw,
|
||||
uint storageRateMs = DefaultStorageRateMs,
|
||||
bool applyScaling = false,
|
||||
Models.HistorianStorageType storageType = Models.HistorianStorageType.Cyclic,
|
||||
double integralDivisor = 1.0)
|
||||
{
|
||||
if (storageRateMs == 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(storageRateMs), "Storage rate must be > 0 ms.");
|
||||
}
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
byte typeCode = GetAnalogDataTypeCode(dataType);
|
||||
bool isDelta = storageType == Models.HistorianStorageType.Delta;
|
||||
|
||||
using MemoryStream ms = new();
|
||||
using BinaryWriter w = new(ms);
|
||||
|
||||
w.Write(isDelta ? AnalogHeaderUpToTypeCodeDelta : AnalogHeaderUpToTypeCodeCyclic); // 11 bytes
|
||||
w.Write(typeCode); // 1 byte data-type discriminator
|
||||
w.Write(AnalogPadding16); // 16 bytes (all zero — placeholder GUID + 2)
|
||||
WriteCompactAscii(w, tagName); // var
|
||||
w.Write(AnalogPostNamePadding); // 16 bytes of 0xFF
|
||||
WriteCompactAscii(w, description ?? string.Empty); // var
|
||||
WriteCompactAscii(w, MetadataProvider); // 7 bytes ("MDAS")
|
||||
w.Write(isDelta ? AnalogFlagBlockDelta : AnalogFlagBlockCyclic); // 7 bytes
|
||||
w.Write(storageRateMs); // uint32
|
||||
if (isDelta)
|
||||
{
|
||||
w.Write(AnalogDeltaPostStorageRatePadding); // 4 bytes (Delta-only)
|
||||
}
|
||||
w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64
|
||||
|
||||
if (minEU == DefaultMinEU && maxEU == DefaultMaxEU && minRaw == DefaultMinRaw && maxRaw == DefaultMaxRaw)
|
||||
{
|
||||
w.Write(AnalogScalingDefaultsMarker); // 2 bytes (1A 03)
|
||||
}
|
||||
else
|
||||
{
|
||||
w.Write(AnalogScalingExplicitMarker); // 2 bytes (1F 00)
|
||||
w.Write(minEU);
|
||||
w.Write(maxEU);
|
||||
w.Write(minRaw);
|
||||
w.Write(maxRaw); // 32 bytes total for the 4 doubles
|
||||
}
|
||||
|
||||
WriteCompactAscii(w, engineeringUnit ?? string.Empty); // var
|
||||
w.Write(IntegralDivisorMagic); // uint32 (purpose unclear — captured constant)
|
||||
w.Write(integralDivisor); // double IntegralDivisor (default 1.0)
|
||||
w.Write(applyScaling ? AnalogTrailerScalingEnabled : AnalogTrailerScalingDisabled);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the tagNames byte buffer for the DelT (DeleteTags) WCF op.
|
||||
/// Decoded layout from a captured DelT request:
|
||||
/// <code>
|
||||
/// ushort header1 = 0x6751
|
||||
/// ushort header2 = 1
|
||||
/// uint32 tagCount
|
||||
/// for each tag: uint32 charCount + charCount × UTF-16 LE chars
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public static byte[] SerializeDeleteTagNames(IReadOnlyList<string> tagNames)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagNames);
|
||||
if (tagNames.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("DeleteTags requires at least one tag name.", nameof(tagNames));
|
||||
}
|
||||
|
||||
using MemoryStream ms = new();
|
||||
using BinaryWriter w = new(ms);
|
||||
w.Write((ushort)0x6751);
|
||||
w.Write((ushort)1);
|
||||
w.Write(checked((uint)tagNames.Count));
|
||||
foreach (string name in tagNames)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(tagNames));
|
||||
w.Write(checked((uint)name.Length));
|
||||
w.Write(Encoding.Unicode.GetBytes(name));
|
||||
}
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Compact ASCII string: <c>0x09 + UInt16 byteLen + LEN ASCII bytes</c>.</summary>
|
||||
private static void WriteCompactAscii(BinaryWriter writer, string value)
|
||||
{
|
||||
byte[] ascii = Encoding.ASCII.GetBytes(value);
|
||||
if (ascii.Length > ushort.MaxValue)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(value), "Compact ASCII strings cannot exceed UInt16 length.");
|
||||
}
|
||||
writer.Write(CompactAsciiMarker);
|
||||
writer.Write((ushort)ascii.Length);
|
||||
writer.Write(ascii);
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
internal static class HistorianWcfAuthChainHelper
|
||||
{
|
||||
private const int OpenConnection3MinResponseLength = 5;
|
||||
public const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
|
||||
public const uint NativeIntegratedEventConnectionMode = 0x501;
|
||||
/// <summary>
|
||||
/// Process + write-enabled + integrated security. Per native ilspy
|
||||
/// (HistorianAccessUtil.SetConnectionMode): Process=1, OR 0x400 for integratedSecurity.
|
||||
/// EnsT2 and DelT silently return false with err code 132 (OperationNotEnabled) when
|
||||
/// Open2 is opened with 0x402 (read-only); 0x401 unlocks write capability.
|
||||
/// </summary>
|
||||
public const uint NativeIntegratedWriteEnabledConnectionMode = 0x401;
|
||||
|
||||
/// <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);
|
||||
HistorianWcfClientCredentialsHelper.Configure(historyFactory, options);
|
||||
historyFactory.Endpoint.EndpointBehaviors.Add(new HistorianWcfHistAddressingBehavior());
|
||||
if (HistorianWcfMessageCaptureBehavior.IsEnabled)
|
||||
{
|
||||
historyFactory.Endpoint.EndpointBehaviors.Add(new HistorianWcfMessageCaptureBehavior());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IHistoryServiceContract2 historyChannel = historyFactory.CreateChannel();
|
||||
ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel;
|
||||
try
|
||||
{
|
||||
historyChannel.GetInterfaceVersion(out _);
|
||||
RunValClRounds(historyChannel, contextKey, options, cancellationToken);
|
||||
|
||||
byte[] open2Request = HistorianNativeHandshake.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, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
||||
|
||||
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)
|
||||
{
|
||||
HistorianNativeHandshake.RunTokenRounds(
|
||||
(handle, wrapped, _) =>
|
||||
{
|
||||
bool serverSuccess = channel.ValidateClientCredential(handle, wrapped, out byte[] serverOutput, out byte[] errorBuffer);
|
||||
return new HistorianNativeHandshake.TokenExchangeResult(serverSuccess, serverOutput ?? [], errorBuffer ?? []);
|
||||
},
|
||||
contextKey,
|
||||
options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
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 { } }
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using System.Buffers.Binary;
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
using System.Net.Security;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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
|
||||
};
|
||||
}
|
||||
|
||||
// NetNamedPipeBinding is Windows-only at the BCL level; calling this on Linux
|
||||
// throws PlatformNotSupportedException at runtime. Cross-platform callers should
|
||||
// choose Transport = RemoteTcpCertificate (or RemoteTcpIntegrated on Windows).
|
||||
#pragma warning disable CA1416 // Documented Windows-only entry point
|
||||
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
|
||||
};
|
||||
}
|
||||
#pragma warning restore CA1416
|
||||
|
||||
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, options.ServerDnsIdentity),
|
||||
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, string? dnsIdentity = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
|
||||
|
||||
Uri uri = new($"{Scheme}://{host}:{port}/{serviceName}");
|
||||
return string.IsNullOrWhiteSpace(dnsIdentity)
|
||||
? new EndpointAddress(uri)
|
||||
: new EndpointAddress(uri, new DnsEndpointIdentity(dnsIdentity));
|
||||
}
|
||||
|
||||
public static EndpointAddress CreatePipeEndpointAddress(string host, string serviceName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(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>
|
||||
// NetNamedPipeBinding / WindowsStreamSecurityBindingElement are Windows-only at the
|
||||
// BCL level; calling this on Linux throws PlatformNotSupportedException at runtime.
|
||||
// Cross-platform callers should choose Transport = RemoteTcpCertificate.
|
||||
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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using System.IdentityModel.Selectors;
|
||||
using System.IdentityModel.Tokens;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Security;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
/// <remarks>
|
||||
/// Centralizes per-channel-factory credentials configuration that's not bound to a
|
||||
/// single binding type. Today this covers <c>ServerCertificateValidation</c> for the
|
||||
/// cert-transport binding when callers opt into <see cref="HistorianClientOptions.AllowUntrustedServerCertificate"/>.
|
||||
/// Apply at every ChannelFactory<T> instantiation point in the WCF layer.
|
||||
/// </remarks>
|
||||
internal static class HistorianWcfClientCredentialsHelper
|
||||
{
|
||||
public static void Configure<TChannel>(ChannelFactory<TChannel> factory, HistorianClientOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (options.AllowUntrustedServerCertificate)
|
||||
{
|
||||
factory.Credentials.ServiceCertificate.SslCertificateAuthentication = new X509ServiceCertificateAuthentication
|
||||
{
|
||||
CertificateValidationMode = X509CertificateValidationMode.Custom,
|
||||
CustomCertificateValidator = AcceptAnyCertificateValidator.Instance,
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AcceptAnyCertificateValidator : X509CertificateValidator
|
||||
{
|
||||
public static readonly AcceptAnyCertificateValidator Instance = new();
|
||||
public override void Validate(X509Certificate2 certificate) { }
|
||||
}
|
||||
}
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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>
|
||||
internal sealed class HistorianWcfEventOrchestrator
|
||||
{
|
||||
private const int OpenConnection3MinResponseLength = 5;
|
||||
private const int CredentialBlockSizeBytes = 1026;
|
||||
private const int MaxValClRounds = 8;
|
||||
private const string ClientNodeNameFallback = "ZB.MOM.WW.SPHistorianClient";
|
||||
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);
|
||||
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
|
||||
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
|
||||
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||
_options, histBinding, histEndpoint, contextKey, cancellationToken,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
|
||||
additionalSetup: (historyChannel, context) =>
|
||||
AddCmEventTagViaAddT(historyChannel, context, auxBinding, 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);
|
||||
HistorianWcfClientCredentialsHelper.Configure(factory, _options);
|
||||
|
||||
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 { } }
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using System.ServiceModel.Description;
|
||||
using System.ServiceModel.Dispatcher;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
/// <remarks>
|
||||
/// Forces an explicit <c>wsa:To</c> URI on every outgoing message. Native captures
|
||||
/// of EnsT2 / DelT include <c>net.pipe://localhost/Hist</c> in the addressing header
|
||||
/// block; without it the server appears to accept the body but not act on it
|
||||
/// (silent fail observed for both write ops). WCF normally derives To from the
|
||||
/// endpoint address, but the captured SDK bytes show it absent — re-asserting it
|
||||
/// here closes the gap.
|
||||
/// </remarks>
|
||||
internal sealed class HistorianWcfHistAddressingBehavior : IEndpointBehavior
|
||||
{
|
||||
public void Validate(ServiceEndpoint endpoint) { }
|
||||
public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { }
|
||||
public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher) { }
|
||||
|
||||
public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
|
||||
{
|
||||
clientRuntime.ClientMessageInspectors.Add(new ToHeaderInspector(endpoint.Address.Uri));
|
||||
}
|
||||
|
||||
private sealed class ToHeaderInspector(Uri toUri) : IClientMessageInspector
|
||||
{
|
||||
public object? BeforeSendRequest(ref Message request, IClientChannel channel)
|
||||
{
|
||||
request.Headers.To = toUri;
|
||||
return null;
|
||||
}
|
||||
|
||||
public void AfterReceiveReply(ref Message reply, object? correlationState) { }
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using System.ServiceModel.Description;
|
||||
using System.ServiceModel.Dispatcher;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
/// <remarks>
|
||||
/// Reverse-engineering aid: when the env var <c>AVEVA_HISTORIAN_SDK_WIRE_CAPTURE</c> is set,
|
||||
/// every outgoing WCF message body and every incoming response body on this endpoint is
|
||||
/// captured to that file as one ndjson record per call. Pair with the
|
||||
/// <c>instrument-wcf-{write,read}message</c> native captures and diff offset-by-offset to
|
||||
/// isolate SDK-vs-native differences. NEVER enable in production.
|
||||
/// </remarks>
|
||||
internal sealed class HistorianWcfMessageCaptureBehavior : IEndpointBehavior
|
||||
{
|
||||
public const string CapturePathEnvVar = "AVEVA_HISTORIAN_SDK_WIRE_CAPTURE";
|
||||
|
||||
public static bool IsEnabled => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(CapturePathEnvVar));
|
||||
|
||||
public void Validate(ServiceEndpoint endpoint) { }
|
||||
public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { }
|
||||
public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher) { }
|
||||
|
||||
public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
|
||||
{
|
||||
clientRuntime.ClientMessageInspectors.Add(new MessageCaptureInspector());
|
||||
}
|
||||
|
||||
private sealed class MessageCaptureInspector : IClientMessageInspector
|
||||
{
|
||||
private static readonly object Lock = new();
|
||||
|
||||
public object? BeforeSendRequest(ref Message request, IClientChannel channel)
|
||||
{
|
||||
CaptureMessage("SDK.WriteMessage.Body", ref request);
|
||||
return null;
|
||||
}
|
||||
|
||||
public void AfterReceiveReply(ref Message reply, object? correlationState)
|
||||
{
|
||||
CaptureMessage("SDK.ReadMessage.Body", ref reply);
|
||||
}
|
||||
|
||||
private static void CaptureMessage(string phase, ref Message message)
|
||||
{
|
||||
string? path = Environment.GetEnvironmentVariable(CapturePathEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(path) || message.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Buffer the message so we can both inspect and forward the bytes.
|
||||
MessageBuffer buffer = message.CreateBufferedCopy(int.MaxValue);
|
||||
Message copy = buffer.CreateMessage();
|
||||
using MemoryStream ms = new();
|
||||
BinaryMessageEncodingBindingElement binaryEncoder = new();
|
||||
MessageEncoderFactory factory = binaryEncoder.CreateMessageEncoderFactory();
|
||||
factory.Encoder.WriteMessage(copy, ms);
|
||||
byte[] bytes = ms.ToArray();
|
||||
message = buffer.CreateMessage();
|
||||
|
||||
string action = message.Headers.Action ?? "<no-action>";
|
||||
var record = new
|
||||
{
|
||||
TimestampUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||
Phase = phase,
|
||||
Action = action,
|
||||
Length = bytes.Length,
|
||||
Base64 = Convert.ToBase64String(bytes),
|
||||
};
|
||||
|
||||
string? dir = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
File.AppendAllText(path, JsonSerializer.Serialize(record) + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Capture is reverse-engineering aid — never let it break the live call.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.ServiceModel;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
internal sealed class HistorianWcfReadOrchestrator
|
||||
{
|
||||
private const ushort StartQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData;
|
||||
private const int CredentialBlockSizeBytes = 1026;
|
||||
private const int OpenConnection3MinResponseLength = 5;
|
||||
private const string ClientNodeNameFallback = "ZB.MOM.WW.SPHistorianClient";
|
||||
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);
|
||||
HistorianWcfClientCredentialsHelper.Configure(retrievalFactory, _options);
|
||||
|
||||
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);
|
||||
HistorianWcfClientCredentialsHelper.Configure(retrievalFactory, _options);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
internal 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);
|
||||
}
|
||||
|
||||
internal 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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// QueryType wire value matches the native <c>ArchestrA.HistorianRetrievalMode</c> enum
|
||||
/// ordinal exactly — verified 2026-05-04 by probing every mode through the
|
||||
/// <c>instrument-wcf-writemessage</c> capture pipeline and reading the QueryType uint32
|
||||
/// at offset 2 of <c>pRequestBuff</c>:
|
||||
/// <code>
|
||||
/// Cyclic=0 Delta=1 Full=2 Interpolated=3 BestFit=4 TimeWeightedAverage=5
|
||||
/// MinimumWithTime=6 MaximumWithTime=7 Integral=8 Slope=9 Counter=10
|
||||
/// ValueState=11 RoundTrip=12 StartBound=13 EndBound=14
|
||||
/// </code>
|
||||
/// The public <see cref="Models.RetrievalMode"/> enum mirrors the native order, so the
|
||||
/// mapping reduces to <c>(uint)mode</c>. Prior version mapped <c>Cyclic</c> to 4
|
||||
/// (BestFit's value) and threw for everything outside the four common modes.
|
||||
/// </summary>
|
||||
internal static uint MapRetrievalModeToQueryType(Models.RetrievalMode mode)
|
||||
{
|
||||
if (!Enum.IsDefined(mode))
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException($"Retrieval mode {mode} is not a defined RetrievalMode value.");
|
||||
}
|
||||
return (uint)mode;
|
||||
}
|
||||
|
||||
internal 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
/// <remarks>
|
||||
/// Drives the AddNonStreamValuesBegin / AddNonStreamValues / AddNonStreamValuesEnd
|
||||
/// WCF op group on the <c>/Trx</c> service end-to-end. The native AVEVA wrapper's
|
||||
/// equivalent surface (<c>HistorianAccess.AddRevisionValues*</c>) is gated by the
|
||||
/// C++ <c>HistorianClient</c>'s per-connection cache and rejects all writes from a
|
||||
/// managed client with err 129 <c>TagNotFoundInCache</c>. This SDK orchestrator
|
||||
/// bypasses the wrapper entirely — talks WCF directly — to test whether the SERVER
|
||||
/// gates on the same condition.
|
||||
///
|
||||
/// Live behavior is unverified. The first iteration is probe-only: open the auth
|
||||
/// chain, drive the standard write priming, call AddNonStreamValuesBegin and
|
||||
/// surface whatever the server returns.
|
||||
/// </remarks>
|
||||
internal sealed class HistorianWcfRevisionOrchestrator
|
||||
{
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianWcfRevisionOrchestrator(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public Task<HistorianRevisionProbeResult> ProbeBeginAsync(CancellationToken cancellationToken)
|
||||
=> Task.Run(() => ProbeBegin(cancellationToken), cancellationToken);
|
||||
|
||||
private HistorianRevisionProbeResult ProbeBegin(CancellationToken cancellationToken)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
|
||||
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
|
||||
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
|
||||
|
||||
HistorianRevisionProbeResult result = new();
|
||||
|
||||
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||
_options, histBinding, histEndpoint, contextKey, cancellationToken,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
|
||||
additionalSetup: (historyChannel, context) =>
|
||||
{
|
||||
result.OpenSucceeded = true;
|
||||
result.ClientHandle = context.ClientHandle;
|
||||
result.StorageSessionId = context.StorageSessionId;
|
||||
|
||||
// Run the same priming chain that EnsT2/DelT use — without it, the Trx
|
||||
// service rejects calls with err 51 UnknownClient because the client
|
||||
// hasn't registered itself across the auxiliary services.
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
|
||||
EndpointAddress retrievalEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Retrieval);
|
||||
RunPrimingChain(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
|
||||
|
||||
// Hypothesis: calling RTag2 (RegisterTags2) cascades client identity into
|
||||
// the Trx service's session table. The event flow uses RTag2 with the
|
||||
// CM_EVENT tag id and subsequent ops succeed. Try RTag2 with that same
|
||||
// tag id here as a registration probe.
|
||||
try
|
||||
{
|
||||
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
|
||||
byte[] rtag2Buffer = BuildRTag2CmEventInputBuffer();
|
||||
bool rtag2Ok = historyChannel.RegisterTags2(
|
||||
handle: handle,
|
||||
elementCount: 1,
|
||||
inputBuffer: rtag2Buffer,
|
||||
outputBuffer: out byte[] rtag2Out,
|
||||
errorBuffer: out byte[] rtag2Err);
|
||||
result.RTag2Succeeded = rtag2Ok;
|
||||
result.RTag2OutHex = rtag2Out is null || rtag2Out.Length == 0 ? null : Convert.ToHexString(rtag2Out);
|
||||
result.RTag2ErrorHex = rtag2Err is null || rtag2Err.Length == 0 ? null : Convert.ToHexString(rtag2Err);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.RTag2Exception = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
ChannelFactory<ITransactionServiceContract2> trxFactory = new(auxBinding, transactionEndpoint);
|
||||
HistorianWcfClientCredentialsHelper.Configure(trxFactory, _options);
|
||||
ITransactionServiceContract2 trxChannel = trxFactory.CreateChannel();
|
||||
ICommunicationObject trxCo = (ICommunicationObject)trxChannel;
|
||||
try
|
||||
{
|
||||
// Get interface version first to register the client in the Trx service's
|
||||
// session table (matches the cross-service GetV priming pattern used by
|
||||
// RunWritePriming for EnsT2/DelT).
|
||||
try
|
||||
{
|
||||
uint trxRc = trxChannel.GetInterfaceVersion(out uint trxVersion);
|
||||
result.TrxInterfaceVersionReturnCode = trxRc;
|
||||
result.TrxInterfaceVersion = trxVersion;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.TrxInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
// Probe V2 AddNonStreamValuesBegin2. Try BOTH possible handle formats —
|
||||
// the server returns 0433000000 (UnknownClient = 51) when the wrong one
|
||||
// is sent. Capture which one (if any) is recognized.
|
||||
foreach ((string label, string handle) in new[]
|
||||
{
|
||||
("contextKey", contextKey.ToString("D").ToUpperInvariant()),
|
||||
("storageSessionId", context.StorageSessionId.ToString("D").ToUpperInvariant()),
|
||||
("contextKey-lower", contextKey.ToString("D")),
|
||||
("clientHandle-as-string", context.ClientHandle.ToString()),
|
||||
})
|
||||
{
|
||||
try
|
||||
{
|
||||
string? transactionId = null;
|
||||
byte[]? errorBuffer = null;
|
||||
bool ok = trxChannel.AddNonStreamValuesBegin2(handle, out transactionId, out errorBuffer);
|
||||
result.BeginAttempts.Add(new HistorianRevisionBeginAttempt
|
||||
{
|
||||
HandleLabel = label,
|
||||
HandleSent = handle,
|
||||
Succeeded = ok,
|
||||
TransactionId = transactionId,
|
||||
ErrorHex = errorBuffer is null || errorBuffer.Length == 0 ? null : Convert.ToHexString(errorBuffer),
|
||||
});
|
||||
if (ok && !string.IsNullOrEmpty(transactionId))
|
||||
{
|
||||
result.BeginSucceeded = true;
|
||||
result.BeginTransactionId = transactionId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.BeginAttempts.Add(new HistorianRevisionBeginAttempt
|
||||
{
|
||||
HandleLabel = label,
|
||||
HandleSent = handle,
|
||||
Exception = $"{ex.GetType().Name}: {ex.Message}",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (trxCo.State == CommunicationState.Faulted) trxCo.Abort(); else trxCo.Close(); } catch { try { trxCo.Abort(); } catch { } }
|
||||
try { if (trxFactory.State == CommunicationState.Faulted) trxFactory.Abort(); else trxFactory.Close(); } catch { try { trxFactory.Abort(); } catch { } }
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors HistorianWcfTagWriteOrchestrator.RunWritePriming. The cross-service GetV
|
||||
/// calls + UpdC3 register the client in each aux service's session table so that
|
||||
/// subsequent ops (like AddNonStreamValuesBegin2 on /Trx) recognize the handle.
|
||||
/// </summary>
|
||||
private static void RunPrimingChain(
|
||||
IHistoryServiceContract2 historyChannel,
|
||||
HistorianWcfAuthChainHelper.OpenConnectionContext context,
|
||||
Binding auxBinding,
|
||||
EndpointAddress statusEndpoint,
|
||||
EndpointAddress transactionEndpoint,
|
||||
EndpointAddress retrievalEndpoint)
|
||||
{
|
||||
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
|
||||
|
||||
ChannelFactory<IStatusServiceContract2> statusFactory = new(auxBinding, statusEndpoint);
|
||||
IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
|
||||
ChannelFactory<ITransactionServiceContract> transactionFactory = new(auxBinding, transactionEndpoint);
|
||||
ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
|
||||
ChannelFactory<IRetrievalServiceContract4> retrievalFactory = new(auxBinding, retrievalEndpoint);
|
||||
IRetrievalServiceContract4 retrievalChannel = retrievalFactory.CreateChannel();
|
||||
|
||||
try
|
||||
{
|
||||
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();
|
||||
TryRun(() => historyChannel.UpdateClientStatus3(handle, (uint)clientStatus.Length, ref clientStatus, out _, out _, out _, out _));
|
||||
|
||||
foreach (string parameterName in new[] { "AllowOriginals", "HistorianPartner", "HistorianVersion", "MaxCyclicStorageTimeout", "RealTimeWindow", "FutureTimeThreshold", "AllowRenameTags" })
|
||||
{
|
||||
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _));
|
||||
}
|
||||
TryRun(() => transactionChannel.GetInterfaceVersion(out _));
|
||||
TryRun(() => statusChannel.GetInterfaceVersion(out _));
|
||||
TryRun(() => retrievalChannel.GetInterfaceVersion(out _));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseSafely(retrievalChannel, retrievalFactory);
|
||||
CloseSafely(transactionChannel, transactionFactory);
|
||||
CloseSafely(statusChannel, statusFactory);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Same 24-byte RTag2 buffer the event flow uses (CM_EVENT tag id).</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);
|
||||
// CM_EVENT tag id — duplicated here to avoid a cross-class dependency on the
|
||||
// event orchestrator. Verify against HistorianWcfEventOrchestrator.CmEventTagId
|
||||
// if the value ever needs updating.
|
||||
new Guid("353b8145-5df0-4d46-a253-871aef49b321").ToByteArray().CopyTo(buffer.AsSpan(8, 16));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] BuildUpdC3ClientStatusBlob()
|
||||
{
|
||||
byte[] blob = new byte[81];
|
||||
blob[0] = 0x02;
|
||||
blob[1] = 0x01;
|
||||
blob[77] = 0x1E;
|
||||
return blob;
|
||||
}
|
||||
|
||||
private static byte[] BuildGetHistorianInfoRequest(string parameterName)
|
||||
{
|
||||
byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName);
|
||||
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;
|
||||
}
|
||||
|
||||
private static void TryRun(Action a) { try { a(); } catch { } }
|
||||
|
||||
private static void CloseSafely(object channel, ICommunicationObject factory)
|
||||
{
|
||||
try { if (channel is ICommunicationObject co) { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } } catch { }
|
||||
try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HistorianRevisionProbeResult
|
||||
{
|
||||
public bool OpenSucceeded { get; set; }
|
||||
public uint ClientHandle { get; set; }
|
||||
public Guid StorageSessionId { get; set; }
|
||||
public uint? TrxInterfaceVersionReturnCode { get; set; }
|
||||
public uint? TrxInterfaceVersion { get; set; }
|
||||
public string? TrxInterfaceVersionException { get; set; }
|
||||
public string? BeginTransactionId { get; set; }
|
||||
public bool BeginSucceeded { get; set; }
|
||||
public string? BeginErrorHex { get; set; }
|
||||
public string? BeginException { get; set; }
|
||||
public List<HistorianRevisionBeginAttempt> BeginAttempts { get; } = new();
|
||||
public bool RTag2Succeeded { get; set; }
|
||||
public string? RTag2OutHex { get; set; }
|
||||
public string? RTag2ErrorHex { get; set; }
|
||||
public string? RTag2Exception { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class HistorianRevisionBeginAttempt
|
||||
{
|
||||
public string HandleLabel { get; set; } = "";
|
||||
public string HandleSent { get; set; } = "";
|
||||
public bool Succeeded { get; set; }
|
||||
public string? TransactionId { get; set; }
|
||||
public string? ErrorHex { get; set; }
|
||||
public string? Exception { get; set; }
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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";
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
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);
|
||||
Binding statusBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(options);
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(options, HistorianWcfServiceNames.Status);
|
||||
|
||||
string? value = null;
|
||||
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||
options, histBinding, histEndpoint, contextKey, CancellationToken.None,
|
||||
additionalSetup: (_, context) => value = QuerySystemParameter(statusBinding, 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);
|
||||
}
|
||||
}
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
using System.Net;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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(
|
||||
Name: parsed.TagName,
|
||||
Key: parsed.TagKey,
|
||||
DataType: MapDataType(parsed.NativeDataTypeDescriptor),
|
||||
Description: parsed.Description,
|
||||
EngineeringUnit: parsed.EngineeringUnit,
|
||||
MinRaw: parsed.MinEU,
|
||||
MaxRaw: parsed.MaxEU);
|
||||
}
|
||||
|
||||
/// <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 and returns the raw response bytes alongside the parsed record (for byte-layout reverse engineering).</summary>
|
||||
internal static IReadOnlyDictionary<string, byte[]?> GetTagInfoRawBytesForProbe(
|
||||
HistorianClientOptions options,
|
||||
IEnumerable<string> tags)
|
||||
{
|
||||
Dictionary<string, byte[]?> results = new(StringComparer.Ordinal);
|
||||
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
|
||||
foreach (string tag in tags)
|
||||
{
|
||||
try
|
||||
{
|
||||
uint rc = session.RetrievalChannel.GetTagInfoFromName(session.Handle, tag, out _, out byte[] bytes);
|
||||
results[tag] = (rc == 0 && bytes.Length > 0) ? bytes : null;
|
||||
}
|
||||
catch { results[tag] = null; }
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
// byte 1 origin marker: 0xCF = system / built-in tag, 0xC3 = MDAS-routed
|
||||
// (e.g. OPC UA imported), 0xC7 = SDK-created via EnsT2 (live-verified by the
|
||||
// EnsureTagAsync round-trip test).
|
||||
if (nativeDataTypeDescriptor is not [0x03, 0xCF or 0xC3 or 0xC7, _, _])
|
||||
{
|
||||
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);
|
||||
|
||||
// The browse/metadata code uses the legacy Open2-V1 buffer, which carries
|
||||
// its own auth blob. That buffer is only valid against the WCF transport that
|
||||
// negotiates Windows security at the channel level (`/Hist-Integrated`) or
|
||||
// against the cert binding (which trusts the channel-level cert identity).
|
||||
// For LocalPipe and RemoteTcpIntegrated the original behaviour stays —
|
||||
// hit the Integrated endpoint with the Windows transport binding. Only
|
||||
// RemoteTcpCertificate gets the cert binding here, so browse/metadata
|
||||
// works from a Linux client over the cert transport.
|
||||
(Binding historyBinding, EndpointAddress historyEndpoint) = options.Transport switch
|
||||
{
|
||||
HistorianTransport.RemoteTcpCertificate => (
|
||||
HistorianWcfBindingFactory.CreateMdasNetTcpCertificateBinding(options.RequestTimeout),
|
||||
HistorianWcfBindingFactory.CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.HistoryCertificate, options.ServerDnsIdentity)),
|
||||
_ => (
|
||||
HistorianWcfBindingFactory.CreateMdasNetTcpWindowsBinding(options.RequestTimeout),
|
||||
HistorianWcfBindingFactory.CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.HistoryIntegrated)),
|
||||
};
|
||||
|
||||
ChannelFactory<IHistoryServiceContract2>? historyFactory = null;
|
||||
IHistoryServiceContract2? historyChannel = null;
|
||||
ChannelFactory<IRetrievalServiceContract2>? retrievalFactory = null;
|
||||
IRetrievalServiceContract2? retrievalChannel = null;
|
||||
try
|
||||
{
|
||||
historyFactory = new ChannelFactory<IHistoryServiceContract2>(historyBinding, historyEndpoint);
|
||||
HistorianWcfClientCredentialsHelper.Configure(historyFactory, options);
|
||||
if (options.Transport != HistorianTransport.RemoteTcpCertificate)
|
||||
{
|
||||
// Windows transport-security only applies to the integrated-auth binding.
|
||||
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));
|
||||
HistorianWcfClientCredentialsHelper.Configure(retrievalFactory, options);
|
||||
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)
|
||||
{
|
||||
// Three valid auth shapes:
|
||||
// 1. IntegratedSecurity=true (current Windows identity, no UserName/Password)
|
||||
// 2. IntegratedSecurity=false + UserName + Password (NTLM/Kerberos with explicit creds)
|
||||
// 3. IntegratedSecurity=true + UserName + Password (impersonation/explicit override)
|
||||
// The fourth combination — IntegratedSecurity=false with no UserName/Password — has
|
||||
// no way to authenticate against the /Hist-Integrated endpoint and is rejected.
|
||||
if (!options.IntegratedSecurity
|
||||
&& string.IsNullOrEmpty(options.UserName)
|
||||
&& string.IsNullOrEmpty(options.Password))
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"Tag browse / metadata requires either IntegratedSecurity=true OR an explicit UserName + Password.");
|
||||
}
|
||||
}
|
||||
|
||||
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) ?? "ZB.MOM.WW.SPHistorianClient";
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
/// <remarks>
|
||||
/// Drives the EnsT2 (EnsureTags2) and DelT (DeleteTags) WCF operations end-to-end.
|
||||
/// Mirrors <see cref="HistorianWcfReadOrchestrator"/> for the reads flow — opens an
|
||||
/// authenticated session, runs the documented priming chain (UpdC3 + 7×
|
||||
/// Stat.GetSystemParameter + Trx/Stat/Retr GetV) and then issues the write op.
|
||||
///
|
||||
/// AddS2 is intentionally NOT here — it is blocked architecturally per
|
||||
/// <c>docs/plans/write-commands-reverse-engineering.md</c> Phase 2 findings.
|
||||
/// </remarks>
|
||||
internal sealed class HistorianWcfTagWriteOrchestrator
|
||||
{
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianWcfTagWriteOrchestrator(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(definition.TagName, nameof(definition));
|
||||
// GetAnalogDataTypeCode throws ProtocolEvidenceMissingException for unsupported
|
||||
// types (String, Int1/Int8/UInt8, Guid, Event, Structure) — surface that early.
|
||||
_ = HistorianTagWriteProtocol.GetAnalogDataTypeCode(definition.DataType);
|
||||
return Task.Run(() => EnsureTag(definition), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
return Task.Run(() => DeleteTag(tagName), cancellationToken);
|
||||
}
|
||||
|
||||
private bool EnsureTag(HistorianTagDefinition definition)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
|
||||
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
|
||||
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
|
||||
EndpointAddress retrievalEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval);
|
||||
if (_options.Transport != HistorianTransport.LocalPipe)
|
||||
{
|
||||
retrievalEndpoint = HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
|
||||
}
|
||||
|
||||
bool result = false;
|
||||
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
|
||||
additionalSetup: (historyChannel, context) => result = SendEnsureTags2(
|
||||
historyChannel, context, definition, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint));
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool DeleteTag(string tagName)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
|
||||
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
|
||||
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
|
||||
EndpointAddress retrievalEndpoint = _options.Transport == HistorianTransport.LocalPipe
|
||||
? HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval)
|
||||
: HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
|
||||
|
||||
bool result = false;
|
||||
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
|
||||
additionalSetup: (historyChannel, context) =>
|
||||
{
|
||||
RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
|
||||
result = SendDeleteTags(historyChannel, context, tagName);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool SendEnsureTags2(
|
||||
IHistoryServiceContract2 historyChannel,
|
||||
HistorianWcfAuthChainHelper.OpenConnectionContext context,
|
||||
HistorianTagDefinition definition,
|
||||
Binding auxBinding,
|
||||
EndpointAddress statusEndpoint,
|
||||
EndpointAddress transactionEndpoint,
|
||||
EndpointAddress retrievalEndpoint)
|
||||
{
|
||||
RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
|
||||
|
||||
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
|
||||
byte[] payload = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: definition.TagName,
|
||||
description: definition.Description,
|
||||
engineeringUnit: definition.EngineeringUnit,
|
||||
dateCreatedUtc: DateTime.UtcNow,
|
||||
dataType: definition.DataType,
|
||||
minEU: definition.MinEU,
|
||||
maxEU: definition.MaxEU,
|
||||
minRaw: definition.MinRaw,
|
||||
maxRaw: definition.MaxRaw,
|
||||
storageRateMs: definition.StorageRateMs,
|
||||
applyScaling: definition.ApplyScaling,
|
||||
storageType: definition.StorageType,
|
||||
integralDivisor: definition.IntegralDivisor);
|
||||
|
||||
bool ok = historyChannel.EnsureTags2(
|
||||
handle: handle,
|
||||
elementCount: 1,
|
||||
inputBuffer: payload,
|
||||
outputBuffer: out byte[] outBuf,
|
||||
errorBuffer: out byte[] errBuf);
|
||||
WriteDiag("EnsT2", $"Returned={ok} OutLen={outBuf?.Length ?? -1} OutHex={(outBuf is null ? "<null>" : Convert.ToHexString(outBuf))} ErrLen={errBuf?.Length ?? -1} ErrHex={(errBuf is null ? "<null>" : Convert.ToHexString(errBuf))}");
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the priming chain captured between Open2 and the actual write op (EnsT2 / DelT).
|
||||
/// Both paths share the same priming per the native flow capture:
|
||||
/// Stat.GetV ×2 → Stat.GETHI(HistorianVersion) ×2 → UpdC3 → 6 GetSystemParameter →
|
||||
/// GetSystemParameter("AllowRenameTags") → Trx.GetV → Stat.GetV → Retr.GetV.
|
||||
/// </summary>
|
||||
private static void RunWritePriming(
|
||||
IHistoryServiceContract2 historyChannel,
|
||||
HistorianWcfAuthChainHelper.OpenConnectionContext context,
|
||||
Binding auxBinding,
|
||||
EndpointAddress statusEndpoint,
|
||||
EndpointAddress transactionEndpoint,
|
||||
EndpointAddress retrievalEndpoint)
|
||||
{
|
||||
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
|
||||
|
||||
ChannelFactory<IStatusServiceContract2> statusFactory = new(auxBinding, statusEndpoint);
|
||||
IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
|
||||
ChannelFactory<ITransactionServiceContract> transactionFactory = new(auxBinding, transactionEndpoint);
|
||||
ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
|
||||
ChannelFactory<IRetrievalServiceContract4> retrievalFactory = new(auxBinding, retrievalEndpoint);
|
||||
IRetrievalServiceContract4 retrievalChannel = retrievalFactory.CreateChannel();
|
||||
|
||||
try
|
||||
{
|
||||
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();
|
||||
historyChannel.UpdateClientStatus3(handle, (uint)clientStatus.Length, ref clientStatus, out _, out _, out _, out _);
|
||||
|
||||
foreach (string parameterName in NativeStatusParametersBeforeAnalogEnsT2)
|
||||
{
|
||||
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _));
|
||||
}
|
||||
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, "AllowRenameTags", out _, out _, out _));
|
||||
TryRun(() => transactionChannel.GetInterfaceVersion(out _));
|
||||
TryRun(() => statusChannel.GetInterfaceVersion(out _));
|
||||
TryRun(() => retrievalChannel.GetInterfaceVersion(out _));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseSafely(retrievalChannel, retrievalFactory);
|
||||
CloseSafely(transactionChannel, transactionFactory);
|
||||
CloseSafely(statusChannel, statusFactory);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SendDeleteTags(
|
||||
IHistoryServiceContract2 historyChannel,
|
||||
HistorianWcfAuthChainHelper.OpenConnectionContext context,
|
||||
string tagName)
|
||||
{
|
||||
// DelT uses the uint clientHandle, NOT the GUID handle (decoded from wire capture).
|
||||
// Native DelT request encodes statusSize as MS-NBFS marker 0x81
|
||||
// (ZeroTextWithEndElement = value 0) and status as xsi:nil. Earlier notes called
|
||||
// 0x81 "OneText" — that was wrong; the WithEndElement-pair table is:
|
||||
// 0x80/0x81 ZeroText, 0x82/0x83 OneText, 0x84/0x85 FalseText,
|
||||
// 0x86/0x87 TrueText, 0x88/0x89 Int8Text.
|
||||
// Sending statusSize=1 (which WCF encodes as 0x83 OneTextWithEndElement) made the
|
||||
// server return DelTResult=false with err=04 84 00 00 00 (HistorianAccessError
|
||||
// type 4 / code 132). statusSize=0 matches the native parity request.
|
||||
byte[] tagNamesBytes = HistorianTagWriteProtocol.SerializeDeleteTagNames([tagName]);
|
||||
uint statusSize = 0;
|
||||
byte[] status = null!;
|
||||
|
||||
bool ok = historyChannel.DeleteTags(
|
||||
handle: context.ClientHandle,
|
||||
tagNamesSize: checked((uint)tagNamesBytes.Length),
|
||||
tagNames: tagNamesBytes,
|
||||
statusSize: ref statusSize,
|
||||
status: ref status,
|
||||
errorSize: out uint errorSize,
|
||||
errorBuffer: out byte[] errorBuffer);
|
||||
|
||||
WriteDiag("DelT", $"Returned={ok} ClientHandle={context.ClientHandle} StatusSize={statusSize} StatusLen={status?.Length ?? -1} StatusHex={(status is null ? "<null>" : Convert.ToHexString(status))} ErrorSize={errorSize} ErrorLen={errorBuffer?.Length ?? -1} ErrorHex={(errorBuffer is null ? "<null>" : Convert.ToHexString(errorBuffer))}");
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static void WriteDiag(string op, string line)
|
||||
{
|
||||
string? diagPath = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_DELT_DIAG");
|
||||
if (string.IsNullOrWhiteSpace(diagPath)) return;
|
||||
try { File.AppendAllText(diagPath, $"{DateTimeOffset.UtcNow:O} {op} {line}{Environment.NewLine}"); } catch { }
|
||||
}
|
||||
|
||||
private static readonly string[] NativeStatusParametersBeforeAnalogEnsT2 =
|
||||
[
|
||||
"AllowOriginals",
|
||||
"HistorianPartner",
|
||||
"HistorianVersion",
|
||||
"MaxCyclicStorageTimeout",
|
||||
"RealTimeWindow",
|
||||
"FutureTimeThreshold",
|
||||
];
|
||||
|
||||
private static void TryRun(Action a) { try { a(); } catch { } }
|
||||
|
||||
/// <summary>81-byte UpdC3 status blob captured from native (same as event flow).</summary>
|
||||
private static byte[] BuildUpdC3ClientStatusBlob()
|
||||
{
|
||||
byte[] blob = new byte[81];
|
||||
blob[0] = 0x02;
|
||||
blob[1] = 0x01;
|
||||
blob[77] = 0x1E;
|
||||
return blob;
|
||||
}
|
||||
|
||||
/// <summary>GETHI request bytes for a parameter-name query (decoded from native).</summary>
|
||||
private static byte[] BuildGetHistorianInfoRequest(string parameterName)
|
||||
{
|
||||
byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName);
|
||||
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;
|
||||
}
|
||||
|
||||
private static void CloseSafely(object channel, ICommunicationObject factory)
|
||||
{
|
||||
try { if (channel is ICommunicationObject co) { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } } catch { }
|
||||
try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.ServiceModel.Channels;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using System.ServiceModel.Channels;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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;
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using System.ServiceModel.Channels;
|
||||
using System.Xml;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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>();
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.SPHistorianClient.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class BinaryPrimitiveTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToFileTimeUtc_TreatsUnspecifiedAsUtc()
|
||||
{
|
||||
DateTime value = new(2020, 4, 5, 10, 7, 42, DateTimeKind.Unspecified);
|
||||
|
||||
long actual = HistorianBinaryPrimitives.ToFileTimeUtc(value);
|
||||
|
||||
Assert.Equal(DateTime.SpecifyKind(value, DateTimeKind.Utc).ToFileTimeUtc(), actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteUtf16NullTerminated_WritesUnicodeWithTerminator()
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
|
||||
HistorianBinaryPrimitives.WriteUtf16NullTerminated(stream, "UTC");
|
||||
|
||||
Assert.Equal(Encoding.Unicode.GetBytes("UTC\0"), stream.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteFileTimeUtc_WritesLittleEndianUInt64()
|
||||
{
|
||||
DateTime value = new(2020, 4, 5, 10, 7, 42, DateTimeKind.Utc);
|
||||
using MemoryStream stream = new();
|
||||
|
||||
HistorianBinaryPrimitives.WriteFileTimeUtc(stream, value);
|
||||
|
||||
Assert.Equal(BitConverter.GetBytes(value.ToFileTimeUtc()), stream.ToArray());
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class EnumCompatibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void RetrievalMode_ValuesMatchManagedWrapper()
|
||||
{
|
||||
Assert.Equal(0, (int)RetrievalMode.Cyclic);
|
||||
Assert.Equal(1, (int)RetrievalMode.Delta);
|
||||
Assert.Equal(2, (int)RetrievalMode.Full);
|
||||
Assert.Equal(3, (int)RetrievalMode.Interpolated);
|
||||
Assert.Equal(11, (int)RetrievalMode.ValueState);
|
||||
Assert.Equal(14, (int)RetrievalMode.EndBound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionKind_ValuesMatchManagedWrapper()
|
||||
{
|
||||
Assert.Equal(1, (int)HistorianConnectionKind.Process);
|
||||
Assert.Equal(2, (int)HistorianConnectionKind.Event);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterpolationType_ValuesMatchManagedWrapper()
|
||||
{
|
||||
Assert.Equal(0, (int)InterpolationType.StairStep);
|
||||
Assert.Equal(1, (int)InterpolationType.Linear);
|
||||
Assert.Equal(254, (int)InterpolationType.SystemDefault);
|
||||
Assert.Equal(255, (int)InterpolationType.None);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using System.Runtime.Versioning;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class EventChainDiagnosticTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public EventChainDiagnosticTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventOrchestrator_DiagnosticDump_AgainstLocalHistorian()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClientOptions options = new()
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
};
|
||||
|
||||
HistorianWcfEventOrchestrator orchestrator = new(options);
|
||||
DateTime endUtc = DateTime.UtcNow;
|
||||
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
|
||||
|
||||
int observed = 0;
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianEvent? firstEvent = null;
|
||||
await foreach (var evt in orchestrator.ReadEventsAsync(startUtc, endUtc, CancellationToken.None))
|
||||
{
|
||||
observed++;
|
||||
firstEvent ??= evt;
|
||||
}
|
||||
|
||||
_output.WriteLine($"Events observed: {observed}");
|
||||
if (firstEvent is not null)
|
||||
{
|
||||
_output.WriteLine($" EventTimeUtc: {firstEvent.EventTimeUtc:O}");
|
||||
_output.WriteLine($" ReceivedTimeUtc: {firstEvent.ReceivedTimeUtc:O}");
|
||||
_output.WriteLine($" Type: {firstEvent.Type}");
|
||||
_output.WriteLine($" Properties.Count:{firstEvent.Properties.Count}");
|
||||
_output.WriteLine($" Has alarm_id: {firstEvent.Id != Guid.Empty}");
|
||||
}
|
||||
_output.WriteLine($"LastEnsT2Handle: {HistorianWcfEventOrchestrator.LastEnsT2Handle}");
|
||||
_output.WriteLine($"LastEnsT2PayloadSha256: {HistorianWcfEventOrchestrator.LastEnsT2PayloadSha256}");
|
||||
_output.WriteLine($"LastUpdC3ReturnCode: {HistorianWcfEventOrchestrator.LastUpdC3ReturnCode}");
|
||||
_output.WriteLine($"LastRTag2ReturnCode: {HistorianWcfEventOrchestrator.LastRTag2ReturnCode}");
|
||||
_output.WriteLine($"LastAddReturnCode (EnsT2): {HistorianWcfEventOrchestrator.LastAddReturnCode}");
|
||||
_output.WriteLine($"LastAddOutputLength: {HistorianWcfEventOrchestrator.LastAddOutputLength}");
|
||||
_output.WriteLine($"LastResultBufferLength: {orchestrator.LastResultBufferLength}");
|
||||
_output.WriteLine($"LastErrorBufferDescription: {orchestrator.LastErrorBufferDescription}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class FrameTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FrameWriterAndReader_RoundTrip()
|
||||
{
|
||||
HistorianFrame frame = new((HistorianMessageType)42, 123u, new byte[] { 1, 2, 3, 4 });
|
||||
byte[] bytes = HistorianFrameWriter.ToArray(frame);
|
||||
|
||||
HistorianFrame actual = await HistorianFrameReader.ReadAsync(new MemoryStream(bytes), CancellationToken.None);
|
||||
|
||||
Assert.Equal(frame.MessageType, actual.MessageType);
|
||||
Assert.Equal(frame.CorrelationId, actual.CorrelationId);
|
||||
Assert.True(frame.Payload.Span.SequenceEqual(actual.Payload.Span));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FrameReader_RejectsInvalidLength()
|
||||
{
|
||||
byte[] bytes = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
await Assert.ThrowsAsync<FrameFormatException>(async () =>
|
||||
await HistorianFrameReader.ReadAsync(new MemoryStream(bytes), CancellationToken.None));
|
||||
}
|
||||
}
|
||||
+737
@@ -0,0 +1,737 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class HistorianClientIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProbeAsync_ReturnsTrueForConfiguredHistorian()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort)
|
||||
? parsedPort
|
||||
: HistorianClientOptions.DefaultPort;
|
||||
HistorianClient client = new(new HistorianClientOptions { Host = host, Port = port });
|
||||
|
||||
Assert.True(await client.ProbeAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseTagNamesAsync_ReturnsConfiguredTestTag()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
string? filter = Environment.GetEnvironmentVariable("HISTORIAN_TAG_FILTER") ?? testTag;
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort)
|
||||
? parsedPort
|
||||
: HistorianClientOptions.DefaultPort;
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
IntegratedSecurity = true,
|
||||
UserName = Environment.GetEnvironmentVariable("HISTORIAN_USER") ?? string.Empty,
|
||||
Password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD") ?? string.Empty
|
||||
});
|
||||
|
||||
List<string> tagNames = [];
|
||||
await foreach (string tagName in client.BrowseTagNamesAsync(filter, CancellationToken.None))
|
||||
{
|
||||
tagNames.Add(tagName);
|
||||
}
|
||||
|
||||
Assert.Contains(testTag, tagNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_AgainstLocalHistorian_ReturnsAtLeastOneRow()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// The managed read flow currently only supports the LocalPipe transport.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
DateTime endUtc = DateTime.UtcNow;
|
||||
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
|
||||
|
||||
List<ZB.MOM.WW.SPHistorianClient.Models.HistorianSample> samples = [];
|
||||
await foreach (ZB.MOM.WW.SPHistorianClient.Models.HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None))
|
||||
{
|
||||
samples.Add(sample);
|
||||
}
|
||||
|
||||
Assert.NotEmpty(samples);
|
||||
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAggregateAsync_AgainstLocalHistorian_ReturnsTimeWeightedAverageRows()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
DateTime endUtc = DateTime.UtcNow;
|
||||
DateTime startUtc = endUtc - TimeSpan.FromMinutes(10);
|
||||
|
||||
List<ZB.MOM.WW.SPHistorianClient.Models.HistorianAggregateSample> samples = [];
|
||||
await foreach (ZB.MOM.WW.SPHistorianClient.Models.HistorianAggregateSample sample in client.ReadAggregateAsync(
|
||||
testTag, startUtc, endUtc,
|
||||
ZB.MOM.WW.SPHistorianClient.Models.RetrievalMode.TimeWeightedAverage,
|
||||
TimeSpan.FromMinutes(1),
|
||||
CancellationToken.None))
|
||||
{
|
||||
samples.Add(sample);
|
||||
}
|
||||
|
||||
Assert.NotEmpty(samples);
|
||||
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||
Assert.All(samples, s => Assert.Equal(ZB.MOM.WW.SPHistorianClient.Models.RetrievalMode.TimeWeightedAverage, s.RetrievalMode));
|
||||
}
|
||||
|
||||
// Verifies a previously-unmapped RetrievalMode (one of the 11 modes that prior to
|
||||
// 2026-05-04 threw ProtocolEvidenceMissingException). MinimumWithTime → QueryType=6
|
||||
// exercises the "QueryType is the native enum ordinal" mapping against the live server.
|
||||
[Theory]
|
||||
[InlineData(ZB.MOM.WW.SPHistorianClient.Models.RetrievalMode.MinimumWithTime)]
|
||||
[InlineData(ZB.MOM.WW.SPHistorianClient.Models.RetrievalMode.MaximumWithTime)]
|
||||
[InlineData(ZB.MOM.WW.SPHistorianClient.Models.RetrievalMode.BestFit)]
|
||||
public async Task ReadAggregateAsync_AgainstLocalHistorian_AcceptsPreviouslyUnmappedRetrievalMode(
|
||||
ZB.MOM.WW.SPHistorianClient.Models.RetrievalMode mode)
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)
|
||||
|| !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)
|
||||
|| !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
DateTime endUtc = DateTime.UtcNow;
|
||||
DateTime startUtc = endUtc - TimeSpan.FromMinutes(10);
|
||||
|
||||
List<ZB.MOM.WW.SPHistorianClient.Models.HistorianAggregateSample> samples = [];
|
||||
await foreach (ZB.MOM.WW.SPHistorianClient.Models.HistorianAggregateSample s in client.ReadAggregateAsync(
|
||||
testTag, startUtc, endUtc, mode, TimeSpan.FromMinutes(2), CancellationToken.None))
|
||||
{
|
||||
samples.Add(s);
|
||||
}
|
||||
|
||||
// Server should accept the request without error. Even if no rows come back
|
||||
// (unlikely for a 10-minute window on a steadily-counting tag), the absence of an
|
||||
// exception proves the QueryType byte was accepted.
|
||||
Assert.All(samples, s => Assert.Equal(mode, s.RetrievalMode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAtTimeAsync_AgainstLocalHistorian_ReturnsRequestedTimestamps()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
DateTime nowUtc = DateTime.UtcNow;
|
||||
DateTime[] timestamps =
|
||||
[
|
||||
nowUtc - TimeSpan.FromMinutes(5),
|
||||
nowUtc - TimeSpan.FromMinutes(2),
|
||||
nowUtc - TimeSpan.FromMinutes(1)
|
||||
];
|
||||
|
||||
IReadOnlyList<ZB.MOM.WW.SPHistorianClient.Models.HistorianSample> samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None);
|
||||
|
||||
Assert.NotEmpty(samples);
|
||||
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadEventsAsync_AgainstLocalHistorian_DoesNotThrow()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
DateTime endUtc = DateTime.UtcNow;
|
||||
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
|
||||
|
||||
// The event-row WCF wire format is not yet decoded; this test verifies the chain
|
||||
// (ValCl + Open2 + Retr.IsOriginalAllowed + Retr.StartEventQuery) reaches the server
|
||||
// without throwing. An empty event list is acceptable until row parsing is wired.
|
||||
List<ZB.MOM.WW.SPHistorianClient.Models.HistorianEvent> events = [];
|
||||
await foreach (ZB.MOM.WW.SPHistorianClient.Models.HistorianEvent evt in client.ReadEventsAsync(startUtc, endUtc, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
Assert.NotNull(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSystemParameterAsync_AgainstLocalHistorian_ReturnsHistorianVersion()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
string? value = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None);
|
||||
|
||||
// The server returns a non-empty version string for the documented HistorianVersion parameter.
|
||||
Assert.False(string.IsNullOrWhiteSpace(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionStatusAsync_AgainstLocalHistorian_ReportsConnectedToServer()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianConnectionStatus status =
|
||||
await client.GetConnectionStatusAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(status.ConnectedToServer);
|
||||
Assert.False(status.ErrorOccurred);
|
||||
Assert.False(status.Pending);
|
||||
Assert.Equal(host, status.ServerName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStoreForwardStatusAsync_AgainstLocalHistorian_ReturnsDefaults()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianStoreForwardStatus status =
|
||||
await client.GetStoreForwardStatusAsync(CancellationToken.None);
|
||||
|
||||
// The synthesized status returns defaults — no store-forward sidecar to probe in this build.
|
||||
Assert.False(status.ErrorOccurred);
|
||||
Assert.False(status.Pending);
|
||||
Assert.Equal(host, status.ServerName);
|
||||
}
|
||||
|
||||
// The validator inside HistorianWcfTagClient now allows IntegratedSecurity=false WHEN
|
||||
// explicit UserName + Password are provided (NTLM/Kerberos with non-current-user creds).
|
||||
// It still rejects the no-credentials-at-all case since there's no way to authenticate
|
||||
// against /Hist-Integrated.
|
||||
[Fact]
|
||||
public async Task GetTagMetadataAsync_NoAuthAndNoCredentials_Throws()
|
||||
{
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = "localhost",
|
||||
IntegratedSecurity = false,
|
||||
UserName = string.Empty,
|
||||
Password = string.Empty,
|
||||
});
|
||||
await Assert.ThrowsAsync<ProtocolEvidenceMissingException>(
|
||||
() => client.GetTagMetadataAsync("anytag", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTagMetadataAsync_ExplicitCredentials_AgainstLocalHistorian()
|
||||
{
|
||||
// Live verification of the explicit-creds tag-metadata path. Gated on
|
||||
// HISTORIAN_USER + HISTORIAN_PASSWORD being set; skips cleanly otherwise. The path
|
||||
// routes through WCF Windows transport security with Credentials.Windows.ClientCredential.
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)
|
||||
|| string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(password)
|
||||
|| !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = false,
|
||||
UserName = user,
|
||||
Password = password,
|
||||
});
|
||||
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianTagMetadata? metadata =
|
||||
await client.GetTagMetadataAsync(testTag, CancellationToken.None);
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(testTag, metadata.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTagMetadataAsync_ReturnsConfiguredTestTagMetadata()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort)
|
||||
? parsedPort
|
||||
: HistorianClientOptions.DefaultPort;
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
IntegratedSecurity = true,
|
||||
UserName = Environment.GetEnvironmentVariable("HISTORIAN_USER") ?? string.Empty,
|
||||
Password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD") ?? string.Empty
|
||||
});
|
||||
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianTagMetadata? metadata =
|
||||
await client.GetTagMetadataAsync(testTag, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(testTag, metadata.Name);
|
||||
Assert.NotNull(metadata.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian()
|
||||
{
|
||||
// Per docs/plans/write-commands-reverse-engineering.md safety rules: localhost only,
|
||||
// sandbox tag name must start with "RetestSdkWrite", tag is created if missing and
|
||||
// always deleted at the end so the test leaves zero residue.
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(sandboxTag) || !sandboxTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
|
||||
{
|
||||
return; // safety gate per the plan
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition definition = new()
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "SDK live integration test sandbox",
|
||||
EngineeringUnit = "test",
|
||||
DataType = ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
|
||||
MinEU = 0.0,
|
||||
MaxEU = 100.0,
|
||||
};
|
||||
|
||||
// Both EnsureTagAsync and DeleteTagAsync now work end-to-end against the live
|
||||
// Historian. Open2 must use write-enabled connectionMode 0x401 (not the default
|
||||
// 0x402 read-only); the EnsT2 InBuff layout is corrected to native parity (144
|
||||
// bytes incl 0x4E leading marker, no trailing 01 01 01 closing markers).
|
||||
bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
|
||||
Assert.True(ensured, "EnsureTagAsync returned false against the live Historian.");
|
||||
|
||||
bool deleted = await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
Assert.True(deleted, "DeleteTagAsync returned false against the live Historian.");
|
||||
}
|
||||
|
||||
// Round-trip every live-verified analog data type + the non-default-range case. The
|
||||
// sandbox tag name is suffixed per case so the runs don't collide. Always cleans up.
|
||||
[Theory]
|
||||
[InlineData("RetestSdkWriteFloatRT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float, 0.0, 100.0, 0.0, 100.0)]
|
||||
[InlineData("RetestSdkWriteDoubleRT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Double, 0.0, 100.0, 0.0, 100.0)]
|
||||
[InlineData("RetestSdkWriteInt2RT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Int2, 0.0, 100.0, 0.0, 100.0)]
|
||||
[InlineData("RetestSdkWriteInt4RT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Int4, 0.0, 100.0, 0.0, 100.0)]
|
||||
[InlineData("RetestSdkWriteUInt4RT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.UInt4, 0.0, 100.0, 0.0, 100.0)]
|
||||
[InlineData("RetestSdkWriteFloatRangesRT", ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float, -50.0, 200.0, 10.0, 4095.0)]
|
||||
public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_PerDataTypeAndRange(
|
||||
string sandboxTag,
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType dataType,
|
||||
double minEU, double maxEU, double minRaw, double maxRaw)
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition definition = new()
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = $"SDK round-trip {dataType}",
|
||||
EngineeringUnit = "test",
|
||||
DataType = dataType,
|
||||
MinEU = minEU,
|
||||
MaxEU = maxEU,
|
||||
MinRaw = minRaw,
|
||||
MaxRaw = maxRaw,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
|
||||
Assert.True(ensured, $"EnsureTagAsync({dataType}) returned false against the live Historian.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always clean up — DeleteTagAsync returns true on a freshly-created tag.
|
||||
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTagAsync_StorageTypeDelta_PersistsToTagTableAsTwo()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sandboxTag = "RetestSdkWriteStorageTypeDeltaRT";
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
bool ok = await client.EnsureTagAsync(new ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "SDK Delta round-trip",
|
||||
EngineeringUnit = "test",
|
||||
DataType = ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
|
||||
StorageType = ZB.MOM.WW.SPHistorianClient.Models.HistorianStorageType.Delta,
|
||||
}, CancellationToken.None);
|
||||
Assert.True(ok, "EnsureTagAsync(Delta) returned false");
|
||||
|
||||
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
|
||||
sql.Open();
|
||||
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
|
||||
cmd.CommandText = "SELECT StorageType FROM Tag WHERE TagName = @t";
|
||||
cmd.Parameters.AddWithValue("@t", sandboxTag);
|
||||
object? st = cmd.ExecuteScalar();
|
||||
Assert.NotNull(st);
|
||||
Assert.Equal((int)ZB.MOM.WW.SPHistorianClient.Models.HistorianStorageType.Delta, Convert.ToInt32(st));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTagAsync_NonDefaultStorageRate_PersistsToTagTable()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sandboxTag = "RetestSdkWriteStorageRateRT";
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
bool ok = await client.EnsureTagAsync(new ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "SDK StorageRate round-trip",
|
||||
EngineeringUnit = "test",
|
||||
DataType = ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
|
||||
// Server only accepts quantized rates — 1000, 5000, 10000, 60000, 300000 ms.
|
||||
StorageRateMs = 5000u,
|
||||
}, CancellationToken.None);
|
||||
Assert.True(ok, "EnsureTagAsync returned false");
|
||||
|
||||
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
|
||||
sql.Open();
|
||||
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
|
||||
cmd.CommandText = "SELECT StorageRate FROM Tag WHERE TagName = @t";
|
||||
cmd.Parameters.AddWithValue("@t", sandboxTag);
|
||||
object? rate = cmd.ExecuteScalar();
|
||||
Assert.NotNull(rate);
|
||||
Assert.Equal(5000, Convert.ToInt32(rate));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTagAsync_CalledTwiceOnSameTag_UpdatesFieldsInPlace()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sandboxTag = "RetestSdkWriteIdempotencyRT";
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
bool firstOk = await client.EnsureTagAsync(new ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "First version",
|
||||
EngineeringUnit = "test",
|
||||
DataType = ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
|
||||
MinEU = 0.0, MaxEU = 100.0, MinRaw = 0.0, MaxRaw = 100.0,
|
||||
ApplyScaling = false,
|
||||
}, CancellationToken.None);
|
||||
Assert.True(firstOk, "First EnsureTagAsync returned false");
|
||||
(string desc1, double minEU1, double maxEU1, double minRaw1, double maxRaw1, int scaling1) = ReadTagState(sandboxTag);
|
||||
Assert.Equal("First version", desc1);
|
||||
Assert.Equal(0.0, minEU1);
|
||||
Assert.Equal(0, scaling1);
|
||||
|
||||
bool secondOk = await client.EnsureTagAsync(new ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "Second version",
|
||||
EngineeringUnit = "kPa",
|
||||
DataType = ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
|
||||
MinEU = -50.0, MaxEU = 200.0, MinRaw = 10.0, MaxRaw = 4095.0,
|
||||
ApplyScaling = true,
|
||||
}, CancellationToken.None);
|
||||
Assert.True(secondOk, "Second EnsureTagAsync returned false");
|
||||
(string desc2, double minEU2, double maxEU2, double minRaw2, double maxRaw2, int scaling2) = ReadTagState(sandboxTag);
|
||||
|
||||
// EnsureTagAsync upserts: second call updates the existing row in place.
|
||||
Assert.Equal("Second version", desc2);
|
||||
Assert.Equal(-50.0, minEU2);
|
||||
Assert.Equal(200.0, maxEU2);
|
||||
Assert.Equal(10.0, minRaw2);
|
||||
Assert.Equal(4095.0, maxRaw2);
|
||||
Assert.Equal(1, scaling2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
}
|
||||
|
||||
static (string desc, double minEU, double maxEU, double minRaw, double maxRaw, int scaling) ReadTagState(string tagName)
|
||||
{
|
||||
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
|
||||
sql.Open();
|
||||
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
|
||||
cmd.CommandText = "SELECT t.[Description], a.MinEU, a.MaxEU, a.MinRaw, a.MaxRaw, a.Scaling FROM Tag t JOIN AnalogTag a ON a.TagName=t.TagName WHERE t.TagName=@t";
|
||||
cmd.Parameters.AddWithValue("@t", tagName);
|
||||
using Microsoft.Data.SqlClient.SqlDataReader r = cmd.ExecuteReader();
|
||||
Assert.True(r.Read(), $"Tag {tagName} not found");
|
||||
return (r.GetString(0), r.GetDouble(1), r.GetDouble(2), r.GetDouble(3), r.GetDouble(4), Convert.ToInt32(r.GetValue(5)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTagAsync_ApplyScalingTrue_PersistsDistinctMinRawAndMaxRaw()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sandboxTag = "RetestSdkWriteApplyScalingRT";
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe,
|
||||
});
|
||||
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianTagDefinition definition = new()
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "SDK ApplyScaling round-trip",
|
||||
EngineeringUnit = "test",
|
||||
DataType = ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
|
||||
MinEU = -50.0,
|
||||
MaxEU = 200.0,
|
||||
MinRaw = 10.0,
|
||||
MaxRaw = 4095.0,
|
||||
ApplyScaling = true,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
|
||||
Assert.True(ensured, "EnsureTagAsync(ApplyScaling=true) returned false against the live Historian.");
|
||||
|
||||
// Verify directly against the AnalogTag table — the read-path GetTagMetadataAsync
|
||||
// surfaces only one of (MinRaw, MinEU); SQL is the unambiguous source of truth.
|
||||
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
|
||||
sql.Open();
|
||||
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
|
||||
cmd.CommandText = "SELECT MinEU, MaxEU, MinRaw, MaxRaw, Scaling FROM AnalogTag WHERE TagName = @t";
|
||||
cmd.Parameters.AddWithValue("@t", sandboxTag);
|
||||
using Microsoft.Data.SqlClient.SqlDataReader r = cmd.ExecuteReader();
|
||||
Assert.True(r.Read(), $"AnalogTag row for {sandboxTag} not found after EnsureTag.");
|
||||
Assert.Equal(-50.0, r.GetDouble(0));
|
||||
Assert.Equal(200.0, r.GetDouble(1));
|
||||
Assert.Equal(10.0, r.GetDouble(2));
|
||||
Assert.Equal(4095.0, r.GetDouble(3));
|
||||
Assert.Equal(1, Convert.ToInt32(r.GetValue(4)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTagMetadataAsync_PopulatesDescriptionAndEuRangeForAnalogTag()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// SysTimeSec is a built-in analog UInt16 tag with non-empty Description, MaxEU,
|
||||
// and an EngineeringUnit. Verifies the parser populates those new fields end-to-end.
|
||||
const string analogTag = "SysTimeSec";
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianTagMetadata? metadata =
|
||||
await client.GetTagMetadataAsync(analogTag, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(analogTag, metadata.Name);
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.Description));
|
||||
Assert.NotNull(metadata.MaxRaw);
|
||||
Assert.True(metadata.MaxRaw is > 0 and <= 1e15);
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.EngineeringUnit));
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class HistorianEventRowProtocolTests
|
||||
{
|
||||
private static readonly Guid PlaceholderAlarmId = new("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
[Fact]
|
||||
public void Parse_EmptyBuffer_ReturnsEmpty()
|
||||
{
|
||||
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse([]);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_HeaderWithZeroRowCount_ReturnsEmpty()
|
||||
{
|
||||
byte[] buffer = BuildHeader(rowCount: 0);
|
||||
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WrongVersion_ReturnsEmpty()
|
||||
{
|
||||
byte[] buffer = new byte[6];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 8); // not 9
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(2, 4), 5u);
|
||||
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TwoSyntheticRows_ReturnsTimestampsAndEventTypes()
|
||||
{
|
||||
DateTime t1 = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
|
||||
DateTime t2 = t1.AddSeconds(10);
|
||||
|
||||
byte[] buffer = Concat(
|
||||
BuildHeader(rowCount: 2),
|
||||
BuildRow(t1, "Alarm.Set", []),
|
||||
BuildRow(t2, "Alarm.Clear", []));
|
||||
|
||||
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
|
||||
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Equal(t1, events[0].EventTimeUtc);
|
||||
Assert.Equal("Alarm.Set", events[0].Type);
|
||||
Assert.Equal(t2, events[1].EventTimeUtc);
|
||||
Assert.Equal("Alarm.Clear", events[1].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RowWithKnownProperties_PopulatesEventFields()
|
||||
{
|
||||
DateTime eventTime = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
|
||||
DateTime receivedTime = eventTime.AddMilliseconds(250);
|
||||
|
||||
var properties = new (string Name, byte[] Value)[]
|
||||
{
|
||||
("alarm_inalarm", BuildBool(true)),
|
||||
("alarm_id", BuildGuid(PlaceholderAlarmId)),
|
||||
("severity", BuildInt32(2)),
|
||||
("priority", BuildInt32(500)),
|
||||
("alarm_class", BuildUtf16String("DSC")),
|
||||
("source_processvariable", BuildUtf16String("Sample.Tag")),
|
||||
("provider_system", BuildUtf16String("Application Server")),
|
||||
("receivedtime", BuildFiletime(receivedTime)),
|
||||
("revisionversion", BuildInt32(7)),
|
||||
};
|
||||
|
||||
byte[] buffer = Concat(BuildHeader(rowCount: 1), BuildRow(eventTime, "Alarm.Set", properties));
|
||||
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
|
||||
|
||||
HistorianEvent evt = Assert.Single(events);
|
||||
Assert.Equal(PlaceholderAlarmId, evt.Id);
|
||||
Assert.Equal(eventTime, evt.EventTimeUtc);
|
||||
Assert.Equal(receivedTime, evt.ReceivedTimeUtc);
|
||||
Assert.Equal("Alarm.Set", evt.Type);
|
||||
Assert.Equal("Sample.Tag", evt.SourceName);
|
||||
Assert.Equal("Application Server", evt.Namespace);
|
||||
Assert.Equal(7, evt.RevisionVersion);
|
||||
Assert.Equal(true, evt.Properties["alarm_inalarm"]);
|
||||
Assert.Equal("DSC", evt.Properties["alarm_class"]);
|
||||
Assert.Equal(2, evt.Properties["severity"]);
|
||||
Assert.Equal(500, evt.Properties["priority"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UnknownTypeMarker_KeepsRawBytesInPropertyBag()
|
||||
{
|
||||
DateTime eventTime = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
|
||||
// Custom type 0xAA with 3-byte value.
|
||||
byte[] customValue = [0xAA, 0x03, 0x00, 0xDE, 0xAD, 0xBE];
|
||||
byte[] buffer = Concat(
|
||||
BuildHeader(rowCount: 1),
|
||||
BuildRowWithRawValue(eventTime, "Alarm.Set", "custom_field", customValue));
|
||||
|
||||
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
|
||||
HistorianEvent evt = Assert.Single(events);
|
||||
Assert.IsType<byte[]>(evt.Properties["custom_field"]);
|
||||
Assert.Equal([0xDE, 0xAD, 0xBE], (byte[])evt.Properties["custom_field"]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RowWithMissingMarker_StopsAtBadRow()
|
||||
{
|
||||
DateTime t1 = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
|
||||
byte[] goodRow = BuildRow(t1, "Alarm.Set", []);
|
||||
byte[] badRow = new byte[goodRow.Length];
|
||||
byte[] buffer = Concat(BuildHeader(rowCount: 2), goodRow, badRow);
|
||||
|
||||
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
|
||||
|
||||
Assert.Single(events);
|
||||
Assert.Equal("Alarm.Set", events[0].Type);
|
||||
}
|
||||
|
||||
private static byte[] BuildHeader(uint rowCount)
|
||||
{
|
||||
byte[] header = new byte[6];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0, 2), HistorianEventRowProtocol.EventRowProtocolVersion);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(2, 4), rowCount);
|
||||
return header;
|
||||
}
|
||||
|
||||
private static byte[] BuildRow(DateTime eventTimeUtc, string eventType, (string Name, byte[] Value)[] properties)
|
||||
{
|
||||
byte[] eventTypeBytes = BuildCompactAscii(eventType);
|
||||
ushort propertyCount = (ushort)properties.Length;
|
||||
int propertyBlockSize = 0;
|
||||
byte[][] propertyBlocks = new byte[properties.Length][];
|
||||
for (int i = 0; i < properties.Length; i++)
|
||||
{
|
||||
byte[] nameBlock = BuildCompactAscii(properties[i].Name);
|
||||
propertyBlocks[i] = Concat(nameBlock, properties[i].Value);
|
||||
propertyBlockSize += propertyBlocks[i].Length;
|
||||
}
|
||||
|
||||
byte[] row = new byte[4 + 2 + 8 + 16 + eventTypeBytes.Length + 2 + propertyBlockSize];
|
||||
Span<byte> span = row;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span[..4], HistorianEventRowProtocol.RowMarker);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(4, 2), HistorianEventRowProtocol.RowFormatV9);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(span.Slice(6, 8), eventTimeUtc.ToFileTimeUtc());
|
||||
// 16 bytes of zeroed slot ushorts left as-is.
|
||||
int eventTypeOffset = 4 + 2 + 8 + 16;
|
||||
eventTypeBytes.CopyTo(span[eventTypeOffset..]);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(eventTypeOffset + eventTypeBytes.Length, 2), propertyCount);
|
||||
int cursor = eventTypeOffset + eventTypeBytes.Length + 2;
|
||||
foreach (byte[] block in propertyBlocks)
|
||||
{
|
||||
block.CopyTo(span[cursor..]);
|
||||
cursor += block.Length;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static byte[] BuildRowWithRawValue(DateTime eventTimeUtc, string eventType, string propertyName, byte[] rawValueBytes)
|
||||
{
|
||||
return BuildRow(eventTimeUtc, eventType, [(propertyName, rawValueBytes)]);
|
||||
}
|
||||
|
||||
private static byte[] BuildCompactAscii(string s)
|
||||
{
|
||||
byte[] ascii = Encoding.ASCII.GetBytes(s);
|
||||
byte[] result = new byte[3 + ascii.Length];
|
||||
result[0] = 0x09;
|
||||
result[1] = (byte)ascii.Length;
|
||||
result[2] = 0x00;
|
||||
ascii.CopyTo(result, 3);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] BuildBool(bool value) => [0x02, 0x01, 0x00, value ? (byte)1 : (byte)0];
|
||||
|
||||
private static byte[] BuildInt32(int value)
|
||||
{
|
||||
byte[] result = [0x31, 0x04, 0x00, 0, 0, 0, 0];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(3, 4), value);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] BuildGuid(Guid value)
|
||||
{
|
||||
byte[] result = new byte[19];
|
||||
result[0] = 0x10;
|
||||
result[1] = 0x10;
|
||||
result[2] = 0x00;
|
||||
value.ToByteArray().CopyTo(result, 3);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] BuildFiletime(DateTime value)
|
||||
{
|
||||
byte[] result = [0x18, 0x08, 0x00, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(result.AsSpan(3, 8), value.ToFileTimeUtc());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] BuildUtf16String(string value)
|
||||
{
|
||||
byte[] chars = Encoding.Unicode.GetBytes(value);
|
||||
ushort innerLength = (ushort)(2 + chars.Length); // UInt16 charCount + chars
|
||||
byte[] result = new byte[3 + innerLength];
|
||||
result[0] = 0x43;
|
||||
result[1] = (byte)innerLength;
|
||||
result[2] = 0x00;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(result.AsSpan(3, 2), (ushort)value.Length);
|
||||
chars.CopyTo(result, 5);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] arrays)
|
||||
{
|
||||
int total = 0;
|
||||
foreach (byte[] a in arrays) total += a.Length;
|
||||
byte[] result = new byte[total];
|
||||
int offset = 0;
|
||||
foreach (byte[] a in arrays)
|
||||
{
|
||||
Buffer.BlockCopy(a, 0, result, offset, a.Length);
|
||||
offset += a.Length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Live integration tests for the 2023 R2 RemoteGrpc transport. Gated on a dedicated
|
||||
/// <c>HISTORIAN_GRPC_HOST</c> env var (plus <c>HISTORIAN_TEST_TAG</c>) so they skip cleanly until
|
||||
/// a 2023 R2 Historian is available. Optional:
|
||||
/// HISTORIAN_GRPC_PORT (default 32565), HISTORIAN_GRPC_TLS (true/false),
|
||||
/// HISTORIAN_USER / HISTORIAN_PASSWORD (explicit creds; otherwise IntegratedSecurity),
|
||||
/// HISTORIAN_GRPC_DNSID (server certificate name when connecting by IP over TLS).
|
||||
/// </summary>
|
||||
public sealed class HistorianGrpcIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_OverGrpc_ReturnsAtLeastOneRow()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(BuildOptions(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);
|
||||
}
|
||||
|
||||
Assert.NotEmpty(samples);
|
||||
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||
}
|
||||
|
||||
private static HistorianClientOptions BuildOptions(string host)
|
||||
{
|
||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||
bool explicitCreds = !string.IsNullOrEmpty(user);
|
||||
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
|
||||
? parsed
|
||||
: HistorianClientOptions.DefaultGrpcPort;
|
||||
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Transport = HistorianTransport.RemoteGrpc,
|
||||
GrpcUseTls = tls,
|
||||
AllowUntrustedServerCertificate = tls,
|
||||
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
|
||||
IntegratedSecurity = !explicitCreds,
|
||||
UserName = user ?? string.Empty,
|
||||
Password = password ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Grpc;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
using Google.Protobuf;
|
||||
using ArchestrA.Grpc.Contract.Retrieval;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for the 2023 R2 RemoteGrpc transport — the parts that do not require a live
|
||||
/// server: channel address/port resolution, metadata, transport routing, and the invariant that
|
||||
/// gRPC request messages carry the same native byte buffers the WCF path uses.
|
||||
/// </summary>
|
||||
public sealed class HistorianGrpcTransportTests
|
||||
{
|
||||
private static HistorianClientOptions Options(
|
||||
string host = "histserver",
|
||||
int port = HistorianClientOptions.DefaultPort,
|
||||
bool tls = false,
|
||||
string? dnsIdentity = null,
|
||||
bool compression = false) => new()
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Transport = HistorianTransport.RemoteGrpc,
|
||||
GrpcUseTls = tls,
|
||||
ServerDnsIdentity = dnsIdentity,
|
||||
Compression = compression,
|
||||
IntegratedSecurity = true
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ResolvePort_DefaultWcfPort_SubstitutesGrpcDefault()
|
||||
{
|
||||
Assert.Equal(HistorianClientOptions.DefaultGrpcPort, HistorianGrpcChannelFactory.ResolvePort(Options(port: HistorianClientOptions.DefaultPort)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePort_ExplicitPort_IsHonoured()
|
||||
{
|
||||
Assert.Equal(443, HistorianGrpcChannelFactory.ResolvePort(Options(port: 443)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAddress_Plaintext_UsesHttpAndHost()
|
||||
{
|
||||
Assert.Equal("http://histserver:32565", HistorianGrpcChannelFactory.ResolveAddress(Options()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAddress_Tls_UsesHttpsAndHostWhenNoDnsIdentity()
|
||||
{
|
||||
Assert.Equal("https://histserver:32565", HistorianGrpcChannelFactory.ResolveAddress(Options(tls: true)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAddress_Tls_PrefersDnsIdentityForCertMatch()
|
||||
{
|
||||
string address = HistorianGrpcChannelFactory.ResolveAddress(Options(host: "10.0.0.5", tls: true, dnsIdentity: "localhost"));
|
||||
Assert.Equal("https://localhost:32565", address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_CompressionDisabled_EmitsNoEncodingHeader()
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(Options(compression: false));
|
||||
Assert.DoesNotContain(connection.Metadata, e => e.Key == "grpc-internal-encoding-request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_CompressionEnabled_AdvertisesGzipRequestEncoding()
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(Options(compression: true));
|
||||
global::Grpc.Core.Metadata.Entry entry = Assert.Single(connection.Metadata, e => e.Key == "grpc-internal-encoding-request");
|
||||
Assert.Equal("gzip", entry.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartQueryRequest_CarriesNativeDataQueryBufferUnchanged()
|
||||
{
|
||||
// The gRPC envelope must wrap the exact bytes the WCF StartQuery2 path sends, so the
|
||||
// already-reverse-engineered DataQueryRequest serializer is reused verbatim.
|
||||
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest(
|
||||
"Tag.Counter", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), 100);
|
||||
byte[] nativeBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
|
||||
|
||||
var message = new StartQueryRequest
|
||||
{
|
||||
UiHandle = 7,
|
||||
UiQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData,
|
||||
BtRequestBuffer = ByteString.CopyFrom(nativeBuffer)
|
||||
};
|
||||
|
||||
// Round-trip through protobuf and confirm the native buffer survives byte-for-byte.
|
||||
byte[] wire = message.ToByteArray();
|
||||
var decoded = StartQueryRequest.Parser.ParseFrom(wire);
|
||||
Assert.Equal(nativeBuffer, decoded.BtRequestBuffer.ToByteArray());
|
||||
Assert.Equal(7u, decoded.UiHandle);
|
||||
Assert.Equal((uint)HistorianDataQueryProtocol.QueryRequestTypeData, decoded.UiQueryRequestType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged()
|
||||
{
|
||||
byte[] open2 = HistorianNativeHandshake.BuildOpenConnection3Request(
|
||||
"histserver", Guid.NewGuid(), HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
|
||||
|
||||
var message = new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2) };
|
||||
var decoded = GrpcHistory.OpenConnectionRequest.Parser.ParseFrom(message.ToByteArray());
|
||||
|
||||
Assert.Equal(open2, decoded.BtConnectionRequest.ToByteArray());
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
using System.Runtime.Versioning;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class HistorianRetrievalModeMappingTests
|
||||
{
|
||||
// Probed 2026-05-04 via instrument-wcf-writemessage against every
|
||||
// ArchestrA.HistorianRetrievalMode value — see HistorianWcfReadOrchestrator
|
||||
// MapRetrievalModeToQueryType doc comment for capture details.
|
||||
[Theory]
|
||||
[InlineData(RetrievalMode.Cyclic, 0u)]
|
||||
[InlineData(RetrievalMode.Delta, 1u)]
|
||||
[InlineData(RetrievalMode.Full, 2u)]
|
||||
[InlineData(RetrievalMode.Interpolated, 3u)]
|
||||
[InlineData(RetrievalMode.BestFit, 4u)]
|
||||
[InlineData(RetrievalMode.TimeWeightedAverage, 5u)]
|
||||
[InlineData(RetrievalMode.MinimumWithTime, 6u)]
|
||||
[InlineData(RetrievalMode.MaximumWithTime, 7u)]
|
||||
[InlineData(RetrievalMode.Integral, 8u)]
|
||||
[InlineData(RetrievalMode.Slope, 9u)]
|
||||
[InlineData(RetrievalMode.Counter, 10u)]
|
||||
[InlineData(RetrievalMode.ValueState, 11u)]
|
||||
[InlineData(RetrievalMode.RoundTrip, 12u)]
|
||||
[InlineData(RetrievalMode.StartBound, 13u)]
|
||||
[InlineData(RetrievalMode.EndBound, 14u)]
|
||||
public void MapRetrievalModeToQueryType_MatchesNativeEnumOrdinal(RetrievalMode mode, uint expectedQueryType)
|
||||
{
|
||||
Assert.Equal(expectedQueryType, HistorianWcfReadOrchestrator.MapRetrievalModeToQueryType(mode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapRetrievalModeToQueryType_UndefinedValue_Throws()
|
||||
{
|
||||
Assert.Throws<ProtocolEvidenceMissingException>(
|
||||
() => HistorianWcfReadOrchestrator.MapRetrievalModeToQueryType((RetrievalMode)999));
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
using System.Runtime.Versioning;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class HistorianSspiClientTests
|
||||
{
|
||||
[Fact]
|
||||
public void NativeFlagsRound0_MatchesDocumentedNativeWrapperValue()
|
||||
{
|
||||
Assert.Equal(0x2081C, HistorianSspiClient.NativeFlagsRound0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeFlagsRoundSubsequent_MatchesDocumentedNativeWrapperValue()
|
||||
{
|
||||
Assert.Equal(0x81C, HistorianSspiClient.NativeFlagsRoundSubsequent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round0FlagsIncludeIdentify_LaterRoundsDoNot()
|
||||
{
|
||||
Assert.Equal(HistorianSspiClient.IscReqIdentify, HistorianSspiClient.NativeFlagsRound0 & HistorianSspiClient.IscReqIdentify);
|
||||
Assert.Equal(0, HistorianSspiClient.NativeFlagsRoundSubsequent & HistorianSspiClient.IscReqIdentify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllRoundsRequestReplayAndSequenceDetection()
|
||||
{
|
||||
const int both = HistorianSspiClient.IscReqReplayDetect | HistorianSspiClient.IscReqSequenceDetect;
|
||||
Assert.Equal(both, HistorianSspiClient.NativeFlagsRound0 & both);
|
||||
Assert.Equal(both, HistorianSspiClient.NativeFlagsRoundSubsequent & both);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectRequestFlags_DispatchesByRoundIndex()
|
||||
{
|
||||
Assert.Equal(HistorianSspiClient.NativeFlagsRound0, HistorianSspiClient.SelectRequestFlags(0));
|
||||
Assert.Equal(HistorianSspiClient.NativeFlagsRoundSubsequent, HistorianSspiClient.SelectRequestFlags(1));
|
||||
Assert.Equal(HistorianSspiClient.NativeFlagsRoundSubsequent, HistorianSspiClient.SelectRequestFlags(7));
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class HistorianTagWriteProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_MatchesCapturedNativeBytesByteForByte()
|
||||
{
|
||||
// Reproduces the captured native EnsT2(Float) CTagMetadata bytes for the sandbox
|
||||
// tag with default ranges and ApplyScaling=false. 2-byte trailer = `FE 00` where
|
||||
// the second byte is the ApplyScaling flag (0x00 = false; 0x01 = true).
|
||||
const string ExpectedHex =
|
||||
"4E6703000100000004C6020100000000000000000000000000000000"
|
||||
+ "09150052657465737453646B577269746553616E64626F78"
|
||||
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
||||
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E803000049D087CDBFDBDC011A030904007465737410270000000000000000F03FFE00";
|
||||
|
||||
byte[] expected = Convert.FromHexString(ExpectedHex);
|
||||
byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteSandbox",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01DCDBBFCD87D049L));
|
||||
|
||||
Assert.Equal(144, expected.Length);
|
||||
Assert.Equal(144, actual.Length);
|
||||
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
|
||||
}
|
||||
|
||||
// Per-data-type captures from instrument-wcf-writemessage 2026-05-04 — the only
|
||||
// diff vs the Float baseline is byte 11 (the data-type discriminator) plus tag-name
|
||||
// length. All other inputs (description, EU, default ranges, storage rate) match
|
||||
// the captured baseline so the byte-for-byte assertion exercises the dispatch.
|
||||
[Theory]
|
||||
[InlineData(
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Double,
|
||||
"RetestSdkWriteDouble", 0x01dcdbed24988f3aL,
|
||||
"4E6703000100000004C6022100000000000000000000000000000000"
|
||||
+ "09140052657465737453646B5772697465446F75626C65"
|
||||
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
||||
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
||||
[InlineData(
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Int4,
|
||||
"RetestSdkWriteInt4", 0x01dcdbed292e1cecL,
|
||||
"4E6703000100000004C6023100000000000000000000000000000000"
|
||||
+ "09120052657465737453646B5772697465496E7434"
|
||||
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
||||
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000EC1C2E29EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
||||
[InlineData(
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.UInt4,
|
||||
"RetestSdkWriteUInt4", 0x01dcdbed2d33b02cL,
|
||||
"4E6703000100000004C6021100000000000000000000000000000000"
|
||||
+ "09130052657465737453646B577269746555496E7434"
|
||||
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
||||
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300002CB0332DEDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
||||
[InlineData(
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Int2,
|
||||
"RetestSdkWriteInt2", 0x01dcdbed360e9b54L,
|
||||
"4E6703000100000004C6022900000000000000000000000000000000"
|
||||
+ "09120052657465737453646B5772697465496E7432"
|
||||
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
||||
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
||||
public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes(
|
||||
ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType dataType,
|
||||
string tagName,
|
||||
long fileTimeUtc,
|
||||
string expectedHex)
|
||||
{
|
||||
byte[] expected = Convert.FromHexString(expectedHex);
|
||||
byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: tagName,
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(fileTimeUtc),
|
||||
dataType: dataType);
|
||||
|
||||
Assert.Equal(expected.Length, actual.Length);
|
||||
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
|
||||
}
|
||||
|
||||
// Captured 2026-05-04 with MinEU=-50, MaxEU=200, MinRaw=10, MaxRaw=4095. Verifies
|
||||
// the explicit-scaling marker `1F` + 4 doubles in order (MinEU, MaxEU, MinRaw, MaxRaw).
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_NonDefaultRanges_EmitsExplicitMarkerAndFourDoubles()
|
||||
{
|
||||
const string ExpectedHex =
|
||||
"4E6703000100000004C6020100000000000000000000000000000000"
|
||||
+ "09190052657465737453646B5772697465466C6F617452616E676573"
|
||||
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF09180053444B207772697465"
|
||||
+ "2D52452073616E64626F78207461670904004D444153020101000000"
|
||||
+ "01E8030000BE294B47EDDBDC011F0000000000000049C00000000000"
|
||||
+ "00694000000000000024400000000000FEAF40090400746573741027"
|
||||
+ "0000000000000000F03FFE00";
|
||||
|
||||
byte[] expected = Convert.FromHexString(ExpectedHex);
|
||||
byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteFloatRanges",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
|
||||
dataType: ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
|
||||
minEU: -50.0,
|
||||
maxEU: 200.0,
|
||||
minRaw: 10.0,
|
||||
maxRaw: 4095.0);
|
||||
|
||||
Assert.Equal(180, expected.Length);
|
||||
Assert.Equal(180, actual.Length);
|
||||
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_NonDefaultStorageRate_EncodesUInt32LittleEndianAtKnownOffset()
|
||||
{
|
||||
byte[] defaultRate = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteRate",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL));
|
||||
byte[] customRate = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteRate",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
|
||||
storageRateMs: 2500u);
|
||||
|
||||
Assert.Equal(defaultRate.Length, customRate.Length);
|
||||
// Storage-rate uint32 is at the byte position immediately after the
|
||||
// "MDAS" + flag-block sequence; the only diff between the two payloads
|
||||
// is those 4 bytes.
|
||||
int firstDiff = 0;
|
||||
while (firstDiff < defaultRate.Length && defaultRate[firstDiff] == customRate[firstDiff]) firstDiff++;
|
||||
Assert.Equal(0xE8, defaultRate[firstDiff]); // 1000 = 0x000003E8 LE → 0xE8 0x03 0x00 0x00
|
||||
Assert.Equal(0x03, defaultRate[firstDiff + 1]);
|
||||
Assert.Equal(0xC4, customRate[firstDiff]); // 2500 = 0x000009C4 LE → 0xC4 0x09 0x00 0x00
|
||||
Assert.Equal(0x09, customRate[firstDiff + 1]);
|
||||
// Beyond the 4-byte rate field, the rest is identical.
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(defaultRate.AsSpan(firstDiff + 4)),
|
||||
Convert.ToHexString(customRate.AsSpan(firstDiff + 4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_ZeroStorageRate_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteRate",
|
||||
description: "x",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.UtcNow,
|
||||
storageRateMs: 0u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_StorageTypeDelta_FlipsHeaderByte10AndFlagBlockByte1AndAddsFourBytePadding()
|
||||
{
|
||||
// Captured 2026-05-04 by toggling --write-storage-type on the native harness:
|
||||
// Delta differs from Cyclic in three places — header byte 10 (0x02 -> 0x06),
|
||||
// flag-block byte 1 (0x01 -> 0x02), and 4 zero bytes inserted after StorageRate
|
||||
// before the FILETIME. Net length difference is +4 bytes for Delta.
|
||||
byte[] cyclic = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteStorageTypeRT",
|
||||
description: "x",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
|
||||
storageType: ZB.MOM.WW.SPHistorianClient.Models.HistorianStorageType.Cyclic);
|
||||
byte[] delta = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteStorageTypeRT",
|
||||
description: "x",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
|
||||
storageType: ZB.MOM.WW.SPHistorianClient.Models.HistorianStorageType.Delta);
|
||||
|
||||
Assert.Equal(cyclic.Length + 4, delta.Length);
|
||||
// Header byte 10 (storage-type sub-marker before the data-type code).
|
||||
Assert.Equal(0x02, cyclic[10]);
|
||||
Assert.Equal(0x06, delta[10]);
|
||||
// The data-type code at byte 11 is unchanged.
|
||||
Assert.Equal(cyclic[11], delta[11]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_NonDefaultIntegralDivisor_FlipsEightBytesBeforeTrailer()
|
||||
{
|
||||
byte[] @default = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteIntDiv",
|
||||
description: "x",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL));
|
||||
byte[] custom = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteIntDiv",
|
||||
description: "x",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
|
||||
integralDivisor: 2.5);
|
||||
|
||||
Assert.Equal(@default.Length, custom.Length);
|
||||
// The 8 bytes immediately before the 2-byte trailer are the IntegralDivisor double.
|
||||
ReadOnlySpan<byte> defaultDivisor = @default.AsSpan(@default.Length - 10, 8);
|
||||
ReadOnlySpan<byte> customDivisor = custom.AsSpan(custom.Length - 10, 8);
|
||||
Assert.Equal(1.0, BitConverter.ToDouble(defaultDivisor));
|
||||
Assert.Equal(2.5, BitConverter.ToDouble(customDivisor));
|
||||
// Bytes preceding the divisor are identical.
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(@default.AsSpan(0, @default.Length - 10)),
|
||||
Convert.ToHexString(custom.AsSpan(0, custom.Length - 10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_ApplyScalingTrue_FlipsTrailerSecondByte()
|
||||
{
|
||||
// Captured 2026-05-04 by toggling --write-apply-scaling on the native harness:
|
||||
// ApplyScaling=true sets the trailer's second byte to 0x01 (vs 0x00 for false).
|
||||
// Live-verified: with 0x01 the server persists distinct MinRaw/MaxRaw and sets
|
||||
// AnalogTag.Scaling=1; with 0x00 it mirrors MinRaw to MinEU and sets Scaling=0.
|
||||
byte[] withFlag = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteFloatRanges",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
|
||||
dataType: ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
|
||||
minEU: -50.0, maxEU: 200.0, minRaw: 10.0, maxRaw: 4095.0,
|
||||
applyScaling: true);
|
||||
byte[] withoutFlag = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteFloatRanges",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
|
||||
dataType: ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Float,
|
||||
minEU: -50.0, maxEU: 200.0, minRaw: 10.0, maxRaw: 4095.0,
|
||||
applyScaling: false);
|
||||
|
||||
Assert.Equal(withoutFlag.Length, withFlag.Length);
|
||||
Assert.Equal(0xFE, withFlag[^2]);
|
||||
Assert.Equal(0x01, withFlag[^1]);
|
||||
Assert.Equal(0xFE, withoutFlag[^2]);
|
||||
Assert.Equal(0x00, withoutFlag[^1]);
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(withoutFlag.AsSpan(0, withoutFlag.Length - 1)),
|
||||
Convert.ToHexString(withFlag.AsSpan(0, withFlag.Length - 1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAnalogDataTypeCode_UnsupportedType_Throws()
|
||||
{
|
||||
Assert.Throws<ProtocolEvidenceMissingException>(
|
||||
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.SingleByteString));
|
||||
Assert.Throws<ProtocolEvidenceMissingException>(
|
||||
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.Int1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_DifferentInputsProducesDifferentBytesInExpectedSlots()
|
||||
{
|
||||
DateTime t = new(2026, 5, 4, 12, 0, 0, DateTimeKind.Utc);
|
||||
byte[] a = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata("Tag1", "DescA", "uA", t);
|
||||
byte[] b = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata("Tag2", "DescB", "uB", t);
|
||||
Assert.NotEqual(Convert.ToHexString(a), Convert.ToHexString(b));
|
||||
// First difference must be inside the tagName region (offset 27+ after the 9-byte
|
||||
// header + 16-byte zero block + 2-byte compact-ASCII len-prefix).
|
||||
int firstDiff = 0;
|
||||
while (firstDiff < a.Length && a[firstDiff] == b[firstDiff]) firstDiff++;
|
||||
Assert.InRange(firstDiff, 25, a.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeleteTagNames_SingleTagMatchesCapturedShape()
|
||||
{
|
||||
// Captured DelT.tagNames bytes for ['RetestSdkWriteSandbox']:
|
||||
// ushort 0x6751 + ushort 1 + uint32 1 + uint32 21 + UTF-16 "RetestSdkWriteSandbox"
|
||||
// = 12-byte header + 42-byte UTF-16 string = 54 bytes total.
|
||||
byte[] expected = Concat(
|
||||
[0x51, 0x67, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00],
|
||||
Encoding.Unicode.GetBytes("RetestSdkWriteSandbox"));
|
||||
|
||||
byte[] actual = HistorianTagWriteProtocol.SerializeDeleteTagNames(["RetestSdkWriteSandbox"]);
|
||||
|
||||
Assert.Equal(54, actual.Length);
|
||||
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeleteTagNames_MultipleTagsAppendsEach()
|
||||
{
|
||||
byte[] result = HistorianTagWriteProtocol.SerializeDeleteTagNames(["A", "BB", "CCC"]);
|
||||
// 8-byte header (ushort 0x6751 + ushort 1 + uint32 tagCount)
|
||||
// + 3 × (uint32 charCount + UTF-16 chars)
|
||||
// = 8 + (4 + 2) + (4 + 4) + (4 + 6) = 32 bytes
|
||||
Assert.Equal(32, result.Length);
|
||||
// Header: 0x6751 + 0x0001 + count=3
|
||||
Assert.Equal(0x51, result[0]); Assert.Equal(0x67, result[1]);
|
||||
Assert.Equal(0x01, result[2]); Assert.Equal(0x00, result[3]);
|
||||
Assert.Equal(3, BitConverter.ToInt32(result, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeleteTagNames_EmptyListThrows()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => HistorianTagWriteProtocol.SerializeDeleteTagNames([]));
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] arrays)
|
||||
{
|
||||
int total = 0; foreach (byte[] a in arrays) total += a.Length;
|
||||
byte[] result = new byte[total]; int off = 0;
|
||||
foreach (byte[] a in arrays) { Buffer.BlockCopy(a, 0, result, off, a.Length); off += a.Length; }
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
using System.IdentityModel.Selectors;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using System.ServiceModel.Security;
|
||||
using ZB.MOM.WW.SPHistorianClient;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class HistorianWcfCertOptionTests
|
||||
{
|
||||
private static HistorianClientOptions BaseOptions(bool allowUntrusted = false, string? dnsIdentity = null) =>
|
||||
new()
|
||||
{
|
||||
Host = "10.0.0.1",
|
||||
Port = HistorianClientOptions.DefaultPort,
|
||||
Transport = HistorianTransport.RemoteTcpCertificate,
|
||||
IntegratedSecurity = false,
|
||||
UserName = "user",
|
||||
Password = "pass",
|
||||
AllowUntrustedServerCertificate = allowUntrusted,
|
||||
ServerDnsIdentity = dnsIdentity,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ClientCredentialsHelper_Disabled_LeavesValidationModeAtDefault()
|
||||
{
|
||||
Binding binding = HistorianWcfBindingFactory.CreateMdasNetTcpBinding(TimeSpan.FromSeconds(5));
|
||||
ChannelFactory<IHistoryServiceContract2> factory = new(binding, new EndpointAddress("net.tcp://10.0.0.1:32568/Hist"));
|
||||
try
|
||||
{
|
||||
HistorianWcfClientCredentialsHelper.Configure(factory, BaseOptions(allowUntrusted: false));
|
||||
|
||||
X509ServiceCertificateAuthentication auth = factory.Credentials.ServiceCertificate.SslCertificateAuthentication
|
||||
?? factory.Credentials.ServiceCertificate.Authentication;
|
||||
// Default validation mode is ChainTrust — explicitly NOT None / Custom.
|
||||
Assert.NotEqual(X509CertificateValidationMode.None, auth.CertificateValidationMode);
|
||||
Assert.Null(auth.CustomCertificateValidator);
|
||||
}
|
||||
finally
|
||||
{
|
||||
factory.Abort();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientCredentialsHelper_Enabled_InstallsAcceptAnyValidator()
|
||||
{
|
||||
Binding binding = HistorianWcfBindingFactory.CreateMdasNetTcpBinding(TimeSpan.FromSeconds(5));
|
||||
ChannelFactory<IHistoryServiceContract2> factory = new(binding, new EndpointAddress("net.tcp://10.0.0.1:32568/Hist"));
|
||||
try
|
||||
{
|
||||
HistorianWcfClientCredentialsHelper.Configure(factory, BaseOptions(allowUntrusted: true));
|
||||
|
||||
X509ServiceCertificateAuthentication auth = factory.Credentials.ServiceCertificate.SslCertificateAuthentication;
|
||||
Assert.NotNull(auth);
|
||||
Assert.Equal(X509CertificateValidationMode.Custom, auth.CertificateValidationMode);
|
||||
Assert.Equal(X509RevocationMode.NoCheck, auth.RevocationMode);
|
||||
Assert.NotNull(auth.CustomCertificateValidator);
|
||||
Assert.IsAssignableFrom<X509CertificateValidator>(auth.CustomCertificateValidator);
|
||||
}
|
||||
finally
|
||||
{
|
||||
factory.Abort();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateEndpointAddress_WithoutDnsIdentity_HasNullIdentity()
|
||||
{
|
||||
EndpointAddress address = HistorianWcfBindingFactory.CreateEndpointAddress("10.0.0.1", 32568, "Hist");
|
||||
Assert.Null(address.Identity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateEndpointAddress_WithDnsIdentity_AttachesDnsEndpointIdentity()
|
||||
{
|
||||
EndpointAddress address = HistorianWcfBindingFactory.CreateEndpointAddress("10.0.0.1", 32568, "HistCert", "localhost");
|
||||
Assert.NotNull(address.Identity);
|
||||
DnsEndpointIdentity dns = Assert.IsType<DnsEndpointIdentity>(address.Identity);
|
||||
Assert.Equal("localhost", dns.IdentityClaim.Resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBindingPair_RemoteTcpCertificate_PropagatesServerDnsIdentity()
|
||||
{
|
||||
HistorianClientOptions options = BaseOptions(dnsIdentity: "localhost");
|
||||
var (_, historyEndpoint, _, retrievalEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(options);
|
||||
|
||||
DnsEndpointIdentity historyIdentity = Assert.IsType<DnsEndpointIdentity>(historyEndpoint.Identity);
|
||||
Assert.Equal("localhost", historyIdentity.IdentityClaim.Resource);
|
||||
// The Retrieval endpoint uses plain MdasNetTcp without TLS — no DNS identity needed.
|
||||
Assert.Null(retrievalEndpoint.Identity);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
using System.Runtime.Versioning;
|
||||
using ZB.MOM.WW.SPHistorianClient;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
/// <remarks>
|
||||
/// Probes the SDK-direct WCF revision-write path (D2 new path). Calls
|
||||
/// <c>AddNonStreamValuesBegin</c> through <see cref="HistorianWcfRevisionOrchestrator"/>
|
||||
/// against the live local Historian and surfaces what the server returns. The
|
||||
/// underlying native wrapper is gated client-side by err 129 TagNotFoundInCache;
|
||||
/// this test bypasses the wrapper entirely and asks the SERVER directly. Gated on
|
||||
/// HISTORIAN_HOST=localhost; skips otherwise.
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class HistorianWcfRevisionProbeTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public HistorianWcfRevisionProbeTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddNonStreamValuesBegin_ProbeReturnsServerResult()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClientOptions options = new()
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe,
|
||||
};
|
||||
|
||||
HistorianWcfRevisionOrchestrator orchestrator = new(options);
|
||||
HistorianRevisionProbeResult result = await orchestrator.ProbeBeginAsync(CancellationToken.None);
|
||||
|
||||
_output.WriteLine($"OpenSucceeded: {result.OpenSucceeded}");
|
||||
_output.WriteLine($"ClientHandle: {result.ClientHandle}");
|
||||
_output.WriteLine($"StorageSessionId: {result.StorageSessionId}");
|
||||
_output.WriteLine($"TrxInterfaceVersion: {result.TrxInterfaceVersion} (rc={result.TrxInterfaceVersionReturnCode}) ex={result.TrxInterfaceVersionException}");
|
||||
_output.WriteLine($"RTag2Succeeded: {result.RTag2Succeeded} OutHex={result.RTag2OutHex} ErrHex={result.RTag2ErrorHex} Ex={result.RTag2Exception}");
|
||||
_output.WriteLine($"BeginSucceeded: {result.BeginSucceeded}");
|
||||
_output.WriteLine($"BeginTransactionId: {result.BeginTransactionId}");
|
||||
foreach (HistorianRevisionBeginAttempt attempt in result.BeginAttempts)
|
||||
{
|
||||
_output.WriteLine($" attempt[{attempt.HandleLabel}] handle={attempt.HandleSent} ok={attempt.Succeeded} tx={attempt.TransactionId} err={attempt.ErrorHex} ex={attempt.Exception}");
|
||||
}
|
||||
|
||||
Assert.True(result.OpenSucceeded, "Auth chain failed; revision probe never reached the Trx endpoint.");
|
||||
// Don't assert BeginSucceeded — we're surfacing whatever the server says, not requiring success.
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class ProtocolGuardrailTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAtTime_RequiresAuthCredentials()
|
||||
{
|
||||
HistorianClient client = new(new HistorianClientOptions { Host = "localhost", IntegratedSecurity = false });
|
||||
|
||||
ProtocolEvidenceMissingException ex = await Assert.ThrowsAsync<ProtocolEvidenceMissingException>(() =>
|
||||
client.ReadAtTimeAsync("SysTimeSec", [DateTime.UtcNow], CancellationToken.None));
|
||||
|
||||
Assert.Contains("IntegratedSecurity", ex.Operation);
|
||||
}
|
||||
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
using System.Runtime.Versioning;
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using System.Runtime.Versioning;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class TagMetadataDescriptorProbeTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public TagMetadataDescriptorProbeTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeDescriptorsForKnownSampleTags()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_DESCRIPTOR_PROBE_TAGS")
|
||||
?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (sampleTags.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClientOptions options = new()
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
};
|
||||
|
||||
foreach (string tagName in sampleTags)
|
||||
{
|
||||
try
|
||||
{
|
||||
HistorianTagInfoResponse parsed = HistorianWcfTagClient.GetTagInfoForDescriptorProbe(options, tagName);
|
||||
_output.WriteLine($" {tagName,-50} descriptor=0x{Convert.ToHexString(parsed.NativeDataTypeDescriptor)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_output.WriteLine($" {tagName,-50} ERROR: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DumpRawTagInfoBytesForLayoutDecoding()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_RAW_TAGINFO_TAGS") ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (string.IsNullOrWhiteSpace(host) || sampleTags.Length == 0 || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClientOptions options = new() { Host = host, IntegratedSecurity = true };
|
||||
var results = HistorianWcfTagClient.GetTagInfoRawBytesForProbe(options, sampleTags);
|
||||
foreach (var (tag, bytes) in results)
|
||||
{
|
||||
if (bytes is null) { _output.WriteLine($" {tag}: <null>"); continue; }
|
||||
_output.WriteLine($" {tag} ({bytes.Length} bytes): {Convert.ToHexString(bytes)}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumerateAllTagDescriptorsAcrossOneSession()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_DESCRIPTOR_PROBE_TAGS") ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (sampleTags.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClientOptions options = new()
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
};
|
||||
|
||||
IReadOnlyDictionary<string, HistorianTagInfoResponse?> results =
|
||||
HistorianWcfTagClient.GetTagInfosForDescriptorProbe(options, sampleTags);
|
||||
|
||||
// Group by descriptor (hex string) and report counts only — no tag names in output to
|
||||
// avoid leaking customer-tag identifiers.
|
||||
var grouped = results
|
||||
.Where(static kv => kv.Value is not null)
|
||||
.GroupBy(static kv => Convert.ToHexString(kv.Value!.NativeDataTypeDescriptor))
|
||||
.OrderBy(static g => g.Key);
|
||||
_output.WriteLine($"Probed {results.Count} tags ({results.Count(static kv => kv.Value is null)} errors).");
|
||||
foreach (var grp in grouped)
|
||||
{
|
||||
_output.WriteLine($" 0x{grp.Key} count={grp.Count()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class WcfAuthenticationProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void WrapValidateClientCredentialToken_UsesNativeRoundAndLengthEnvelope()
|
||||
{
|
||||
byte[] actual = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken(
|
||||
isFirstRound: true,
|
||||
[0x4E, 0x54, 0x4C, 0x4D]);
|
||||
|
||||
Assert.Equal([0x01, 0x04, 0x00, 0x00, 0x00, 0x4E, 0x54, 0x4C, 0x4D], actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadWrappedValidateClientCredentialToken_ReadsNativeEnvelope()
|
||||
{
|
||||
ValidateClientCredentialToken? actual =
|
||||
HistorianWcfAuthenticationProtocol.TryReadWrappedValidateClientCredentialToken(
|
||||
[0x00, 0x03, 0x00, 0x00, 0x00, 0xAA, 0xBB, 0xCC]);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.False(actual.IsFirstRound);
|
||||
Assert.Equal([0xAA, 0xBB, 0xCC], actual.Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadWrappedValidateClientCredentialToken_RejectsLengthMismatch()
|
||||
{
|
||||
Assert.Null(HistorianWcfAuthenticationProtocol.TryReadWrappedValidateClientCredentialToken(
|
||||
[0x01, 0x04, 0x00, 0x00, 0x00, 0xAA]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadValidateClientCredentialResponse_ReadsContinueFlagAndServerToken()
|
||||
{
|
||||
ValidateClientCredentialResponse? actual =
|
||||
HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(
|
||||
[0x01, 0x11, 0x22, 0x33]);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.True(actual.Continue);
|
||||
Assert.Equal([0x11, 0x22, 0x33], actual.Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadValidateClientCredentialResponse_ReadsTerminalOneByteResponse()
|
||||
{
|
||||
ValidateClientCredentialResponse? actual =
|
||||
HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse([0x00]);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.False(actual.Continue);
|
||||
Assert.Empty(actual.Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadValidateClientCredentialResponse_RejectsEmptyResponse()
|
||||
{
|
||||
Assert.Null(HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse([]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyNativeNtlmNegotiateVersionFlag_MatchesObservedNativeFirstTokenFlag()
|
||||
{
|
||||
byte[] token =
|
||||
[
|
||||
0x4E, 0x54, 0x4C, 0x4D, 0x53, 0x53, 0x50, 0x00,
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0xB7, 0xB2, 0x08, 0xE2
|
||||
];
|
||||
|
||||
bool changed = HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(token);
|
||||
|
||||
Assert.True(changed);
|
||||
Assert.Equal(
|
||||
[
|
||||
0x4E, 0x54, 0x4C, 0x4D, 0x53, 0x53, 0x50, 0x00,
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0xB7, 0xB2, 0x18, 0xE2
|
||||
],
|
||||
token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyNativeNtlmNegotiateVersionFlag_IgnoresNonNtlmNegotiateTokens()
|
||||
{
|
||||
byte[] token = [0x4B, 0x52, 0x42, 0x35];
|
||||
|
||||
bool changed = HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(token);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Equal([0x4B, 0x52, 0x42, 0x35], token);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceModel.Channels;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class WcfBindingFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateMdasNetNamedPipeBinding_WrapsTheInnerEncoderInMdas()
|
||||
{
|
||||
Binding binding = HistorianWcfBindingFactory.CreateMdasNetNamedPipeBinding(TimeSpan.FromSeconds(5));
|
||||
|
||||
BindingElementCollection elements = binding.CreateBindingElements();
|
||||
Assert.Contains(elements, e => e is MdasMessageEncodingBindingElement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMdasNetNamedPipeBinding_AppliesProvidedTimeout()
|
||||
{
|
||||
TimeSpan timeout = TimeSpan.FromSeconds(7);
|
||||
|
||||
Binding binding = HistorianWcfBindingFactory.CreateMdasNetNamedPipeBinding(timeout);
|
||||
|
||||
Assert.Equal(timeout, binding.OpenTimeout);
|
||||
Assert.Equal(timeout, binding.CloseTimeout);
|
||||
Assert.Equal(timeout, binding.SendTimeout);
|
||||
Assert.Equal(timeout, binding.ReceiveTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatePipeEndpointAddress_BuildsNetPipeUri()
|
||||
{
|
||||
var address = HistorianWcfBindingFactory.CreatePipeEndpointAddress("localhost", "Hist");
|
||||
|
||||
Assert.Equal(new Uri("net.pipe://localhost/Hist"), address.Uri);
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class WcfDataQueryProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializerMatchesInstrumentedNativeFullHistoryRequest()
|
||||
{
|
||||
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
|
||||
["OtOpcUaParityTest_001.Counter"],
|
||||
new DateTime(2026, 5, 1, 14, 17, 5, 659, DateTimeKind.Utc).AddTicks(3154),
|
||||
new DateTime(2026, 5, 2, 14, 17, 5, 659, DateTimeKind.Utc).AddTicks(3154),
|
||||
MaxStates: 100,
|
||||
BatchSize: 1,
|
||||
Option: string.Empty));
|
||||
|
||||
byte[] expected = Convert.FromBase64String(
|
||||
"CQACAAAAAAAAAAAAAAAC4ScwddncAQKhkVo+2twBAAAAAAAAAAAAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializerMatchesInstrumentedNativeTimeWeightedAverageRequest()
|
||||
{
|
||||
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
|
||||
["OtOpcUaParityTest_001.Counter"],
|
||||
new DateTime(2026, 5, 1, 14, 29, 2, 223, DateTimeKind.Utc).AddTicks(2955),
|
||||
new DateTime(2026, 5, 2, 14, 29, 2, 223, DateTimeKind.Utc).AddTicks(2955),
|
||||
MaxStates: 100,
|
||||
BatchSize: 3,
|
||||
Option: string.Empty)
|
||||
{
|
||||
QueryType = 5,
|
||||
Resolution = TimeSpan.FromMinutes(1)
|
||||
});
|
||||
|
||||
byte[] expected = Convert.FromBase64String(
|
||||
"CQAFAAAAAAAAAAAAAAB73ULbdtncAXudrAVA2twBAAAAAKPhwUEAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAABg3vt0BQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializerMatchesInstrumentedNativeInterpolatedRequest()
|
||||
{
|
||||
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
|
||||
["OtOpcUaParityTest_001.Counter"],
|
||||
new DateTime(2026, 5, 1, 14, 32, 12, 72, DateTimeKind.Utc).AddTicks(8924),
|
||||
new DateTime(2026, 5, 2, 14, 32, 12, 72, DateTimeKind.Utc).AddTicks(8924),
|
||||
MaxStates: 100,
|
||||
BatchSize: 3,
|
||||
Option: string.Empty)
|
||||
{
|
||||
QueryType = 3,
|
||||
Resolution = TimeSpan.FromMinutes(1)
|
||||
});
|
||||
|
||||
byte[] expected = Convert.FromBase64String(
|
||||
"CQADAAAAAAAAAAAAAABcnWtMd9ncAVxd1XZA2twBAAAAAKPhwUEAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAABg3vt0BQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializerUsesDecompiledEmptyMetadataAndAutoSummaryLayout()
|
||||
{
|
||||
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
|
||||
["T"],
|
||||
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
MaxStates: 100,
|
||||
BatchSize: 1,
|
||||
Option: string.Empty));
|
||||
|
||||
byte[] expectedMiddle =
|
||||
[
|
||||
0x64, 0x00,
|
||||
0x01,
|
||||
0x01, 0x00, 0x00,
|
||||
0x01, 0x00, 0x00,
|
||||
0x01, 0x00, 0x00,
|
||||
0x09, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00
|
||||
];
|
||||
|
||||
AssertContains(expectedMiddle, actual);
|
||||
AssertEndsWith(ExpectedEmptyEndpointAndAutoSummarySuffix(), actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializerWritesPackedCqtiFlagsSeparatelyFromColumnSelectorFlags()
|
||||
{
|
||||
byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest(
|
||||
["T"],
|
||||
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
MaxStates: 100,
|
||||
BatchSize: 1,
|
||||
Option: "NoOption")
|
||||
{
|
||||
InterpolationType = 255,
|
||||
TimestampRule = 1,
|
||||
QualityRule = 0,
|
||||
ColumnSelectorFlags = 0x0000_0000_0003_FFFF
|
||||
});
|
||||
|
||||
int resultBufferOffset = 2 + 4 + 4 + 4 + 8 + 8 + 8 + 4 + 4 + 10 + 4;
|
||||
Assert.Equal([0x00, 0x00, 0x01, 0x00, 0xFF, 0x01], actual[resultBufferOffset..(resultBufferOffset + 6)]);
|
||||
AssertContains([0x01, 0x00, 0xFF, 0xFF, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00], actual);
|
||||
}
|
||||
|
||||
private static byte[] ExpectedEmptyEndpointAndAutoSummarySuffix()
|
||||
{
|
||||
List<byte> expected = [];
|
||||
AppendEmptyEndpoint(expected);
|
||||
AppendEmptyEndpoint(expected);
|
||||
expected.AddRange(new byte[8]);
|
||||
expected.AddRange([0x00, 0x00, 0x00, 0x00]);
|
||||
expected.AddRange([0x00, 0x00, 0x00, 0x00]);
|
||||
expected.AddRange([0x01, 0x00]);
|
||||
expected.AddRange(new byte[16]);
|
||||
expected.AddRange(new byte[5]);
|
||||
expected.AddRange([0x00, 0x00, 0x00, 0x00]);
|
||||
return expected.ToArray();
|
||||
}
|
||||
|
||||
private static void AppendEmptyEndpoint(List<byte> bytes)
|
||||
{
|
||||
bytes.AddRange([0x01, 0x00]);
|
||||
bytes.AddRange([0x00, 0x00, 0x00, 0x00]);
|
||||
bytes.AddRange([0x00, 0x00]);
|
||||
}
|
||||
|
||||
private static void AssertContains(byte[] expected, byte[] actual)
|
||||
{
|
||||
for (int index = 0; index <= actual.Length - expected.Length; index++)
|
||||
{
|
||||
if (actual.AsSpan(index, expected.Length).SequenceEqual(expected))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Fail($"Expected byte sequence {Convert.ToHexString(expected)} was not found.");
|
||||
}
|
||||
|
||||
private static void AssertEndsWith(byte[] expectedSuffix, byte[] actual)
|
||||
{
|
||||
Assert.True(actual.Length >= expectedSuffix.Length);
|
||||
Assert.Equal(expectedSuffix, actual[^expectedSuffix.Length..]);
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
using ZB.MOM.WW.SPHistorianClient.Models;
|
||||
using ZB.MOM.WW.SPHistorianClient.Wcf;
|
||||
|
||||
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||
|
||||
public sealed class WcfDataQueryResultBufferTests
|
||||
{
|
||||
// Captured from artifacts/reverse-engineering/instrumented-openconnection3-correlation/capture.ndjson
|
||||
// Wcf.GetNextQueryResultBuffer2.ResultBytes for a 4-row OtOpcUaParityTest_001.Counter Full read.
|
||||
private static readonly byte[] CapturedResultBytes = Convert.FromBase64String(
|
||||
"CQAEAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAu" +
|
||||
"AEMAbwB1AG4AdABlAHIAAQAAAGvPzFvD2dwBhQAAAPgAAADAAAAAAAAAAAAAAAAAAAAAAABZQAAA" +
|
||||
"AWvPzFvD2dwBAAAAAAAAAAClBtClfAAAAAAAAAAAAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAA" +
|
||||
"YQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAAQAAABDWnAFA2twBAQAA" +
|
||||
"ABgAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAARDWnAFA2twBAAAAAAAAAAAwZOgAAAAAAAEAAAAA" +
|
||||
"AAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMA" +
|
||||
"bwB1AG4AdABlAHIAAQAAAEA6hQJA2twBAQAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUA6" +
|
||||
"hQJA2twBAAAAAAAAAABQwwAAAAAAAAEAAAAAAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQBy" +
|
||||
"AGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAAQAAAJD9hQJA2twBAAAAAPgA" +
|
||||
"AADAAAAAAAAAAAAAAAAAAAAAAABZQAAAAZD9hQJA2twBAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAA");
|
||||
|
||||
private static readonly byte[] TerminalNoMoreData = Convert.FromBase64String("BB4AAAA=");
|
||||
|
||||
[Fact]
|
||||
public void TryParseGetNextQueryResultBufferRows_ParsesFourCanonicalFixtureRows()
|
||||
{
|
||||
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
|
||||
CapturedResultBytes,
|
||||
TerminalNoMoreData,
|
||||
out IReadOnlyList<HistorianSample> rows,
|
||||
out bool hasMoreData);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.False(hasMoreData);
|
||||
Assert.Equal(4, rows.Count);
|
||||
|
||||
Assert.All(rows, r => Assert.Equal("OtOpcUaParityTest_001.Counter", r.TagName));
|
||||
|
||||
HistorianSample row0 = rows[0];
|
||||
Assert.Equal(133, row0.Quality);
|
||||
Assert.Equal(248u, row0.QualityDetail);
|
||||
Assert.Equal(192, row0.OpcQuality);
|
||||
Assert.Equal(0, row0.NumericValue);
|
||||
Assert.Equal(100.0, row0.PercentGood);
|
||||
Assert.Equal(DateTime.FromFileTimeUtc(0x01DCD9C35BCCCF6B), row0.TimestampUtc);
|
||||
|
||||
HistorianSample row3 = rows[3];
|
||||
Assert.Equal(0, row3.Quality);
|
||||
Assert.Equal(248u, row3.QualityDetail);
|
||||
Assert.Equal(192, row3.OpcQuality);
|
||||
Assert.Equal(100.0, row3.PercentGood);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseGetNextQueryResultBufferRows_FlagsContinuationWhenErrorTerminalIsEmpty()
|
||||
{
|
||||
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
|
||||
CapturedResultBytes,
|
||||
errorTerminal: [],
|
||||
out _,
|
||||
out bool hasMoreData);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(hasMoreData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseGetNextQueryResultBufferRows_FlagsContinuationWhenErrorIsNotNoMoreData()
|
||||
{
|
||||
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
|
||||
CapturedResultBytes,
|
||||
errorTerminal: [0x04, 0x01, 0x00, 0x00, 0x00],
|
||||
out _,
|
||||
out bool hasMoreData);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(hasMoreData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseGetNextQueryResultBufferRows_RejectsBufferWithUnsupportedVersion()
|
||||
{
|
||||
byte[] mangled = (byte[])CapturedResultBytes.Clone();
|
||||
mangled[0] = 0x07;
|
||||
|
||||
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
|
||||
mangled,
|
||||
TerminalNoMoreData,
|
||||
out IReadOnlyList<HistorianSample> rows,
|
||||
out _);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Empty(rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseGetNextQueryResultBufferRows_HandlesEmptyResultBuffer()
|
||||
{
|
||||
bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(
|
||||
result: [],
|
||||
TerminalNoMoreData,
|
||||
out IReadOnlyList<HistorianSample> rows,
|
||||
out bool hasMoreData);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.False(hasMoreData);
|
||||
Assert.Empty(rows);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user