diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/HistorianGrpcChannelFactory.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/HistorianGrpcChannelFactory.cs
new file mode 100644
index 0000000..9e7225c
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/HistorianGrpcChannelFactory.cs
@@ -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;
+
+///
+/// Builds a for the 2023 R2 Historian Client Access Point,
+/// replicating the stock Archestra.Historian.GrpcClient.GrpcClientBase.InitializeBase
+/// transport shape: gRPC-Web (binary) over HTTP/1.1, optional TLS with an
+/// untrusted-certificate bypass, and gzip request encoding.
+///
+internal static class HistorianGrpcChannelFactory
+{
+ ///
+ /// Resolves the effective gRPC port: when the caller left
+ /// at the WCF default (32568), the 2023 R2 gRPC default (32565) is substituted; otherwise the
+ /// explicit value is honoured.
+ ///
+ internal static int ResolvePort(HistorianClientOptions options) =>
+ options.Port == HistorianClientOptions.DefaultPort ? HistorianClientOptions.DefaultGrpcPort : options.Port;
+
+ ///
+ /// Builds the channel address. TLS uses https://{ServerDnsIdentity|Host}:{port} (the
+ /// DNS-identity override lets the URL match the server certificate name when connecting by IP);
+ /// plaintext uses http://{Host}:{port}.
+ ///
+ 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;
+}
+
+/// A live gRPC channel plus the per-call metadata header set.
+internal sealed class HistorianGrpcConnection(GrpcChannel channel, Metadata metadata) : IDisposable
+{
+ public GrpcChannel Channel { get; } = channel;
+
+ public Metadata Metadata { get; } = metadata;
+
+ public void Dispose() => Channel.Dispose();
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/HistorianGrpcReadOrchestrator.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/HistorianGrpcReadOrchestrator.cs
new file mode 100644
index 0000000..8fe3f72
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/HistorianGrpcReadOrchestrator.cs
@@ -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;
+
+///
+/// 2023 R2 gRPC read orchestrator. Mirrors over the
+/// gRPC transport: the same native binary buffers travel inside protobuf bytes fields,
+/// and the same serializers/parsers (,
+/// ) 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.
+///
+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 ReadRawAsync(
+ string tag,
+ DateTime startUtc,
+ DateTime endUtc,
+ int maxValues,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ ValidateAuth();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ IReadOnlyList 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 ReadAggregateAsync(
+ string tag,
+ DateTime startUtc,
+ DateTime endUtc,
+ RetrievalMode mode,
+ TimeSpan interval,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ ValidateAuth();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ IReadOnlyList 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> ReadAtTimeAsync(
+ string tag,
+ IReadOnlyList timestampsUtc,
+ CancellationToken cancellationToken)
+ {
+ ValidateAuth();
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.Run>(() => 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 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 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 RunAtTimeChain(string tag, IReadOnlyList timestampsUtc, CancellationToken cancellationToken)
+ {
+ if (timestampsUtc.Count == 0)
+ {
+ return [];
+ }
+
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
+ uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
+
+ List results = new(timestampsUtc.Count);
+ foreach (DateTime ts in timestampsUtc)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ DateTime tsUtc = ts.ToUniversalTime();
+ List 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 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 samples = [];
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ (byte[] resultBuffer, byte[] errorBuffer) = GetNextResultBuffer(retrievalClient, clientHandle, queryHandle, "raw", cancellationToken);
+
+ if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(resultBuffer, errorBuffer, out IReadOnlyList 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 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 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 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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/HistoryService.proto b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/HistoryService.proto
new file mode 100644
index 0000000..5207efe
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/HistoryService.proto
@@ -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);
+}
+
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/RetrievalService.proto b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/RetrievalService.proto
new file mode 100644
index 0000000..8f50c13
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/RetrievalService.proto
@@ -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);
+}
+
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/Status.proto b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/Status.proto
new file mode 100644
index 0000000..4623094
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/Status.proto
@@ -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;
+}
+
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/StatusService.proto b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/StatusService.proto
new file mode 100644
index 0000000..6f98388
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/StatusService.proto
@@ -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);
+}
+
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/StorageService.proto b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/StorageService.proto
new file mode 100644
index 0000000..352d149
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/StorageService.proto
@@ -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);
+}
+
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/TransactionService.proto b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/TransactionService.proto
new file mode 100644
index 0000000..2c0e02c
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/TransactionService.proto
@@ -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);
+}
+
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/HistorianClient.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/HistorianClient.cs
new file mode 100644
index 0000000..3727fa3
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/HistorianClient.cs
@@ -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 ProbeAsync(CancellationToken cancellationToken = default)
+ {
+ return await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
+ }
+
+ public IAsyncEnumerable 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 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> ReadAtTimeAsync(
+ string tag,
+ IReadOnlyList timestampsUtc,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tag);
+ ArgumentNullException.ThrowIfNull(timestampsUtc);
+
+ if (timestampsUtc.Count == 0)
+ {
+ return Task.FromResult>(Array.Empty());
+ }
+
+ return _protocol.ReadAtTimeAsync(tag, timestampsUtc, cancellationToken);
+ }
+
+ public IAsyncEnumerable 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 ReadEventsAsync(
+ DateTime startUtc,
+ DateTime endUtc,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateTimeRange(startUtc, endUtc);
+ return _protocol.ReadEventsAsync(startUtc, endUtc, cancellationToken);
+ }
+
+ public IAsyncEnumerable BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(filter);
+ return HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken);
+ }
+
+ public Task GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tag);
+ return HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
+ }
+
+ public Task GetConnectionStatusAsync(CancellationToken cancellationToken = default)
+ {
+ return _protocol.GetConnectionStatusAsync(cancellationToken);
+ }
+
+ public Task GetStoreForwardStatusAsync(CancellationToken cancellationToken = default)
+ {
+ return _protocol.GetStoreForwardStatusAsync(cancellationToken);
+ }
+
+ public Task GetSystemParameterAsync(string name, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ return _protocol.GetSystemParameterAsync(name, cancellationToken);
+ }
+
+ ///
+ /// Creates or updates the named tag in the Historian Runtime database via
+ /// EnsureTags2. Currently only is
+ /// live-verified. Note: writing data values to the new tag (via a separate
+ /// AddStreamedValue/AddS2 path) is NOT supported by the SDK — see
+ /// docs/plans/write-commands-reverse-engineering.md for the architectural
+ /// finding.
+ ///
+ public Task EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(definition);
+ return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
+ }
+
+ ///
+ /// Deletes the named tag via DeleteTags. **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 Runtime.dbo.Tag). 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.
+ ///
+ public Task 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.");
+ }
+ }
+
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/HistorianClientOptions.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/HistorianClientOptions.cs
new file mode 100644
index 0000000..817d6c7
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/HistorianClientOptions.cs
@@ -0,0 +1,65 @@
+using ZB.MOM.WW.SPHistorianClient.Models;
+
+namespace ZB.MOM.WW.SPHistorianClient;
+
+public sealed class HistorianClientOptions
+{
+ public const int DefaultPort = 32568;
+
+ /// Default TCP port of the 2023 R2 Historian Client Access Point gRPC endpoint.
+ 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";
+
+ ///
+ /// 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 /HistCert 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.
+ ///
+ public bool AllowUntrustedServerCertificate { get; init; }
+
+ ///
+ /// Overrides the expected DNS identity in the endpoint address — set this to
+ /// whatever DNS name the server's certificate actually claims (often
+ /// localhost 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.
+ ///
+ public string? ServerDnsIdentity { get; init; }
+
+ ///
+ /// For : when true the channel uses TLS
+ /// (https://); when false it uses plaintext (http://). Matches the stock
+ /// 2023 R2 client's securedConnection flag. The TLS host is taken from
+ /// when set (to match the server certificate's name),
+ /// otherwise . When is
+ /// true the server certificate chain is not validated. Default false.
+ ///
+ public bool GrpcUseTls { get; init; }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/HistorianTransport.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/HistorianTransport.cs
new file mode 100644
index 0000000..ef819b7
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/HistorianTransport.cs
@@ -0,0 +1,15 @@
+namespace ZB.MOM.WW.SPHistorianClient;
+
+public enum HistorianTransport
+{
+ LocalPipe = 0,
+ RemoteTcpIntegrated = 1,
+ RemoteTcpCertificate = 2,
+
+ ///
+ /// 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 bytes fields. See Grpc/HistorianGrpcReadOrchestrator.
+ ///
+ RemoteGrpc = 3
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/AggregationType.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/AggregationType.cs
new file mode 100644
index 0000000..fbe9278
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/AggregationType.cs
@@ -0,0 +1,15 @@
+namespace ZB.MOM.WW.SPHistorianClient.Models;
+
+public enum AggregationType
+{
+ Minimum,
+ Maximum,
+ Average,
+ Total,
+ Percent,
+ MinContained,
+ MaxContained,
+ TotalContained,
+ AverageContained,
+ PercentContained
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianAggregateSample.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianAggregateSample.cs
new file mode 100644
index 0000000..62a883a
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianAggregateSample.cs
@@ -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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianBlock.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianBlock.cs
new file mode 100644
index 0000000..6d681ba
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianBlock.cs
@@ -0,0 +1,7 @@
+namespace ZB.MOM.WW.SPHistorianClient.Models;
+
+public sealed record HistorianBlock(
+ string TagName,
+ DateTime StartTimeUtc,
+ DateTime EndTimeUtc,
+ IReadOnlyList Samples);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianConnectionKind.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianConnectionKind.cs
new file mode 100644
index 0000000..d75e668
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianConnectionKind.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace ZB.MOM.WW.SPHistorianClient.Models;
+
+[Flags]
+public enum HistorianConnectionKind
+{
+ Process = 1,
+ Event = 2
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianConnectionStatus.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianConnectionStatus.cs
new file mode 100644
index 0000000..c025c9e
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianConnectionStatus.cs
@@ -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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianDataType.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianDataType.cs
new file mode 100644
index 0000000..e9e4e32
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianDataType.cs
@@ -0,0 +1,37 @@
+namespace ZB.MOM.WW.SPHistorianClient.Models;
+
+///
+/// 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.
+///
+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,
+
+ /// 1-byte unsigned integer (native code 0x08).
+ UInt1 = 14,
+
+ /// 8-byte signed integer (native code 0x19).
+ Int8 = 15,
+
+ /// 8-byte unsigned integer (native code 0x39).
+ UInt8 = 16,
+
+ /// 16-byte GUID (native code 0x10, matches CDataType.IsGuid).
+ Guid = 17,
+
+ /// Windows FILETIME (8 bytes, 100-ns ticks since 1601-01-01 UTC; native code 0x18, matches CDataType.IsFileTime).
+ FileTime = 18
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianDataValue.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianDataValue.cs
new file mode 100644
index 0000000..22fcadf
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianDataValue.cs
@@ -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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianEvent.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianEvent.cs
new file mode 100644
index 0000000..f0bbbd0
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianEvent.cs
@@ -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 Properties);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianSample.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianSample.cs
new file mode 100644
index 0000000..3e9ab9a
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianSample.cs
@@ -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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianStorageType.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianStorageType.cs
new file mode 100644
index 0000000..0f45d11
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianStorageType.cs
@@ -0,0 +1,19 @@
+namespace ZB.MOM.WW.SPHistorianClient.Models;
+
+///
+/// Storage strategy for historized samples. Maps to Tag.StorageType in the
+/// Runtime DB. Values match the captured native enum and the server-persisted
+/// integer column.
+///
+public enum HistorianStorageType
+{
+ ///
+ /// Sample on a fixed cadence (see HistorianTagDefinition.StorageRateMs).
+ ///
+ Cyclic = 1,
+
+ ///
+ /// Sample only on value change (with optional value/time/rate deadbands).
+ ///
+ Delta = 2,
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianStoreForwardStatus.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianStoreForwardStatus.cs
new file mode 100644
index 0000000..041b7c1
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianStoreForwardStatus.cs
@@ -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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianTagDefinition.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianTagDefinition.cs
new file mode 100644
index 0000000..338883b
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianTagDefinition.cs
@@ -0,0 +1,84 @@
+namespace ZB.MOM.WW.SPHistorianClient.Models;
+
+///
+/// Input model for . 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 HistorianTagWriteProtocol).
+///
+/// Semantics: EnsureTagAsync is an upsert. Calling it twice on the same
+/// 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).
+///
+public sealed record HistorianTagDefinition
+{
+ /// Tag name (ASCII; up to 255 chars per server limit).
+ public required string TagName { get; init; }
+
+ /// Tag description (free text; up to 255 chars).
+ public string? Description { get; init; }
+
+ /// Engineering unit label (e.g. "Seconds", "kPa"). Required for analog tags.
+ public string? EngineeringUnit { get; init; }
+
+ /// Native data type. Float, Double, Int2, Int4, UInt4 are live-verified.
+ public HistorianDataType DataType { get; init; } = HistorianDataType.Float;
+
+ /// Engineering-units lower bound. Default 0.
+ public double MinEU { get; init; }
+
+ /// Engineering-units upper bound. Default 100.
+ public double MaxEU { get; init; } = 100.0;
+
+ ///
+ /// Raw lower bound (pre-scaling). Default 0. Persisted distinctly only when
+ /// is true; with ApplyScaling=false the server mirrors
+ /// this to MinEU on EnsureTags2 (verified 2026-05-04 against both native and
+ /// managed clients).
+ ///
+ public double MinRaw { get; init; }
+
+ ///
+ /// Raw upper bound (pre-scaling). Default 100. See for the
+ /// ApplyScaling caveat.
+ ///
+ public double MaxRaw { get; init; } = 100.0;
+
+ ///
+ /// When true, the server persists / as
+ /// distinct values from / and sets
+ /// AnalogTag.Scaling = 1. When false (default), the server mirrors MinRaw
+ /// to MinEU and MaxRaw to MaxEU and sets AnalogTag.Scaling = 0.
+ ///
+ public bool ApplyScaling { get; init; }
+
+ ///
+ /// Storage rate in milliseconds. Default 1000ms. The server only accepts
+ /// quantized values (observed valid set: 1000, 5000, 10000, 60000, 300000) —
+ /// non-quantized values cause to
+ /// return false.
+ ///
+ public uint StorageRateMs { get; init; } = 1000u;
+
+ ///
+ /// Storage strategy. Default samples
+ /// on the configured cadence.
+ /// samples only on value change. The server persists this to Tag.StorageType
+ /// (Cyclic = 1, Delta = 2).
+ ///
+ public HistorianStorageType StorageType { get; init; } = HistorianStorageType.Cyclic;
+
+ ///
+ /// 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 IntegralDivisor on
+ /// EngineeringUnit (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 EngineeringUnit.IntegralDivisor for the test
+ /// EU. Exposed for completeness and forward-compatibility; check your server's
+ /// behavior before relying on it.
+ ///
+ public double IntegralDivisor { get; init; } = 1.0;
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianTagMetadata.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianTagMetadata.cs
new file mode 100644
index 0000000..61e5e7e
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/HistorianTagMetadata.cs
@@ -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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/InterpolationType.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/InterpolationType.cs
new file mode 100644
index 0000000..0f3365d
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/InterpolationType.cs
@@ -0,0 +1,9 @@
+namespace ZB.MOM.WW.SPHistorianClient.Models;
+
+public enum InterpolationType
+{
+ StairStep = 0,
+ Linear = 1,
+ SystemDefault = 254,
+ None = 255
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/QualityRule.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/QualityRule.cs
new file mode 100644
index 0000000..9e2441a
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/QualityRule.cs
@@ -0,0 +1,9 @@
+namespace ZB.MOM.WW.SPHistorianClient.Models;
+
+public enum QualityRule
+{
+ Extended,
+ Good,
+ None,
+ Optimistic
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/RetrievalMode.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/RetrievalMode.cs
new file mode 100644
index 0000000..25b5602
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/RetrievalMode.cs
@@ -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
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/TimestampRule.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/TimestampRule.cs
new file mode 100644
index 0000000..d623c45
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/TimestampRule.cs
@@ -0,0 +1,8 @@
+namespace ZB.MOM.WW.SPHistorianClient.Models;
+
+public enum TimestampRule
+{
+ Start,
+ End,
+ None
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/ValueSelector.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/ValueSelector.cs
new file mode 100644
index 0000000..dbef2ed
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Models/ValueSelector.cs
@@ -0,0 +1,13 @@
+namespace ZB.MOM.WW.SPHistorianClient.Models;
+
+public enum ValueSelector
+{
+ Auto = 1,
+ First,
+ Last,
+ Integral,
+ StandardDeviation,
+ Minimum,
+ Maximum,
+ Average
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/FrameFormatException.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/FrameFormatException.cs
new file mode 100644
index 0000000..dd21daa
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/FrameFormatException.cs
@@ -0,0 +1,9 @@
+namespace ZB.MOM.WW.SPHistorianClient.Protocol;
+
+internal sealed class FrameFormatException : Exception
+{
+ public FrameFormatException(string message)
+ : base(message)
+ {
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/Historian2020ProtocolDialect.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/Historian2020ProtocolDialect.cs
new file mode 100644
index 0000000..4b2a6e4
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/Historian2020ProtocolDialect.cs
@@ -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 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 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> ReadAtTimeAsync(string tag, IReadOnlyList timestampsUtc, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return UseGrpc
+ ? new HistorianGrpcReadOrchestrator(_options).ReadAtTimeAsync(tag, timestampsUtc, cancellationToken)
+ : new HistorianWcfReadOrchestrator(_options).ReadAtTimeAsync(tag, timestampsUtc, cancellationToken);
+ }
+
+ public IAsyncEnumerable ReadBlocksAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
+ {
+ return Missing("StartBlockRetrievalQuery", cancellationToken);
+ }
+
+ public IAsyncEnumerable ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
+ {
+ HistorianWcfEventOrchestrator orchestrator = new(_options);
+ return orchestrator.ReadEventsAsync(startUtc, endUtc, cancellationToken);
+ }
+
+ public Task GetConnectionStatusAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken);
+ }
+
+ public Task GetStoreForwardStatusAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Wcf.HistorianWcfStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken);
+ }
+
+ public Task GetSystemParameterAsync(string name, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ return Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
+ }
+
+ private static async IAsyncEnumerable Missing(
+ 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
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianBinaryPrimitives.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianBinaryPrimitives.cs
new file mode 100644
index 0000000..4131edb
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianBinaryPrimitives.cs
@@ -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 buffer = stackalloc byte[sizeof(ushort)];
+ BinaryPrimitives.WriteUInt16LittleEndian(buffer, value);
+ stream.Write(buffer);
+ }
+
+ public static void WriteUInt32LittleEndian(Stream stream, uint value)
+ {
+ Span buffer = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
+ stream.Write(buffer);
+ }
+
+ public static void WriteUInt64LittleEndian(Stream stream, ulong value)
+ {
+ Span 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);
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianConnection.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianConnection.cs
new file mode 100644
index 0000000..802e158
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianConnection.cs
@@ -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 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 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;
+ }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianFrame.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianFrame.cs
new file mode 100644
index 0000000..9621993
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianFrame.cs
@@ -0,0 +1,6 @@
+namespace ZB.MOM.WW.SPHistorianClient.Protocol;
+
+internal readonly record struct HistorianFrame(
+ HistorianMessageType MessageType,
+ uint CorrelationId,
+ ReadOnlyMemory Payload);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianFrameReader.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianFrameReader.cs
new file mode 100644
index 0000000..5db0c0e
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianFrameReader.cs
@@ -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 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 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;
+ }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianFrameWriter.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianFrameWriter.cs
new file mode 100644
index 0000000..6333b7f
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianFrameWriter.cs
@@ -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 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();
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianMessageType.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianMessageType.cs
new file mode 100644
index 0000000..b385ccd
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianMessageType.cs
@@ -0,0 +1,6 @@
+namespace ZB.MOM.WW.SPHistorianClient.Protocol;
+
+internal enum HistorianMessageType : ushort
+{
+ Unknown = 0
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianProtocolFacts.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianProtocolFacts.cs
new file mode 100644
index 0000000..08952e8
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Protocol/HistorianProtocolFacts.cs
@@ -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";
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/ProtocolEvidenceMissingException.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/ProtocolEvidenceMissingException.cs
new file mode 100644
index 0000000..27dac97
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/ProtocolEvidenceMissingException.cs
@@ -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; }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/ProtocolNotImplementedException.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/ProtocolNotImplementedException.cs
new file mode 100644
index 0000000..6098a75
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/ProtocolNotImplementedException.cs
@@ -0,0 +1,9 @@
+namespace ZB.MOM.WW.SPHistorianClient;
+
+public sealed class ProtocolNotImplementedException : NotImplementedException
+{
+ public ProtocolNotImplementedException(string message)
+ : base(message)
+ {
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Transport/IHistorianTransport.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Transport/IHistorianTransport.cs
new file mode 100644
index 0000000..3ba0507
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Transport/IHistorianTransport.cs
@@ -0,0 +1,10 @@
+namespace ZB.MOM.WW.SPHistorianClient.Transport;
+
+internal interface IHistorianTransport : IAsyncDisposable
+{
+ ValueTask ConnectAsync(HistorianClientOptions options, CancellationToken cancellationToken);
+
+ ValueTask SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken);
+
+ ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Transport/IHistorianTransportFactory.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Transport/IHistorianTransportFactory.cs
new file mode 100644
index 0000000..e942c45
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Transport/IHistorianTransportFactory.cs
@@ -0,0 +1,6 @@
+namespace ZB.MOM.WW.SPHistorianClient.Transport;
+
+internal interface IHistorianTransportFactory
+{
+ IHistorianTransport Create();
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Transport/TcpHistorianTransport.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Transport/TcpHistorianTransport.cs
new file mode 100644
index 0000000..088e958
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Transport/TcpHistorianTransport.cs
@@ -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 payload, CancellationToken cancellationToken)
+ {
+ if (_stream is null)
+ {
+ throw new InvalidOperationException("Transport is not connected.");
+ }
+
+ await _stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask ReceiveAsync(Memory 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();
+ }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IHistoryServiceContract.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IHistoryServiceContract.cs
new file mode 100644
index 0000000..80d9cf8
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IHistoryServiceContract.cs
@@ -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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IHistoryServiceContract2.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IHistoryServiceContract2.cs
new file mode 100644
index 0000000..b1d99f4
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IHistoryServiceContract2.cs
@@ -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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract.cs
new file mode 100644
index 0000000..7b469db
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract.cs
@@ -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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract2.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract2.cs
new file mode 100644
index 0000000..c8c8884
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract2.cs
@@ -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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract3.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract3.cs
new file mode 100644
index 0000000..48568fc
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract3.cs
@@ -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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract4.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract4.cs
new file mode 100644
index 0000000..923e29f
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IRetrievalServiceContract4.cs
@@ -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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IStatusServiceContract.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IStatusServiceContract.cs
new file mode 100644
index 0000000..12017a0
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IStatusServiceContract.cs
@@ -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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IStatusServiceContract2.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IStatusServiceContract2.cs
new file mode 100644
index 0000000..8cecc78
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IStatusServiceContract2.cs
@@ -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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IStorageServiceContract.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IStorageServiceContract.cs
new file mode 100644
index 0000000..d49d0da
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/IStorageServiceContract.cs
@@ -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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/ITransactionServiceContract.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/ITransactionServiceContract.cs
new file mode 100644
index 0000000..492e9d6
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/Contracts/ITransactionServiceContract.cs
@@ -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);
+}
+
+///
+/// V2 surface — discovered by inspecting CHistoryConnectionWCF.AddNonStreamValuesBegin's
+/// IL (token 0x06004051), which calls
+/// ITransactionServiceContract2::AddNonStreamValuesBegin2(string, ref string, ref byte[])
+/// 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.
+///
+[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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianAddTagsProtocol.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianAddTagsProtocol.cs
new file mode 100644
index 0000000..384363a
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianAddTagsProtocol.cs
@@ -0,0 +1,94 @@
+using System.Text;
+
+namespace ZB.MOM.WW.SPHistorianClient.Wcf;
+
+///
+/// CTagMetadata serialiser for the CM_EVENT default-event-tag registration that the AVEVA
+/// native wrapper performs via IHistoryServiceContract2.EnsureTags2 (WCF op
+/// EnsT2) before any event read can return rows. The action URI on the wire is
+/// aa/Hist/EnsT2, not the previously-suspected aa/Hist/AddT. Layout
+/// captured byte-for-byte from a successful native event read via the
+/// instrument-wcf-writemessage IL-rewrite tooling on
+/// aahMDASEncoder.ClientMessageEncoder.WriteMessage:
+///
+///
+/// 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)
+///
+///
+/// Earlier probe attempts via the (wrong) AddT WCF op + a payload with the
+/// (wrong) trailer order returned server failures. Routing through EnsT2 with
+/// this exact byte layout is the path the native wrapper uses.
+///
+internal static class HistorianAddTagsProtocol
+{
+ public static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321");
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianDataQueryProtocol.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianDataQueryProtocol.cs
new file mode 100644
index 0000000..b7a5fa8
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianDataQueryProtocol.cs
@@ -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;
+
+ ///
+ /// 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
+ /// 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.
+ ///
+ public static bool TryParseGetNextQueryResultBufferRows(
+ ReadOnlySpan result,
+ ReadOnlySpan errorTerminal,
+ out IReadOnlyList 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 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 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public static bool TryParseGetNextQueryResultBufferAggregateRows(
+ ReadOnlySpan result,
+ ReadOnlySpan errorTerminal,
+ Models.RetrievalMode mode,
+ TimeSpan resolution,
+ out IReadOnlyList 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 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 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 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 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 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 SliceByTagNames { get; init; } = [];
+ public uint TimeoutQueryProcessingMilliseconds { get; init; }
+ public uint MaxQueryMemoryConsumptionInMb { get; init; }
+}
+
+internal sealed record HistorianRedundantEndpoint(string EndpointName, IReadOnlyList Endpoints)
+{
+ public static HistorianRedundantEndpoint Empty { get; } = new(string.Empty, []);
+}
+
+internal sealed record HistorianEndpoint(string NodeName, string PipeName);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianEventQueryProtocol.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianEventQueryProtocol.cs
new file mode 100644
index 0000000..ddd7bf9
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianEventQueryProtocol.cs
@@ -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 CreateStartEventQueryAttempts(DateTime startUtc, DateTime endUtc, uint eventCount)
+ {
+ List 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 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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianEventRowProtocol.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianEventRowProtocol.cs
new file mode 100644
index 0000000..350d1c0
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianEventRowProtocol.cs
@@ -0,0 +1,255 @@
+using System.Buffers.Binary;
+using System.Text;
+using ZB.MOM.WW.SPHistorianClient.Models;
+
+namespace ZB.MOM.WW.SPHistorianClient.Wcf;
+
+///
+/// Parser for the version-9 event-row buffer the Historian server returns from
+/// /Retr/GetNextEventQueryResultBuffer.pResultBuff. Wire shape decoded from a captured
+/// native event read (instrument-wcf-readmessage record 24, two rows for Alarm.Set + Alarm.Clear):
+///
+///
+/// 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
+/// }
+/// }
+/// }
+///
+///
+/// Compact ASCII string: 0x09 LEN 0x00 LEN×ASCII bytes (same encoding as
+/// CTagMetadata strings).
+///
+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 Parse(ReadOnlySpan 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 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 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 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 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 properties, string key) =>
+ properties.TryGetValue(key, out object? value) && value is Guid g ? g : null;
+
+ private static DateTime? TryGetFiletime(Dictionary properties, string key) =>
+ properties.TryGetValue(key, out object? value) && value is DateTime dt ? dt : null;
+
+ private static string? TryGetString(Dictionary properties, string key) =>
+ properties.TryGetValue(key, out object? value) && value is string s ? s : null;
+
+ private static int? TryGetInt32(Dictionary properties, string key) =>
+ properties.TryGetValue(key, out object? value) && value is int i ? i : null;
+
+ ///
+ /// Compact ASCII string encoding: 0x09 LEN 0x00 LEN×ASCII bytes.
+ ///
+ private static bool TryReadCompactAsciiString(ReadOnlySpan 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;
+ }
+
+ ///
+ /// Value encoding: typeMarker(1) + length(1) + status(1) + length×value bytes.
+ /// Decodes the value by typeMarker; unknown markers preserve the raw bytes as a
+ /// in the property bag.
+ ///
+ private static bool TryReadValue(ReadOnlySpan 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 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 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));
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianNativeHandshake.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianNativeHandshake.cs
new file mode 100644
index 0000000..1518021
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianNativeHandshake.cs
@@ -0,0 +1,165 @@
+using System.Buffers.Binary;
+using System.Diagnostics;
+
+namespace ZB.MOM.WW.SPHistorianClient.Wcf;
+
+///
+/// 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
+/// () and the 2023 R2 gRPC path
+/// (Grpc.HistorianGrpcReadOrchestrator). The byte payloads are identical across
+/// transports — only the envelope (WCF operation vs gRPC method) differs.
+///
+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;
+
+ /// Result of one transport-level credential-token exchange.
+ internal readonly record struct TokenExchangeResult(bool Success, byte[] ServerOutput, byte[] Error);
+
+ ///
+ /// Performs a single credential-token round on the wire. is the
+ /// upper-case context-key GUID, is the AVEVA-wrapped SSPI
+ /// token (round byte + length + token). The WCF path maps this to
+ /// Hist.ValidateClientCredential; the gRPC path maps it to
+ /// HistoryService.ExchangeKey (the renamed handshake op).
+ ///
+ internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round);
+
+ ///
+ /// Drives the SSPI/NTLM negotiate loop against the supplied
+ /// delegate until the server signals terminal success. Mirrors the native two-round
+ /// (69→239, 93→1) sequence.
+ ///
+ 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.");
+ }
+
+ ///
+ /// Builds the native OpenConnection3 (Open2) version-6 request buffer. Identical bytes are
+ /// sent over WCF (Hist.OpenConnection2) and gRPC
+ /// (HistoryService.OpenConnection.btConnectionRequest).
+ ///
+ 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]);
+ }
+
+ ///
+ /// Decodes the OpenConnection response blob: byte 0 = protocol version, bytes 1..4 =
+ /// transient /Retr client handle (UInt32 LE), bytes 5..20 = storage session GUID.
+ ///
+ public static (uint ClientHandle, Guid StorageSessionId) ParseOpenConnectionResponse(ReadOnlySpan 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;
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianOpen2Protocol.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianOpen2Protocol.cs
new file mode 100644
index 0000000..c809772
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianOpen2Protocol.cs
@@ -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 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 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 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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianSspiClient.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianSspiClient.cs
new file mode 100644
index 0000000..2c6fba0
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianSspiClient.cs
@@ -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;
+
+///
+/// Cross-platform Negotiate / NTLM token producer for the Historian's `Hist.ValCl`
+/// authentication exchange. Uses under the hood
+/// (Windows: SSPI; Linux/macOS: GSSAPI via libgssapi_krb5 / gss-ntlmssp).
+///
+/// The native AVEVA wrapper passes specific request flags to
+/// InitializeSecurityContextW: IDENTIFY | CONNECTION | CONFIDENTIALITY |
+/// SEQUENCE_DETECT | REPLAY_DETECT 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. RequiredProtectionLevel.EncryptAndSign in
+/// NegotiateAuthentication implicitly requests SEQUENCE + REPLAY +
+/// CONFIDENTIALITY, and AllowedImpersonationLevel = Identification 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 HistorianSspiClientTests — they document the
+/// captured native flag values rather than driving the underlying API today.
+///
+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,
+ });
+ }
+
+ ///
+ /// 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
+ /// .
+ ///
+ 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,
+ });
+ }
+
+ /// Internal accessor for tests; returns the request flag bitmask the next Next call corresponds to.
+ 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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianStatusProtocol.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianStatusProtocol.cs
new file mode 100644
index 0000000..eee2e77
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianStatusProtocol.cs
@@ -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 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;
+ }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianTagQueryProtocol.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianTagQueryProtocol.cs
new file mode 100644
index 0000000..bec78d1
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianTagQueryProtocol.cs
@@ -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 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 ParseGetTagInfoResponse(ReadOnlySpan 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 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 response)
+ {
+ int cursor = 0;
+ return ParseTagInfoRecord(response, ref cursor);
+ }
+
+ public static IReadOnlyList ParseGetLikeTagNamesResponse(ReadOnlySpan 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 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 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 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 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 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 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 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 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 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 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 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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianTagWriteProtocol.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianTagWriteProtocol.cs
new file mode 100644
index 0000000..71085f2
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianTagWriteProtocol.cs
@@ -0,0 +1,242 @@
+using System.Text;
+
+namespace ZB.MOM.WW.SPHistorianClient.Wcf;
+
+///
+/// Serializers for the EnsT2 (CTagMetadata) and DelT (tag-name list) write paths.
+/// Decoded from native captures landed in
+/// artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/bothmessage-write-with-delt-latest.ndjson
+/// — see docs/plans/write-commands-reverse-engineering.md Phase 2 findings.
+///
+/// Per the captured analog CTagMetadata, the layout is:
+///
+/// 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
+///
+/// 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.
+///
+internal static class HistorianTagWriteProtocol
+{
+ private const byte CompactAsciiMarker = 0x09;
+
+ ///
+ /// 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).
+ ///
+ 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,
+ ];
+
+ ///
+ /// 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 MapDataType for the read path.
+ ///
+ 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];
+ /// Compact "use defaults" scaling marker — emitted when MinEU/MaxEU/MinRaw/MaxRaw are 0/100/0/100.
+ private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
+ /// Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.
+ 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;
+
+ ///
+ /// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float,
+ /// Double, Int2, Int4, UInt4 — see 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.
+ ///
+ /// Tag name (ASCII).
+ /// Tag description (ASCII; null/empty allowed).
+ /// EU label (ASCII; null/empty allowed).
+ /// Native data type — Float by default for backward compat.
+ /// DateCreated FILETIME (caller passes ).
+ /// Engineering-units lower bound.
+ /// Engineering-units upper bound.
+ /// Raw lower bound.
+ /// Raw upper bound.
+ /// StorageRate in milliseconds.
+ 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();
+ }
+
+ ///
+ /// Serializes the tagNames byte buffer for the DelT (DeleteTags) WCF op.
+ /// Decoded layout from a captured DelT request:
+ ///
+ /// ushort header1 = 0x6751
+ /// ushort header2 = 1
+ /// uint32 tagCount
+ /// for each tag: uint32 charCount + charCount × UTF-16 LE chars
+ ///
+ ///
+ public static byte[] SerializeDeleteTagNames(IReadOnlyList 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();
+ }
+
+ /// Compact ASCII string: 0x09 + UInt16 byteLen + LEN ASCII bytes.
+ 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);
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfAuthChainHelper.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfAuthChainHelper.cs
new file mode 100644
index 0000000..99ededf
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfAuthChainHelper.cs
@@ -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;
+ ///
+ /// 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.
+ ///
+ public const uint NativeIntegratedWriteEnabledConnectionMode = 0x401;
+
+ ///
+ /// 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.
+ ///
+ public static uint OpenAuthenticatedConnection(
+ HistorianClientOptions options,
+ Binding historyBinding,
+ EndpointAddress historyEndpoint,
+ Guid contextKey,
+ CancellationToken cancellationToken,
+ uint connectionMode = NativeIntegratedReadOnlyConnectionMode,
+ Action? additionalSetup = null)
+ {
+ ChannelFactory 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(ChannelFactory factory)
+ {
+ try
+ {
+ if (factory.State == CommunicationState.Faulted) factory.Abort();
+ else factory.Close();
+ }
+ catch { try { factory.Abort(); } catch { } }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfAuthenticationProtocol.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfAuthenticationProtocol.cs
new file mode 100644
index 0000000..e096b44
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfAuthenticationProtocol.cs
@@ -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 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 token)
+ {
+ ReadOnlySpan 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 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 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);
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfBindingFactory.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfBindingFactory.cs
new file mode 100644
index 0000000..90b0b70
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfBindingFactory.cs
@@ -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}");
+ }
+
+ ///
+ /// 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 directly when the calling
+ /// code may run under any transport.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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 — auxiliaries don't repeat the Windows-
+ /// transport-security upgrade that the History service negotiates; the established session
+ /// authenticates the client already.
+ ///
+ // 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.")
+ };
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfClientCredentialsHelper.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfClientCredentialsHelper.cs
new file mode 100644
index 0000000..8534ea8
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfClientCredentialsHelper.cs
@@ -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;
+
+///
+/// Centralizes per-channel-factory credentials configuration that's not bound to a
+/// single binding type. Today this covers ServerCertificateValidation for the
+/// cert-transport binding when callers opt into .
+/// Apply at every ChannelFactory<T> instantiation point in the WCF layer.
+///
+internal static class HistorianWcfClientCredentialsHelper
+{
+ public static void Configure(ChannelFactory 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) { }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfEventOrchestrator.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfEventOrchestrator.cs
new file mode 100644
index 0000000..6915d7d
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfEventOrchestrator.cs
@@ -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;
+
+///
+/// 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
+/// diagnostic so a follow-up capture can decode the wire shape.
+///
+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;
+
+ ///
+ /// 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).
+ ///
+ 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));
+ }
+
+ /// Diagnostic: length of the most recent event-row result buffer the server sent.
+ public int LastResultBufferLength { get; private set; }
+
+ /// Diagnostic: type+code description of the most recent error/terminal buffer.
+ public string LastErrorBufferDescription { get; private set; } = string.Empty;
+
+ /// Diagnostic: handle string passed to EnsT2.
+ public static string LastEnsT2Handle { get; private set; } = string.Empty;
+
+ /// Diagnostic: SHA256 of the CTagMetadata payload sent to EnsT2.
+ public static string LastEnsT2PayloadSha256 { get; private set; } = string.Empty;
+
+ /// Diagnostic: native return code from the prerequisite UpdC3 call.
+ public static uint LastUpdC3ReturnCode { get; private set; }
+
+ /// Diagnostic: native return code from the prerequisite RTag2 call.
+ public static uint LastRTag2ReturnCode { get; private set; }
+
+ public async IAsyncEnumerable 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 events = await Task.Run(
+ () => RunEventChain(startUtc, endUtc, cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+
+ foreach (HistorianEvent evt in events)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return evt;
+ }
+ }
+
+ private List 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 RunEventQuery(
+ Binding binding,
+ EndpointAddress retrievalEndpoint,
+ uint clientHandle,
+ DateTime startUtc,
+ DateTime endUtc,
+ CancellationToken cancellationToken)
+ {
+ ChannelFactory 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 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 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);
+ }
+ }
+
+ /// Diagnostic: native return code from the last AddT(CM_EVENT) call.
+ public static uint LastAddReturnCode { get; private set; }
+
+ /// Diagnostic: byte length of the AddT response output buffer.
+ public static int LastAddOutputLength { get; private set; }
+
+ ///
+ /// Calls IHistoryServiceContract.AddTags 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
+ /// GetNextEventQueryResultBuffer, 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 ,
+ /// , and .
+ /// Next concrete step: instrument Wcf.AddT.Request on a successful native event
+ /// run and compare byte-for-byte against this serialiser's output.
+ ///
+ ///
+ /// 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 .
+ /// 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.
+ ///
+ 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 statusFactory = new(statusBinding, statusEndpoint);
+ IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
+ ICommunicationObject statusCo = (ICommunicationObject)statusChannel;
+
+ ChannelFactory transactionFactory = new(statusBinding, transactionEndpoint);
+ ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
+ ICommunicationObject transactionCo = (ICommunicationObject)transactionChannel;
+
+ ChannelFactory 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 { }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private static byte[] BuildUpdC3ClientStatusBlob()
+ {
+ byte[] blob = new byte[81];
+ blob[0] = 0x02;
+ blob[1] = 0x01;
+ blob[77] = 0x1E;
+ return blob;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 "";
+ }
+
+ 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(ChannelFactory factory)
+ {
+ try
+ {
+ if (factory.State == CommunicationState.Faulted) factory.Abort();
+ else factory.Close();
+ }
+ catch { try { factory.Abort(); } catch { } }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfHistAddressingBehavior.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfHistAddressingBehavior.cs
new file mode 100644
index 0000000..5fe945e
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfHistAddressingBehavior.cs
@@ -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;
+
+///
+/// Forces an explicit wsa:To URI on every outgoing message. Native captures
+/// of EnsT2 / DelT include net.pipe://localhost/Hist 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.
+///
+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) { }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfMessageCaptureBehavior.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfMessageCaptureBehavior.cs
new file mode 100644
index 0000000..da9bfa7
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfMessageCaptureBehavior.cs
@@ -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;
+
+///
+/// Reverse-engineering aid: when the env var AVEVA_HISTORIAN_SDK_WIRE_CAPTURE 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
+/// instrument-wcf-{write,read}message native captures and diff offset-by-offset to
+/// isolate SDK-vs-native differences. NEVER enable in production.
+///
+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 ?? "";
+ 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.
+ }
+ }
+ }
+
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfProbe.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfProbe.cs
new file mode 100644
index 0000000..9b7cfc6
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfProbe.cs
@@ -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 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(
+ options,
+ HistorianWcfServiceNames.History,
+ static channel =>
+ {
+ uint returnCode = channel.GetInterfaceVersion(out uint version);
+ return new WcfServiceVersion(returnCode, version);
+ },
+ timeout);
+
+ WcfServiceVersion retrieval = ProbeService(
+ options,
+ HistorianWcfServiceNames.Retrieval,
+ static channel =>
+ {
+ uint returnCode = channel.GetInterfaceVersion(out uint version);
+ return new WcfServiceVersion(returnCode, version);
+ },
+ timeout);
+
+ WcfServiceVersion status = ProbeService(
+ 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(
+ HistorianClientOptions options,
+ string serviceName,
+ Func call,
+ TimeSpan timeout)
+ where TContract : class
+ {
+ ChannelFactory? factory = null;
+ TContract? channel = null;
+ try
+ {
+ factory = new ChannelFactory(
+ 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);
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfReadOrchestrator.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfReadOrchestrator.cs
new file mode 100644
index 0000000..dbd73d9
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfReadOrchestrator.cs
@@ -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 ReadRawAsync(
+ string tag,
+ DateTime startUtc,
+ DateTime endUtc,
+ int maxValues,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ ValidateTransportAndAuth();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ IReadOnlyList 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 ReadAggregateAsync(
+ string tag,
+ DateTime startUtc,
+ DateTime endUtc,
+ Models.RetrievalMode mode,
+ TimeSpan interval,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ ValidateTransportAndAuth();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ IReadOnlyList 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> ReadAtTimeAsync(
+ string tag,
+ IReadOnlyList 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 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 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 RunAtTimeChain(
+ string tag,
+ IReadOnlyList 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 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 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 RunQuery(
+ Binding binding,
+ EndpointAddress retrievalEndpoint,
+ uint clientHandle,
+ string tag,
+ DateTime startUtc,
+ DateTime endUtc,
+ int maxValues,
+ CancellationToken cancellationToken)
+ {
+ ChannelFactory 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 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 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 RunAggregateQuery(
+ Binding binding,
+ EndpointAddress retrievalEndpoint,
+ uint clientHandle,
+ string tag,
+ DateTime startUtc,
+ DateTime endUtc,
+ Models.RetrievalMode mode,
+ TimeSpan interval,
+ CancellationToken cancellationToken)
+ {
+ ChannelFactory 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 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 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)
+ };
+ }
+
+ ///
+ /// QueryType wire value matches the native ArchestrA.HistorianRetrievalMode enum
+ /// ordinal exactly — verified 2026-05-04 by probing every mode through the
+ /// instrument-wcf-writemessage capture pipeline and reading the QueryType uint32
+ /// at offset 2 of pRequestBuff:
+ ///
+ /// 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
+ ///
+ /// The public enum mirrors the native order, so the
+ /// mapping reduces to (uint)mode. Prior version mapped Cyclic to 4
+ /// (BestFit's value) and threw for everything outside the four common modes.
+ ///
+ 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(ChannelFactory factory)
+ {
+ try
+ {
+ if (factory.State == CommunicationState.Faulted)
+ {
+ factory.Abort();
+ }
+ else
+ {
+ factory.Close();
+ }
+ }
+ catch
+ {
+ try { factory.Abort(); } catch { /* swallow */ }
+ }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfRevisionOrchestrator.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfRevisionOrchestrator.cs
new file mode 100644
index 0000000..1503c37
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfRevisionOrchestrator.cs
@@ -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;
+
+///
+/// Drives the AddNonStreamValuesBegin / AddNonStreamValues / AddNonStreamValuesEnd
+/// WCF op group on the /Trx service end-to-end. The native AVEVA wrapper's
+/// equivalent surface (HistorianAccess.AddRevisionValues*) is gated by the
+/// C++ HistorianClient's per-connection cache and rejects all writes from a
+/// managed client with err 129 TagNotFoundInCache. 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.
+///
+internal sealed class HistorianWcfRevisionOrchestrator
+{
+ private readonly HistorianClientOptions _options;
+
+ public HistorianWcfRevisionOrchestrator(HistorianClientOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public Task 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 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 statusFactory = new(auxBinding, statusEndpoint);
+ IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
+ ChannelFactory transactionFactory = new(auxBinding, transactionEndpoint);
+ ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
+ ChannelFactory 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);
+ }
+ }
+
+ /// Same 24-byte RTag2 buffer the event flow uses (CM_EVENT tag id).
+ 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 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; }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfServiceNames.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfServiceNames.cs
new file mode 100644
index 0000000..d8ea55d
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfServiceNames.cs
@@ -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";
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfStatusClient.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfStatusClient.cs
new file mode 100644
index 0000000..b0a8d1e
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfStatusClient.cs
@@ -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 GetSystemParameterAsync(
+ HistorianClientOptions options,
+ string parameterName,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
+ return Task.Run(() => GetSystemParameter(options, parameterName), cancellationToken);
+ }
+
+ public static Task GetConnectionStatusAsync(
+ HistorianClientOptions options,
+ CancellationToken cancellationToken)
+ {
+ return Task.Run(() => SynthesizeConnectionStatus(options), cancellationToken);
+ }
+
+ public static Task 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 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 { } }
+ }
+ }
+
+ ///
+ /// AVEVA's native HistorianAccess.GetConnectionStatus reads local C++
+ /// HistorianClient state (no WCF op exists for it). We synthesize an equivalent
+ /// by attempting an authenticated session open: a successful auth+open implies
+ /// ConnectedToServer = true. Store-forward and partner-connection state are not
+ /// observable from a single client probe and remain false.
+ ///
+ 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);
+ }
+
+ ///
+ /// Native HistorianAccess.GetStoreForwardStatus 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).
+ ///
+ 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);
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfTagClient.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfTagClient.cs
new file mode 100644
index 0000000..4a89b51
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfTagClient.cs
@@ -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 BrowseTagNamesAsync(
+ HistorianClientOptions options,
+ string filter,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ IReadOnlyList tagNames = await Task.Run(
+ () => BrowseTagNames(options, filter),
+ cancellationToken).ConfigureAwait(false);
+
+ foreach (string tagName in tagNames)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return tagName;
+ }
+ }
+
+ public static Task GetTagMetadataAsync(
+ HistorianClientOptions options,
+ string tag,
+ CancellationToken cancellationToken)
+ {
+ return Task.Run(() => GetTagMetadata(options, tag), cancellationToken);
+ }
+
+ private static IReadOnlyList 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 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);
+ }
+
+ ///
+ /// Reverse-engineering helper: returns the parsed tag-info response (including the raw
+ /// 4-byte native data-type descriptor) without dispatching through .
+ /// Used by TagMetadataDescriptorProbeTests to discover descriptors for new tag
+ /// types so they can be added to the dispatch table.
+ ///
+ internal static HistorianTagInfoResponse GetTagInfoForDescriptorProbe(HistorianClientOptions options, string tag)
+ {
+ using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
+ return GetTagInfoForDescriptorProbe(session, tag);
+ }
+
+ /// Bulk variant: probes many tags and returns the raw response bytes alongside the parsed record (for byte-layout reverse engineering).
+ internal static IReadOnlyDictionary GetTagInfoRawBytesForProbe(
+ HistorianClientOptions options,
+ IEnumerable tags)
+ {
+ Dictionary 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;
+ }
+
+ /// Bulk variant: probes many tags through a single session.
+ internal static IReadOnlyDictionary GetTagInfosForDescriptorProbe(
+ HistorianClientOptions options,
+ IEnumerable tags)
+ {
+ Dictionary 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('*', '%');
+ }
+
+ ///
+ /// Decodes the 4-byte native data-type descriptor returned by GetTagInfoFromName.
+ /// Layout determined by probing live tags + reading the CDataType predicate IL
+ /// (IsAnalog, IsDiscrete, IsString, IsWideString,
+ /// IsEvent, IsStruct, IsBoolean, IsConvertableToInt64,
+ /// IsConvertableToUInt64, IsConvertableToDouble) in
+ /// current/aahClientManaged.dll:
+ ///
+ /// - byte 0 = 0x03 (descriptor format version)
+ /// - byte 1 = tag-origin marker — observed 0xCF (system / built-in) and 0xC3 (user-created).
+ /// - byte 2 = storage attribute byte — varies per tag (0x00 vs 0x04 observed for the same data type).
+ /// - byte 3 = data-type code (the load-bearing field; matches the native CDataType byte 0).
+ ///
+ /// Bit pattern of byte 3 (deduced from the predicate IL):
+ ///
+ /// - bit 0x80: extended/reserved marker — when set the type is treated specially (e.g., 0x81 = Boolean).
+ /// - bit 0x40: wide-string variant (set for , clear for ).
+ /// - bit 0x20: integer signed flag (UInt16=0x09 → Int16=0x29; UInt32=0x11 → Int32=0x31).
+ /// - low 3 bits: type class — 1=numeric, 2=discrete/bool, 3=string, 4=event, 5=structure, 7=fixed-string.
+ ///
+ /// Type-code dispatch:
+ ///
+ /// - 0x01 — probed: SysDataAcqOverallItemsPerSec → 03 CF 00 01
+ /// - 0x02 (Discrete/Bool) — probed: SysClassicDataRedirector → 03 CF 00 02
+ /// - 0x03 — IL inference (string class without bit 0x40)
+ /// - 0x04 — IL inference (IsEvent low 3 bits == 4)
+ /// - 0x05 — IL inference (IsStruct low 3 bits == 5)
+ /// - 0x09 — probed: SysCritErrCnt → 03 CF 00 09, SysTimeSec → 03 CF 04 09
+ /// - 0x11 — probed: SysConfigStatus → 03 CF 04 11
+ /// - 0x21 — IL inference (IsConvertableToDouble matches 33)
+ /// - 0x29 — IL inference (IsConvertableToInt64 matches 41 = signed UInt16 bit pattern)
+ /// - 0x31 — probed: OtOpcUaParityTest_001.Counter → 03 C3 00 31
+ /// - 0x43 — probed: SysString → 03 CF 00 43
+ ///
+ /// Extended dispatch (recovered from the same IL):
+ ///
+ /// - 0x08 — 1-byte unsigned (in IsConvertableToUInt64 list)
+ /// - 0x10 — 16-byte GUID (matches IsGuid)
+ /// - 0x18 — Windows FILETIME (matches IsFileTime)
+ /// - 0x19 — 8-byte signed (in IsConvertableToInt64 list, follows Int16=0x29 / Int32=0x31)
+ /// - 0x39 — 8-byte unsigned (in IsConvertableToUInt64 list, follows UInt16=0x09 / UInt32=0x11 with signed-bit set)
+ /// - 0x81 — Boolean extended form (matches IsBoolean's literal byte=129 check; same semantic as 0x02 Discrete)
+ ///
+ /// Code 0x38 also appears in CDataType.IsConvertableToUInt64's allow-list but is
+ /// NEVER produced by any tag-creation path (verified by reading the IL of
+ /// CDataType.InitializeAnalog/InitializeDiscrete/InitializeStruct/
+ /// InitializeString, and by probing all 198 tags in a sample Runtime DB via the
+ /// EnumerateAllTagDescriptorsAcrossOneSession 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 rather than being silently
+ /// treated as .
+ ///
+ 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 _historyFactory;
+ private readonly IHistoryServiceContract2 _historyChannel;
+ private readonly ChannelFactory _retrievalFactory;
+
+ private WcfRetrievalSession(
+ ChannelFactory historyFactory,
+ IHistoryServiceContract2 historyChannel,
+ ChannelFactory 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? historyFactory = null;
+ IHistoryServiceContract2? historyChannel = null;
+ ChannelFactory? retrievalFactory = null;
+ IRetrievalServiceContract2? retrievalChannel = null;
+ try
+ {
+ historyFactory = new ChannelFactory(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(
+ 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 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();
+ }
+ }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfTagWriteOrchestrator.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfTagWriteOrchestrator.cs
new file mode 100644
index 0000000..133aaf7
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/HistorianWcfTagWriteOrchestrator.cs
@@ -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;
+
+///
+/// Drives the EnsT2 (EnsureTags2) and DelT (DeleteTags) WCF operations end-to-end.
+/// Mirrors 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
+/// docs/plans/write-commands-reverse-engineering.md Phase 2 findings.
+///
+internal sealed class HistorianWcfTagWriteOrchestrator
+{
+ private readonly HistorianClientOptions _options;
+
+ public HistorianWcfTagWriteOrchestrator(HistorianClientOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public Task 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 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 ? "" : Convert.ToHexString(outBuf))} ErrLen={errBuf?.Length ?? -1} ErrHex={(errBuf is null ? "" : Convert.ToHexString(errBuf))}");
+ return ok;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 statusFactory = new(auxBinding, statusEndpoint);
+ IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
+ ChannelFactory transactionFactory = new(auxBinding, transactionEndpoint);
+ ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
+ ChannelFactory 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 ? "" : Convert.ToHexString(status))} ErrorSize={errorSize} ErrorLen={errorBuffer?.Length ?? -1} ErrorHex={(errorBuffer is 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 { } }
+
+ /// 81-byte UpdC3 status blob captured from native (same as event flow).
+ private static byte[] BuildUpdC3ClientStatusBlob()
+ {
+ byte[] blob = new byte[81];
+ blob[0] = 0x02;
+ blob[1] = 0x01;
+ blob[77] = 0x1E;
+ return blob;
+ }
+
+ /// GETHI request bytes for a parameter-name query (decoded from native).
+ 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 { }
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/MdasMessageEncoder.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/MdasMessageEncoder.cs
new file mode 100644
index 0000000..2d31e32
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/MdasMessageEncoder.cs
@@ -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 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 WriteMessage(
+ Message message,
+ int maxMessageSize,
+ BufferManager bufferManager,
+ int messageOffset)
+ {
+ return inner.WriteMessage(message, maxMessageSize, bufferManager, messageOffset);
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/MdasMessageEncoderFactory.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/MdasMessageEncoderFactory.cs
new file mode 100644
index 0000000..4ea2583
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/MdasMessageEncoderFactory.cs
@@ -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;
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/MdasMessageEncodingBindingElement.cs b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/MdasMessageEncodingBindingElement.cs
new file mode 100644
index 0000000..6332fc3
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/Wcf/MdasMessageEncodingBindingElement.cs
@@ -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 BuildChannelFactory(BindingContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ context.BindingParameters.Add(this);
+ return context.BuildInnerChannelFactory();
+ }
+
+ public override bool CanBuildChannelFactory(BindingContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ context.BindingParameters.Add(this);
+ return context.CanBuildInnerChannelFactory();
+ }
+
+ public override T? GetProperty(BindingContext context) where T : class
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ return inner.GetProperty(context) ?? context.GetInnerProperty();
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/BinaryPrimitiveTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/BinaryPrimitiveTests.cs
new file mode 100644
index 0000000..d53e057
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/BinaryPrimitiveTests.cs
@@ -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());
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/EnumCompatibilityTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/EnumCompatibilityTests.cs
new file mode 100644
index 0000000..3e9f593
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/EnumCompatibilityTests.cs
@@ -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);
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/EventChainDiagnosticTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/EventChainDiagnosticTests.cs
new file mode 100644
index 0000000..328faa6
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/EventChainDiagnosticTests.cs
@@ -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}");
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/FrameTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/FrameTests.cs
new file mode 100644
index 0000000..813ad71
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/FrameTests.cs
@@ -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(async () =>
+ await HistorianFrameReader.ReadAsync(new MemoryStream(bytes), CancellationToken.None));
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianClientIntegrationTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianClientIntegrationTests.cs
new file mode 100644
index 0000000..38e44cf
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianClientIntegrationTests.cs
@@ -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 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 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 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 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 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 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(
+ () => 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));
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianEventRowProtocolTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianEventRowProtocolTests.cs
new file mode 100644
index 0000000..716a0eb
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianEventRowProtocolTests.cs
@@ -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 events = HistorianEventRowProtocol.Parse([]);
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public void Parse_HeaderWithZeroRowCount_ReturnsEmpty()
+ {
+ byte[] buffer = BuildHeader(rowCount: 0);
+ IReadOnlyList 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 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 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 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 events = HistorianEventRowProtocol.Parse(buffer);
+ HistorianEvent evt = Assert.Single(events);
+ Assert.IsType(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 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 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;
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianGrpcIntegrationTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianGrpcIntegrationTests.cs
new file mode 100644
index 0000000..e9f7b20
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianGrpcIntegrationTests.cs
@@ -0,0 +1,63 @@
+using ZB.MOM.WW.SPHistorianClient.Models;
+
+namespace ZB.MOM.WW.SPHistorianClient.Tests;
+
+///
+/// Live integration tests for the 2023 R2 RemoteGrpc transport. Gated on a dedicated
+/// HISTORIAN_GRPC_HOST env var (plus HISTORIAN_TEST_TAG) 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).
+///
+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 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
+ };
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianGrpcTransportTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianGrpcTransportTests.cs
new file mode 100644
index 0000000..0fbf9cb
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianGrpcTransportTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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());
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianRetrievalModeMappingTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianRetrievalModeMappingTests.cs
new file mode 100644
index 0000000..8bc70e6
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianRetrievalModeMappingTests.cs
@@ -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(
+ () => HistorianWcfReadOrchestrator.MapRetrievalModeToQueryType((RetrievalMode)999));
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianSspiClientTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianSspiClientTests.cs
new file mode 100644
index 0000000..9d66ea0
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianSspiClientTests.cs
@@ -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));
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianTagWriteProtocolTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianTagWriteProtocolTests.cs
new file mode 100644
index 0000000..15f74c8
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianTagWriteProtocolTests.cs
@@ -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(() => 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 defaultDivisor = @default.AsSpan(@default.Length - 10, 8);
+ ReadOnlySpan 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(
+ () => HistorianTagWriteProtocol.GetAnalogDataTypeCode(ZB.MOM.WW.SPHistorianClient.Models.HistorianDataType.SingleByteString));
+ Assert.Throws(
+ () => 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(() => 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;
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianWcfCertOptionTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianWcfCertOptionTests.cs
new file mode 100644
index 0000000..1b6ce44
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianWcfCertOptionTests.cs
@@ -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 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 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(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(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(historyEndpoint.Identity);
+ Assert.Equal("localhost", historyIdentity.IdentityClaim.Resource);
+ // The Retrieval endpoint uses plain MdasNetTcp without TLS — no DNS identity needed.
+ Assert.Null(retrievalEndpoint.Identity);
+ }
+}
diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianWcfRevisionProbeTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianWcfRevisionProbeTests.cs
new file mode 100644
index 0000000..2b6a3c0
--- /dev/null
+++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/HistorianWcfRevisionProbeTests.cs
@@ -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;
+
+///