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; + +/// +/// Probes the SDK-direct WCF revision-write path (D2 new path). Calls +/// AddNonStreamValuesBegin through +/// against the live local Historian and surfaces what the server returns. The +/// underlying native wrapper is gated client-side by err 129 TagNotFoundInCache; +/// this test bypasses the wrapper entirely and asks the SERVER directly. Gated on +/// HISTORIAN_HOST=localhost; skips otherwise. +/// +[SupportedOSPlatform("windows")] +public sealed class HistorianWcfRevisionProbeTests +{ + private readonly ITestOutputHelper _output; + + public HistorianWcfRevisionProbeTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task AddNonStreamValuesBegin_ProbeReturnsServerResult() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); + if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClientOptions options = new() + { + Host = host, + IntegratedSecurity = true, + Transport = HistorianTransport.LocalPipe, + }; + + HistorianWcfRevisionOrchestrator orchestrator = new(options); + HistorianRevisionProbeResult result = await orchestrator.ProbeBeginAsync(CancellationToken.None); + + _output.WriteLine($"OpenSucceeded: {result.OpenSucceeded}"); + _output.WriteLine($"ClientHandle: {result.ClientHandle}"); + _output.WriteLine($"StorageSessionId: {result.StorageSessionId}"); + _output.WriteLine($"TrxInterfaceVersion: {result.TrxInterfaceVersion} (rc={result.TrxInterfaceVersionReturnCode}) ex={result.TrxInterfaceVersionException}"); + _output.WriteLine($"RTag2Succeeded: {result.RTag2Succeeded} OutHex={result.RTag2OutHex} ErrHex={result.RTag2ErrorHex} Ex={result.RTag2Exception}"); + _output.WriteLine($"BeginSucceeded: {result.BeginSucceeded}"); + _output.WriteLine($"BeginTransactionId: {result.BeginTransactionId}"); + foreach (HistorianRevisionBeginAttempt attempt in result.BeginAttempts) + { + _output.WriteLine($" attempt[{attempt.HandleLabel}] handle={attempt.HandleSent} ok={attempt.Succeeded} tx={attempt.TransactionId} err={attempt.ErrorHex} ex={attempt.Exception}"); + } + + Assert.True(result.OpenSucceeded, "Auth chain failed; revision probe never reached the Trx endpoint."); + // Don't assert BeginSucceeded — we're surfacing whatever the server says, not requiring success. + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/ProtocolGuardrailTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/ProtocolGuardrailTests.cs new file mode 100644 index 0000000..9abb9bf --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/ProtocolGuardrailTests.cs @@ -0,0 +1,16 @@ +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +public sealed class ProtocolGuardrailTests +{ + [Fact] + public async Task ReadAtTime_RequiresAuthCredentials() + { + HistorianClient client = new(new HistorianClientOptions { Host = "localhost", IntegratedSecurity = false }); + + ProtocolEvidenceMissingException ex = await Assert.ThrowsAsync(() => + client.ReadAtTimeAsync("SysTimeSec", [DateTime.UtcNow], CancellationToken.None)); + + Assert.Contains("IntegratedSecurity", ex.Operation); + } + +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/RemoteTcpIntegrationTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/RemoteTcpIntegrationTests.cs new file mode 100644 index 0000000..48a4cf1 --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/RemoteTcpIntegrationTests.cs @@ -0,0 +1,233 @@ +using System.Runtime.Versioning; +using ZB.MOM.WW.SPHistorianClient.Models; +using Xunit.Abstractions; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +/// +/// Live verification of the RemoteTcpIntegrated and RemoteTcpCertificate transports +/// per docs/plans/tcp-connection-validation.md. Gated by env vars: +/// +/// HISTORIAN_REMOTE_TCP_HOST — hostname or IP of a reachable remote Historian. +/// HISTORIAN_REMOTE_TCP_TAG — tag with non-zero history rows. +/// HISTORIAN_REMOTE_TCP_SPN — optional Kerberos SPN override (default per HistorianClientOptions.TargetSpn). +/// HISTORIAN_REMOTE_TCPCERT_HOST + HISTORIAN_REMOTE_TCPCERT_DNS — for the certificate transport variant. +/// +/// All tests skip cleanly if the gating env var isn't set. +/// +[SupportedOSPlatform("windows")] +public sealed class RemoteTcpIntegrationTests +{ + private readonly ITestOutputHelper _output; + + public RemoteTcpIntegrationTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task ProbeAsync_RemoteTcpIntegrated_ReturnsTrue() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + bool reachable = await client.ProbeAsync(CancellationToken.None); + Assert.True(reachable, "ProbeAsync against remote-TCP host returned false"); + } + + [Fact] + public async Task ReadRawAsync_RemoteTcpIntegrated_ReturnsAtLeastOneRow() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + DateTime endUtc = DateTime.UtcNow; + DateTime startUtc = endUtc - TimeSpan.FromDays(7); + + List samples = []; + await foreach (HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None)) + { + samples.Add(sample); + } + + _output.WriteLine($"Returned {samples.Count} samples for {testTag}"); + Assert.NotEmpty(samples); + Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); + } + + [Fact] + public async Task GetTagMetadataAsync_RemoteTcpIntegrated_PopulatesFields() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(testTag, CancellationToken.None); + Assert.NotNull(metadata); + Assert.Equal(testTag, metadata.Name); + } + + [Fact] + public async Task GetSystemParameterAsync_RemoteTcpIntegrated_ReturnsHistorianVersion() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + string? value = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None); + _output.WriteLine($"HistorianVersion: {value}"); + Assert.False(string.IsNullOrWhiteSpace(value)); + } + + [Fact] + public async Task ReadAggregateAsync_RemoteTcpIntegrated_ReturnsTimeWeightedRows() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + DateTime endUtc = DateTime.UtcNow; + DateTime startUtc = endUtc - TimeSpan.FromMinutes(10); + + List samples = []; + await foreach (HistorianAggregateSample sample in client.ReadAggregateAsync( + testTag, startUtc, endUtc, RetrievalMode.TimeWeightedAverage, TimeSpan.FromMinutes(1), CancellationToken.None)) + { + samples.Add(sample); + } + + Assert.NotEmpty(samples); + Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); + } + + [Fact] + public async Task ReadAtTimeAsync_RemoteTcpIntegrated_ReturnsTimestamps() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + DateTime now = DateTime.UtcNow; + DateTime[] timestamps = [now - TimeSpan.FromMinutes(5), now - TimeSpan.FromMinutes(2), now - TimeSpan.FromMinutes(1)]; + IReadOnlyList samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None); + Assert.NotEmpty(samples); + Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); + } + + [Fact] + public async Task BrowseTagNamesAsync_RemoteTcpIntegrated_FindsTestTag() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + List names = []; + await foreach (string name in client.BrowseTagNamesAsync(testTag, CancellationToken.None)) + { + names.Add(name); + } + Assert.Contains(testTag, names); + } + + [Fact] + public async Task ReadEventsAsync_RemoteTcpIntegrated_DoesNotThrow() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + DateTime endUtc = DateTime.UtcNow; + DateTime startUtc = endUtc - TimeSpan.FromDays(1); + + // Empty result is acceptable — we're just verifying the chain doesn't throw over TCP. + List events = []; + await foreach (HistorianEvent evt in client.ReadEventsAsync(startUtc, endUtc, CancellationToken.None)) + { + events.Add(evt); + } + Assert.NotNull(events); + } + + [Fact] + public async Task GetConnectionStatusAsync_RemoteTcpIntegrated_ReportsConnectedToServer() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(BuildIntegratedOptions(host)); + HistorianConnectionStatus status = await client.GetConnectionStatusAsync(CancellationToken.None); + Assert.True(status.ConnectedToServer); + Assert.False(status.ErrorOccurred); + Assert.Equal(host, status.ServerName); + } + + [Fact] + public async Task ProbeAsync_RemoteTcpCertificate_ReturnsTrue() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCPCERT_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClient client = new(new HistorianClientOptions + { + Host = host, + Port = HistorianClientOptions.DefaultPort, + IntegratedSecurity = false, + Transport = HistorianTransport.RemoteTcpCertificate, + }); + + bool reachable = await client.ProbeAsync(CancellationToken.None); + Assert.True(reachable, "ProbeAsync over RemoteTcpCertificate returned false"); + } + + private static HistorianClientOptions BuildIntegratedOptions(string host) + { + string? spn = Environment.GetEnvironmentVariable("HISTORIAN_REMOTE_TCP_SPN"); + return new HistorianClientOptions + { + Host = host, + Port = HistorianClientOptions.DefaultPort, + IntegratedSecurity = true, + Transport = HistorianTransport.RemoteTcpIntegrated, + // SPN default in HistorianClientOptions is "NT SERVICE\aahClientAccessPoint" which is the + // LocalPipe service identity; for remote TCP, override via env var if needed. + TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn, + }; + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/TagMetadataDescriptorProbeTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/TagMetadataDescriptorProbeTests.cs new file mode 100644 index 0000000..a436d73 --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/TagMetadataDescriptorProbeTests.cs @@ -0,0 +1,111 @@ +using System.Runtime.Versioning; +using ZB.MOM.WW.SPHistorianClient.Wcf; +using Xunit.Abstractions; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +[SupportedOSPlatform("windows")] +public sealed class TagMetadataDescriptorProbeTests +{ + private readonly ITestOutputHelper _output; + + public TagMetadataDescriptorProbeTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void ProbeDescriptorsForKnownSampleTags() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_DESCRIPTOR_PROBE_TAGS") + ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (sampleTags.Length == 0) + { + return; + } + + HistorianClientOptions options = new() + { + Host = host, + IntegratedSecurity = true, + }; + + foreach (string tagName in sampleTags) + { + try + { + HistorianTagInfoResponse parsed = HistorianWcfTagClient.GetTagInfoForDescriptorProbe(options, tagName); + _output.WriteLine($" {tagName,-50} descriptor=0x{Convert.ToHexString(parsed.NativeDataTypeDescriptor)}"); + } + catch (Exception ex) + { + _output.WriteLine($" {tagName,-50} ERROR: {ex.GetType().Name}: {ex.Message}"); + } + } + } + + [Fact] + public void DumpRawTagInfoBytesForLayoutDecoding() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); + string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_RAW_TAGINFO_TAGS") ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (string.IsNullOrWhiteSpace(host) || sampleTags.Length == 0 || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClientOptions options = new() { Host = host, IntegratedSecurity = true }; + var results = HistorianWcfTagClient.GetTagInfoRawBytesForProbe(options, sampleTags); + foreach (var (tag, bytes) in results) + { + if (bytes is null) { _output.WriteLine($" {tag}: "); continue; } + _output.WriteLine($" {tag} ({bytes.Length} bytes): {Convert.ToHexString(bytes)}"); + } + } + + [Fact] + public void EnumerateAllTagDescriptorsAcrossOneSession() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; + } + + string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_DESCRIPTOR_PROBE_TAGS") ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (sampleTags.Length == 0) + { + return; + } + + HistorianClientOptions options = new() + { + Host = host, + IntegratedSecurity = true, + }; + + IReadOnlyDictionary results = + HistorianWcfTagClient.GetTagInfosForDescriptorProbe(options, sampleTags); + + // Group by descriptor (hex string) and report counts only — no tag names in output to + // avoid leaking customer-tag identifiers. + var grouped = results + .Where(static kv => kv.Value is not null) + .GroupBy(static kv => Convert.ToHexString(kv.Value!.NativeDataTypeDescriptor)) + .OrderBy(static g => g.Key); + _output.WriteLine($"Probed {results.Count} tags ({results.Count(static kv => kv.Value is null)} errors)."); + foreach (var grp in grouped) + { + _output.WriteLine($" 0x{grp.Key} count={grp.Count()}"); + } + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfAuthenticationProtocolTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfAuthenticationProtocolTests.cs new file mode 100644 index 0000000..b18e338 --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfAuthenticationProtocolTests.cs @@ -0,0 +1,97 @@ +using ZB.MOM.WW.SPHistorianClient.Wcf; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +public sealed class WcfAuthenticationProtocolTests +{ + [Fact] + public void WrapValidateClientCredentialToken_UsesNativeRoundAndLengthEnvelope() + { + byte[] actual = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken( + isFirstRound: true, + [0x4E, 0x54, 0x4C, 0x4D]); + + Assert.Equal([0x01, 0x04, 0x00, 0x00, 0x00, 0x4E, 0x54, 0x4C, 0x4D], actual); + } + + [Fact] + public void TryReadWrappedValidateClientCredentialToken_ReadsNativeEnvelope() + { + ValidateClientCredentialToken? actual = + HistorianWcfAuthenticationProtocol.TryReadWrappedValidateClientCredentialToken( + [0x00, 0x03, 0x00, 0x00, 0x00, 0xAA, 0xBB, 0xCC]); + + Assert.NotNull(actual); + Assert.False(actual.IsFirstRound); + Assert.Equal([0xAA, 0xBB, 0xCC], actual.Token); + } + + [Fact] + public void TryReadWrappedValidateClientCredentialToken_RejectsLengthMismatch() + { + Assert.Null(HistorianWcfAuthenticationProtocol.TryReadWrappedValidateClientCredentialToken( + [0x01, 0x04, 0x00, 0x00, 0x00, 0xAA])); + } + + [Fact] + public void TryReadValidateClientCredentialResponse_ReadsContinueFlagAndServerToken() + { + ValidateClientCredentialResponse? actual = + HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse( + [0x01, 0x11, 0x22, 0x33]); + + Assert.NotNull(actual); + Assert.True(actual.Continue); + Assert.Equal([0x11, 0x22, 0x33], actual.Token); + } + + [Fact] + public void TryReadValidateClientCredentialResponse_ReadsTerminalOneByteResponse() + { + ValidateClientCredentialResponse? actual = + HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse([0x00]); + + Assert.NotNull(actual); + Assert.False(actual.Continue); + Assert.Empty(actual.Token); + } + + [Fact] + public void TryReadValidateClientCredentialResponse_RejectsEmptyResponse() + { + Assert.Null(HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse([])); + } + + [Fact] + public void TryApplyNativeNtlmNegotiateVersionFlag_MatchesObservedNativeFirstTokenFlag() + { + byte[] token = + [ + 0x4E, 0x54, 0x4C, 0x4D, 0x53, 0x53, 0x50, 0x00, + 0x01, 0x00, 0x00, 0x00, + 0xB7, 0xB2, 0x08, 0xE2 + ]; + + bool changed = HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(token); + + Assert.True(changed); + Assert.Equal( + [ + 0x4E, 0x54, 0x4C, 0x4D, 0x53, 0x53, 0x50, 0x00, + 0x01, 0x00, 0x00, 0x00, + 0xB7, 0xB2, 0x18, 0xE2 + ], + token); + } + + [Fact] + public void TryApplyNativeNtlmNegotiateVersionFlag_IgnoresNonNtlmNegotiateTokens() + { + byte[] token = [0x4B, 0x52, 0x42, 0x35]; + + bool changed = HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(token); + + Assert.False(changed); + Assert.Equal([0x4B, 0x52, 0x42, 0x35], token); + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfBindingFactoryTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfBindingFactoryTests.cs new file mode 100644 index 0000000..65734e3 --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfBindingFactoryTests.cs @@ -0,0 +1,39 @@ +using System.Runtime.Versioning; +using System.ServiceModel.Channels; +using ZB.MOM.WW.SPHistorianClient.Wcf; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +[SupportedOSPlatform("windows")] +public sealed class WcfBindingFactoryTests +{ + [Fact] + public void CreateMdasNetNamedPipeBinding_WrapsTheInnerEncoderInMdas() + { + Binding binding = HistorianWcfBindingFactory.CreateMdasNetNamedPipeBinding(TimeSpan.FromSeconds(5)); + + BindingElementCollection elements = binding.CreateBindingElements(); + Assert.Contains(elements, e => e is MdasMessageEncodingBindingElement); + } + + [Fact] + public void CreateMdasNetNamedPipeBinding_AppliesProvidedTimeout() + { + TimeSpan timeout = TimeSpan.FromSeconds(7); + + Binding binding = HistorianWcfBindingFactory.CreateMdasNetNamedPipeBinding(timeout); + + Assert.Equal(timeout, binding.OpenTimeout); + Assert.Equal(timeout, binding.CloseTimeout); + Assert.Equal(timeout, binding.SendTimeout); + Assert.Equal(timeout, binding.ReceiveTimeout); + } + + [Fact] + public void CreatePipeEndpointAddress_BuildsNetPipeUri() + { + var address = HistorianWcfBindingFactory.CreatePipeEndpointAddress("localhost", "Hist"); + + Assert.Equal(new Uri("net.pipe://localhost/Hist"), address.Uri); + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfDataQueryProtocolTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfDataQueryProtocolTests.cs new file mode 100644 index 0000000..4df32b3 --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfDataQueryProtocolTests.cs @@ -0,0 +1,156 @@ +using ZB.MOM.WW.SPHistorianClient.Wcf; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +public sealed class WcfDataQueryProtocolTests +{ + [Fact] + public void SerializerMatchesInstrumentedNativeFullHistoryRequest() + { + byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest( + ["OtOpcUaParityTest_001.Counter"], + new DateTime(2026, 5, 1, 14, 17, 5, 659, DateTimeKind.Utc).AddTicks(3154), + new DateTime(2026, 5, 2, 14, 17, 5, 659, DateTimeKind.Utc).AddTicks(3154), + MaxStates: 100, + BatchSize: 1, + Option: string.Empty)); + + byte[] expected = Convert.FromBase64String( + "CQACAAAAAAAAAAAAAAAC4ScwddncAQKhkVo+2twBAAAAAAAAAAAAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); + + Assert.Equal(expected, actual); + } + + [Fact] + public void SerializerMatchesInstrumentedNativeTimeWeightedAverageRequest() + { + byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest( + ["OtOpcUaParityTest_001.Counter"], + new DateTime(2026, 5, 1, 14, 29, 2, 223, DateTimeKind.Utc).AddTicks(2955), + new DateTime(2026, 5, 2, 14, 29, 2, 223, DateTimeKind.Utc).AddTicks(2955), + MaxStates: 100, + BatchSize: 3, + Option: string.Empty) + { + QueryType = 5, + Resolution = TimeSpan.FromMinutes(1) + }); + + byte[] expected = Convert.FromBase64String( + "CQAFAAAAAAAAAAAAAAB73ULbdtncAXudrAVA2twBAAAAAKPhwUEAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAABg3vt0BQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); + + Assert.Equal(expected, actual); + } + + [Fact] + public void SerializerMatchesInstrumentedNativeInterpolatedRequest() + { + byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest( + ["OtOpcUaParityTest_001.Counter"], + new DateTime(2026, 5, 1, 14, 32, 12, 72, DateTimeKind.Utc).AddTicks(8924), + new DateTime(2026, 5, 2, 14, 32, 12, 72, DateTimeKind.Utc).AddTicks(8924), + MaxStates: 100, + BatchSize: 3, + Option: string.Empty) + { + QueryType = 3, + Resolution = TimeSpan.FromMinutes(1) + }); + + byte[] expected = Convert.FromBase64String( + "CQADAAAAAAAAAAAAAABcnWtMd9ncAVxd1XZA2twBAAAAAKPhwUEAAAAAAAAAAAMAAABVAFQAQwABAAAAAAABAP8BAAAAAAgAAABOAG8ARgBpAGwAdABlAHIAAQADAAEA/4IHAIKBAAABAAAAHQAAAE8AdABPAHAAYwBVAGEAUABhAHIAaQB0AHkAVABlAHMAdABfADAAMAAxAC4AQwBvAHUAbgB0AGUAcgBkAAEBAAABAAABAAAJAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAABg3vt0BQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); + + Assert.Equal(expected, actual); + } + + [Fact] + public void SerializerUsesDecompiledEmptyMetadataAndAutoSummaryLayout() + { + byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest( + ["T"], + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc), + MaxStates: 100, + BatchSize: 1, + Option: string.Empty)); + + byte[] expectedMiddle = + [ + 0x64, 0x00, + 0x01, + 0x01, 0x00, 0x00, + 0x01, 0x00, 0x00, + 0x01, 0x00, 0x00, + 0x09, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00 + ]; + + AssertContains(expectedMiddle, actual); + AssertEndsWith(ExpectedEmptyEndpointAndAutoSummarySuffix(), actual); + } + + [Fact] + public void SerializerWritesPackedCqtiFlagsSeparatelyFromColumnSelectorFlags() + { + byte[] actual = HistorianDataQueryProtocol.SerializeFullHistoryRequest(new HistorianDataQueryRequest( + ["T"], + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc), + MaxStates: 100, + BatchSize: 1, + Option: "NoOption") + { + InterpolationType = 255, + TimestampRule = 1, + QualityRule = 0, + ColumnSelectorFlags = 0x0000_0000_0003_FFFF + }); + + int resultBufferOffset = 2 + 4 + 4 + 4 + 8 + 8 + 8 + 4 + 4 + 10 + 4; + Assert.Equal([0x00, 0x00, 0x01, 0x00, 0xFF, 0x01], actual[resultBufferOffset..(resultBufferOffset + 6)]); + AssertContains([0x01, 0x00, 0xFF, 0xFF, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00], actual); + } + + private static byte[] ExpectedEmptyEndpointAndAutoSummarySuffix() + { + List expected = []; + AppendEmptyEndpoint(expected); + AppendEmptyEndpoint(expected); + expected.AddRange(new byte[8]); + expected.AddRange([0x00, 0x00, 0x00, 0x00]); + expected.AddRange([0x00, 0x00, 0x00, 0x00]); + expected.AddRange([0x01, 0x00]); + expected.AddRange(new byte[16]); + expected.AddRange(new byte[5]); + expected.AddRange([0x00, 0x00, 0x00, 0x00]); + return expected.ToArray(); + } + + private static void AppendEmptyEndpoint(List bytes) + { + bytes.AddRange([0x01, 0x00]); + bytes.AddRange([0x00, 0x00, 0x00, 0x00]); + bytes.AddRange([0x00, 0x00]); + } + + private static void AssertContains(byte[] expected, byte[] actual) + { + for (int index = 0; index <= actual.Length - expected.Length; index++) + { + if (actual.AsSpan(index, expected.Length).SequenceEqual(expected)) + { + return; + } + } + + Assert.Fail($"Expected byte sequence {Convert.ToHexString(expected)} was not found."); + } + + private static void AssertEndsWith(byte[] expectedSuffix, byte[] actual) + { + Assert.True(actual.Length >= expectedSuffix.Length); + Assert.Equal(expectedSuffix, actual[^expectedSuffix.Length..]); + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfDataQueryResultBufferTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfDataQueryResultBufferTests.cs new file mode 100644 index 0000000..8a969f2 --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfDataQueryResultBufferTests.cs @@ -0,0 +1,109 @@ +using ZB.MOM.WW.SPHistorianClient.Models; +using ZB.MOM.WW.SPHistorianClient.Wcf; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +public sealed class WcfDataQueryResultBufferTests +{ + // Captured from artifacts/reverse-engineering/instrumented-openconnection3-correlation/capture.ndjson + // Wcf.GetNextQueryResultBuffer2.ResultBytes for a 4-row OtOpcUaParityTest_001.Counter Full read. + private static readonly byte[] CapturedResultBytes = Convert.FromBase64String( + "CQAEAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAu" + + "AEMAbwB1AG4AdABlAHIAAQAAAGvPzFvD2dwBhQAAAPgAAADAAAAAAAAAAAAAAAAAAAAAAABZQAAA" + + "AWvPzFvD2dwBAAAAAAAAAAClBtClfAAAAAAAAAAAAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAA" + + "YQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAAQAAABDWnAFA2twBAQAA" + + "ABgAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAARDWnAFA2twBAAAAAAAAAAAwZOgAAAAAAAEAAAAA" + + "AAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMA" + + "bwB1AG4AdABlAHIAAQAAAEA6hQJA2twBAQAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUA6" + + "hQJA2twBAAAAAAAAAABQwwAAAAAAAAEAAAAAAAAA7gAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQBy" + + "AGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAAQAAAJD9hQJA2twBAAAAAPgA" + + "AADAAAAAAAAAAAAAAAAAAAAAAABZQAAAAZD9hQJA2twBAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAA"); + + private static readonly byte[] TerminalNoMoreData = Convert.FromBase64String("BB4AAAA="); + + [Fact] + public void TryParseGetNextQueryResultBufferRows_ParsesFourCanonicalFixtureRows() + { + bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows( + CapturedResultBytes, + TerminalNoMoreData, + out IReadOnlyList rows, + out bool hasMoreData); + + Assert.True(ok); + Assert.False(hasMoreData); + Assert.Equal(4, rows.Count); + + Assert.All(rows, r => Assert.Equal("OtOpcUaParityTest_001.Counter", r.TagName)); + + HistorianSample row0 = rows[0]; + Assert.Equal(133, row0.Quality); + Assert.Equal(248u, row0.QualityDetail); + Assert.Equal(192, row0.OpcQuality); + Assert.Equal(0, row0.NumericValue); + Assert.Equal(100.0, row0.PercentGood); + Assert.Equal(DateTime.FromFileTimeUtc(0x01DCD9C35BCCCF6B), row0.TimestampUtc); + + HistorianSample row3 = rows[3]; + Assert.Equal(0, row3.Quality); + Assert.Equal(248u, row3.QualityDetail); + Assert.Equal(192, row3.OpcQuality); + Assert.Equal(100.0, row3.PercentGood); + } + + [Fact] + public void TryParseGetNextQueryResultBufferRows_FlagsContinuationWhenErrorTerminalIsEmpty() + { + bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows( + CapturedResultBytes, + errorTerminal: [], + out _, + out bool hasMoreData); + + Assert.True(ok); + Assert.True(hasMoreData); + } + + [Fact] + public void TryParseGetNextQueryResultBufferRows_FlagsContinuationWhenErrorIsNotNoMoreData() + { + bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows( + CapturedResultBytes, + errorTerminal: [0x04, 0x01, 0x00, 0x00, 0x00], + out _, + out bool hasMoreData); + + Assert.True(ok); + Assert.True(hasMoreData); + } + + [Fact] + public void TryParseGetNextQueryResultBufferRows_RejectsBufferWithUnsupportedVersion() + { + byte[] mangled = (byte[])CapturedResultBytes.Clone(); + mangled[0] = 0x07; + + bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows( + mangled, + TerminalNoMoreData, + out IReadOnlyList rows, + out _); + + Assert.False(ok); + Assert.Empty(rows); + } + + [Fact] + public void TryParseGetNextQueryResultBufferRows_HandlesEmptyResultBuffer() + { + bool ok = HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows( + result: [], + TerminalNoMoreData, + out IReadOnlyList rows, + out bool hasMoreData); + + Assert.True(ok); + Assert.False(hasMoreData); + Assert.Empty(rows); + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfEventQueryProtocolTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfEventQueryProtocolTests.cs new file mode 100644 index 0000000..ee1c1b4 --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfEventQueryProtocolTests.cs @@ -0,0 +1,46 @@ +using ZB.MOM.WW.SPHistorianClient.Wcf; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +public sealed class WcfEventQueryProtocolTests +{ + [Fact] + public void SerializerMatchesInstrumentedNativeEventRequest() + { + HistorianEventQueryAttempt attempt = Assert.Single(HistorianEventQueryProtocol.CreateStartEventQueryAttempts( + new DateTime(2026, 4, 25, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646), + new DateTime(2026, 5, 2, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646), + 3)); + + byte[] expected = Convert.FromBase64String( + "BQBuHAVXwdTcAW5c6X9B2twBAwAAAAAAAAAAAAEAAAAAAAAAAAAAAQADAAAAVQBUAEMAAQEAAAEAAAEAAAAAAAA="); + + Assert.Equal(expected, attempt.RequestBuffer); + Assert.Equal("6b955b02087047a3199a8c74f3eee85c3b49aaa29b05de12eff2dd536f2da0d5", attempt.RequestSha256); + } + + [Fact] + public void NativeEmptyFilterAttemptMatchesDecompiledSaveOrder() + { + HistorianEventQueryAttempt attempt = Assert.Single(HistorianEventQueryProtocol.CreateStartEventQueryAttempts( + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc), + 3)); + + byte[] actual = attempt.RequestBuffer; + + Assert.Equal("native-empty-filter-version5", attempt.Name); + Assert.Equal(3, HistorianEventQueryProtocol.QueryRequestTypeEvent); + Assert.Equal(65, actual.Length); + Assert.Equal([0x05, 0x00], actual[..2]); + Assert.Equal(3u, BitConverter.ToUInt32(actual, 18)); + Assert.Equal(0u, BitConverter.ToUInt32(actual, 22)); + Assert.Equal(0, BitConverter.ToUInt16(actual, 26)); + Assert.Equal(1, BitConverter.ToUInt16(actual, 28)); + Assert.Equal([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], actual[30..37]); + Assert.Equal(65_536u, BitConverter.ToUInt32(actual, 37)); + Assert.Equal([0x03, 0x00, 0x00, 0x00, 0x55, 0x00, 0x54, 0x00, 0x43, 0x00], actual[41..51]); + Assert.Equal([0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00], actual[51..61]); + Assert.Equal([0x00, 0x00, 0x00, 0x00], actual[^4..]); + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfEvidenceTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfEvidenceTests.cs new file mode 100644 index 0000000..dcfb16b --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfEvidenceTests.cs @@ -0,0 +1,105 @@ +using System.Reflection; +using System.ServiceModel; +using ZB.MOM.WW.SPHistorianClient.Wcf; +using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +public sealed class WcfEvidenceTests +{ + [Fact] + public void ServiceContractsUseDecompiledNamesAndNamespace() + { + AssertServiceContract("Hist"); + AssertServiceContract("Hist"); + AssertServiceContract("Retr"); + AssertServiceContract("Retr"); + AssertServiceContract("Stat"); + AssertServiceContract("Stat"); + AssertServiceContract("Storage"); + AssertServiceContract("Trx"); + } + + [Fact] + public void RelayEvidenceIdentifiesHistorySecurityEndpointNames() + { + Assert.Equal("HistCert", HistorianWcfServiceNames.HistoryCertificate); + Assert.Equal("Hist-Integrated", HistorianWcfServiceNames.HistoryIntegrated); + } + + [Fact] + public void KnownOperationAliasesMatchManagedWrapperEvidence() + { + AssertOperation(nameof(IHistoryServiceContract.GetInterfaceVersion), "GetV"); + AssertOperation(nameof(IHistoryServiceContract.OpenConnection), "Open"); + AssertOperation(nameof(IHistoryServiceContract.ValidateClient), "VldC"); + AssertOperation(nameof(IHistoryServiceContract.UpdateClientStatus), "UpdC"); + AssertOperation(nameof(IHistoryServiceContract2.OpenConnection2), "Open2"); + AssertOperation(nameof(IHistoryServiceContract2.ExchangeKey), "ExKey"); + AssertOperation(nameof(IRetrievalServiceContract2.GetTagInfosFromId), "GetTg"); + AssertOperation(nameof(IRetrievalServiceContract3.StartTagQuery), "QTB"); + AssertOperation(nameof(IRetrievalServiceContract4.GetTagExtendedPropertiesFromName), "GetTepByNm"); + AssertOperation(nameof(IStorageServiceContract.OpenStorageConnection), "Open"); + AssertOperation(nameof(IStorageServiceContract.LoadBlocks), "LoadB"); + AssertOperation(nameof(ITransactionServiceContract.GetInterfaceVersion), "GetV"); + AssertDefaultOperation(nameof(IRetrievalServiceContract.StartQuery)); + AssertDefaultOperation(nameof(IRetrievalServiceContract4.StartEventQuery)); + AssertDefaultOperation(nameof(IStatusServiceContract.GetServerTime)); + AssertDefaultOperation(nameof(IStatusServiceContract2.GetSystemParameter)); + AssertOperation(nameof(IStatusServiceContract2.GetHistorianInfo), "GETHI"); + AssertOperation(nameof(IStatusServiceContract2.PingServer), "PNGS"); + AssertOperation(nameof(IStatusServiceContract2.PingPipe), "PNGP"); + } + + [Fact] + public void MdasBindingUsesNetTcpAndCustomContentType() + { + var binding = HistorianWcfBindingFactory.CreateMdasNetTcpBinding(TimeSpan.FromSeconds(5)); + var encoder = binding.CreateBindingElements().Find(); + var endpoint = HistorianWcfBindingFactory.CreateEndpointAddress("localhost", HistorianWcfBindingFactory.DefaultPort, HistorianWcfServiceNames.History); + + Assert.NotNull(encoder); + Assert.Equal("net.tcp://localhost:32568/Hist", endpoint.Uri.AbsoluteUri); + Assert.Equal(MdasMessageEncoder.MdasContentType, encoder.CreateMessageEncoderFactory().Encoder.ContentType); + } + + [Fact] + public void CertificateBindingUsesMdasEncodingOverTransportSecurity() + { + var binding = HistorianWcfBindingFactory.CreateMdasNetTcpCertificateBinding(TimeSpan.FromSeconds(5)); + var elements = binding.CreateBindingElements(); + var encoder = elements.Find(); + var security = elements.Find(); + + Assert.NotNull(encoder); + Assert.NotNull(security); + Assert.Equal(MdasMessageEncoder.MdasContentType, encoder.CreateMessageEncoderFactory().Encoder.ContentType); + } + + private static void AssertServiceContract(string name) + { + var attribute = typeof(TContract).GetCustomAttribute(); + + Assert.NotNull(attribute); + Assert.Equal(name, attribute.Name); + Assert.Equal("aa", attribute.Namespace); + } + + private static void AssertOperation(string methodName, string operationName) + { + var method = typeof(TContract).GetMethod(methodName); + var attribute = method?.GetCustomAttribute(); + + Assert.NotNull(attribute); + Assert.Equal(operationName, attribute.Name); + } + + private static void AssertDefaultOperation(string methodName) + { + var method = typeof(TContract).GetMethod(methodName); + var attribute = method?.GetCustomAttribute(); + + Assert.NotNull(attribute); + Assert.Null(attribute.Name); + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfOpen2ProtocolTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfOpen2ProtocolTests.cs new file mode 100644 index 0000000..c3d6ffd --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfOpen2ProtocolTests.cs @@ -0,0 +1,283 @@ +using System.Text; +using ZB.MOM.WW.SPHistorianClient.Wcf; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +public sealed class WcfOpen2ProtocolTests +{ + [Fact] + public void LegacyVersion1SerializerMatchesDecompiledSaveOpenConnectionParamsLayout() + { + byte[] actual = HistorianOpen2Protocol.SerializeLegacyVersion1(new HistorianOpen2Request( + HostName: "H", + ProcessName: "P", + ProcessId: 0x01020304, + UserName: "U", + Password: Encoding.Unicode.GetBytes("pw"), + ClientType: 4, + ClientVersion: 11, + ConnectionMode: 2, + MetadataNamespace: HistorianMetadataNamespace.Empty)); + + byte[] expected = + [ + 0x01, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x48, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x50, 0x00, + 0x04, 0x03, 0x02, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x55, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x70, 0x00, 0x77, 0x00, + 0x04, + 0x0B, 0x00, + 0x02, 0x00, 0x00, 0x00, + 0x01, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 + ]; + + Assert.Equal(expected, actual); + } + + [Fact] + public void LegacyVersion1SerializerUsesUtf16CodeUnitStringLengths() + { + byte[] actual = HistorianOpen2Protocol.SerializeLegacyVersion1(new HistorianOpen2Request( + HostName: "A\ud83d\ude00", + ProcessName: string.Empty, + ProcessId: 0, + UserName: string.Empty, + Password: [], + ClientType: 4, + ClientVersion: 0, + ConnectionMode: 2, + MetadataNamespace: HistorianMetadataNamespace.Empty)); + + Assert.Equal([0x03, 0x00, 0x00, 0x00], actual[2..6]); + Assert.Equal(Encoding.Unicode.GetBytes("A\ud83d\ude00"), actual[6..12]); + } + + [Fact] + public void NativeErrorParserReadsObservedFiveByteBuffers() + { + HistorianNativeError? error = HistorianOpen2Protocol.TryReadNativeError([0x04, 0xAB, 0x00, 0x00, 0x00]); + + Assert.NotNull(error); + Assert.Equal(4, error.Type); + Assert.Equal(171, error.Code); + Assert.Equal("AuthenticationFailed", error.Name); + } + + [Fact] + public void NativeErrorParserRejectsShortBuffers() + { + Assert.Null(HistorianOpen2Protocol.TryReadNativeError([0x04, 0xAB, 0x00, 0x00])); + } + + [Fact] + public void LegacyOpen2OutputParserReadsObservedWcfLayout() + { + byte[] buffer = + [ + 0x78, 0x56, 0x34, 0x12, + 0x33, 0x22, 0x11, 0x00, + 0x55, 0x44, + 0x77, 0x66, + 0x88, 0x99, 0xAA, 0xBB, + 0xCC, 0xDD, 0xEE, 0xFF, + 0x08, 0x07, 0x06, 0x05, + 0x04, 0x03, 0x02, 0x01, + 0x44, 0x33, 0x22, 0x11 + ]; + + HistorianLegacyOpen2Output? output = HistorianOpen2Protocol.TryReadLegacyOpen2Output(buffer); + + Assert.NotNull(output); + Assert.Equal(0x12345678, output.Handle); + Assert.Equal(new Guid("00112233-4455-6677-8899-aabbccddeeff"), output.StorageSessionId); + Assert.Equal(0x0102030405060708, output.ConnectTimeFileTimeUtc); + Assert.Equal(0x11223344, output.ServerStatus); + } + + [Fact] + public void LegacyOpen2OutputParserRejectsNonLegacyLength() + { + Assert.Null(HistorianOpen2Protocol.TryReadLegacyOpen2Output([0x00])); + } + + [Fact] + public void NativeOpen3OutputParserReadsObservedDeserializerLayout() + { + byte[] buffer = + [ + 0x03, + 0x78, 0x56, 0x34, 0x12, + 0x33, 0x22, 0x11, 0x00, + 0x55, 0x44, + 0x77, 0x66, + 0x88, 0x99, 0xAA, 0xBB, + 0xCC, 0xDD, 0xEE, 0xFF, + 0x08, 0x07, 0x06, 0x05, + 0x04, 0x03, 0x02, 0x01, + 0x18, 0x17, 0x16, 0x15, + 0x14, 0x13, 0x12, 0x11, + 0x44, 0x33, 0x22, 0x11, + 0x00 + ]; + + HistorianNativeOpen3Output? output = HistorianOpen2Protocol.TryReadNativeOpen3Output(buffer); + + Assert.NotNull(output); + Assert.Equal(3, output.ProtocolVersion); + Assert.Equal(0x12345678, output.Handle); + Assert.Equal(new Guid("00112233-4455-6677-8899-aabbccddeeff"), output.StorageSessionId); + Assert.Equal(0x0102030405060708, output.ConnectTimeFileTimeUtc); + Assert.Equal(0x1112131415161718, output.ServerTimeFileTimeUtc); + Assert.Equal([0x44, 0x33, 0x22, 0x11, 0x00], output.TrailingBytes); + } + + [Fact] + public void NativeOpen3OutputParserRejectsUnsupportedVersion() + { + Assert.Null(HistorianOpen2Protocol.TryReadNativeOpen3Output([0x01, 0x00, 0x00, 0x00])); + } + + [Fact] + public void NativeVersion3SerializerMatchesDecompiledFieldOrder() + { + byte[] actual = HistorianOpen2Protocol.SerializeNativeVersion3( + new HistorianOpen2Request( + HostName: "H", + ProcessName: "P", + ProcessId: 0x01020304, + UserName: string.Empty, + Password: [0xAA, 0xBB], + ClientType: 4, + ClientVersion: 11, + ConnectionMode: 1026, + MetadataNamespace: HistorianMetadataNamespace.Empty), + new HistorianClientCommonInfo( + FormatVersion: 3, + ServerNodeName: "S", + ClientNodeName: "C", + ProcessId: 0x11223344, + HcalVersion: 17, + ProcessName: "Proc", + Proxy: string.Empty, + DataSourceId: string.Empty, + ShardId: new Guid("00112233-4455-6677-8899-aabbccddeeff"), + ClientVersion: 0x55667788, + ClientTimestamp: 0x0102030405060708, + ClientDllVersion: string.Empty)); + + byte[] expectedPrefix = + [ + 0x03, + 0x01, 0x00, 0x00, 0x00, 0x48, 0x00, + 0x02, 0x00, 0xAA, 0xBB, + 0x04, + 0x02, 0x04, 0x00, 0x00 + ]; + Assert.Equal(expectedPrefix, actual[..expectedPrefix.Length]); + + Assert.Contains(0x03, actual); + byte[] expectedSuffix = [0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]; + Assert.Equal(expectedSuffix, actual[^expectedSuffix.Length..]); + } + + [Fact] + public void NativeOpenConnection3Version6SerializerAddsObservedPrefixBeforeContent() + { + HistorianOpen2Request request = new( + HostName: "H", + ProcessName: "P", + ProcessId: 0x01020304, + UserName: string.Empty, + Password: [0xAA, 0xBB], + ClientType: 4, + ClientVersion: 11, + ConnectionMode: 1026, + MetadataNamespace: HistorianMetadataNamespace.Empty); + HistorianClientCommonInfo commonInfo = new( + FormatVersion: 3, + ServerNodeName: "S", + ClientNodeName: "C", + ProcessId: 0x11223344, + HcalVersion: 17, + ProcessName: "Proc", + Proxy: string.Empty, + DataSourceId: string.Empty, + ShardId: new Guid("00112233-4455-6677-8899-aabbccddeeff"), + ClientVersion: 0x55667788, + ClientTimestamp: 0x0102030405060708, + ClientDllVersion: string.Empty); + + byte[] actual = HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6( + request, + commonInfo, + new Guid("00112233-4455-6677-8899-aabbccddeeff")); + + byte[] expectedPrefix = + [ + 0x06, + 0x33, 0x22, 0x11, 0x00, + 0x55, 0x44, + 0x77, 0x66, + 0x88, 0x99, 0xAA, 0xBB, + 0xCC, 0xDD, 0xEE, 0xFF, + 0x00 + ]; + Assert.Equal(expectedPrefix, actual[..expectedPrefix.Length]); + byte[] expectedContentPrefix = + [ + 0x01, 0x00, 0x00, 0x00, 0x48, 0x00, + 0x02, 0x00, 0xAA, 0xBB, + 0x04, + 0x02, 0x04, 0x00, 0x00, + 0x01, + 0x01, 0x00, 0x00, + 0x01, 0x00, 0x00, + 0x01, 0x00, 0x00 + ]; + Assert.Equal(expectedContentPrefix, actual[expectedPrefix.Length..(expectedPrefix.Length + expectedContentPrefix.Length)]); + } + + [Fact] + public void NativeOpenConnection3Version6SerializerCanUseSeparateCredentialBlock() + { + HistorianOpen2Request request = new( + HostName: "H", + ProcessName: "P", + ProcessId: 0x01020304, + UserName: string.Empty, + Password: [0xAA, 0xBB], + ClientType: 4, + ClientVersion: 11, + ConnectionMode: 1026, + MetadataNamespace: HistorianMetadataNamespace.Empty); + HistorianClientCommonInfo commonInfo = new( + FormatVersion: 2, + ServerNodeName: string.Empty, + ClientNodeName: string.Empty, + ProcessId: 0, + HcalVersion: 17, + ProcessName: string.Empty, + Proxy: string.Empty, + DataSourceId: string.Empty, + ShardId: Guid.Empty, + ClientVersion: 0, + ClientTimestamp: 0, + ClientDllVersion: string.Empty); + + byte[] actual = HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6( + request, + commonInfo, + Guid.Empty, + [0x00, 0x00, 0x00, 0x00]); + + int hostLengthOffset = 18; + int credentialLengthOffset = hostLengthOffset + 4 + Encoding.Unicode.GetByteCount("H"); + Assert.Equal([0x04, 0x00], actual[credentialLengthOffset..(credentialLengthOffset + 2)]); + Assert.Equal([0x00, 0x00, 0x00, 0x00], actual[(credentialLengthOffset + 2)..(credentialLengthOffset + 6)]); + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfStatusProtocolTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfStatusProtocolTests.cs new file mode 100644 index 0000000..bcc509d --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfStatusProtocolTests.cs @@ -0,0 +1,45 @@ +using ZB.MOM.WW.SPHistorianClient.Wcf; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +public sealed class WcfStatusProtocolTests +{ + [Fact] + public void SystemTimeParserReadsWindowsSystemTimeLayout() + { + byte[] buffer = + [ + 0xEA, 0x07, + 0x04, 0x00, + 0x04, 0x00, + 0x1E, 0x00, + 0x0D, 0x00, + 0x2A, 0x00, + 0x07, 0x00, + 0x7B, 0x00 + ]; + + DateTime? parsed = HistorianStatusProtocol.TryReadSystemTime(buffer); + + Assert.Equal(new DateTime(2026, 4, 30, 13, 42, 7, 123), parsed); + } + + [Fact] + public void SystemTimeParserRejectsShortAndInvalidBuffers() + { + Assert.Null(HistorianStatusProtocol.TryReadSystemTime([0xEA, 0x07])); + + byte[] invalidMonth = + [ + 0xEA, 0x07, + 0x00, 0x00, + 0x04, 0x00, + 0x1E, 0x00, + 0x0D, 0x00, + 0x2A, 0x00, + 0x07, 0x00, + 0x7B, 0x00 + ]; + Assert.Null(HistorianStatusProtocol.TryReadSystemTime(invalidMonth)); + } +} diff --git a/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfTagQueryProtocolTests.cs b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfTagQueryProtocolTests.cs new file mode 100644 index 0000000..e9198fa --- /dev/null +++ b/ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/WcfTagQueryProtocolTests.cs @@ -0,0 +1,276 @@ +using ZB.MOM.WW.SPHistorianClient.Wcf; + +namespace ZB.MOM.WW.SPHistorianClient.Tests; + +public sealed class WcfTagQueryProtocolTests +{ + [Fact] + public void SerializerMatchesInstrumentedNativeTagQueryRequest() + { + HistorianTagQueryAttempt attempt = HistorianTagQueryProtocol.CreateStartTagQueryAttempt( + "TagName eq 'OtOpcUaParityTest_001.Counter'"); + + byte[] expected = Convert.FromBase64String( + "UWcBACoAAABUAGEAZwBOAGEAbQBlACAAZQBxACAAJwBPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIAJwA="); + + Assert.Equal(expected, attempt.RequestBuffer); + Assert.Equal("af1dbcdd3eb0ad91a18882c22252aa74aff82998e96a39b63415ab4792a962ac", attempt.RequestSha256); + } + + [Fact] + public void SerializerUsesDecompiledMarkerVersionAndUtf16Filter() + { + HistorianTagQueryAttempt attempt = HistorianTagQueryProtocol.CreateStartTagQueryAttempt("T*"); + byte[] actual = attempt.RequestBuffer; + + Assert.Equal("native-start-tag-query-version1", attempt.Name); + Assert.Equal(HistorianTagQueryProtocol.NativeStartTagQueryMarker, BitConverter.ToUInt16(actual, 0)); + Assert.Equal(HistorianTagQueryProtocol.NativeStartTagQueryVersion, BitConverter.ToUInt16(actual, 2)); + Assert.Equal(2u, BitConverter.ToUInt32(actual, 4)); + Assert.Equal("T*", System.Text.Encoding.Unicode.GetString(actual, 8, actual.Length - 8)); + } + + [Fact] + public void SerializerMatchesInstrumentedNativeHeaderOnlyTagQueryRequest() + { + HistorianTagQueryAttempt attempt = HistorianTagQueryProtocol.CreateStartTagQueryHeaderOnlyAttempt(); + + Assert.Equal("native-start-tag-query-header-only", attempt.Name); + Assert.Equal(Convert.FromBase64String("UWcBAA=="), attempt.RequestBuffer); + Assert.Equal("17956e4fbe53d5edc0f9170203b013432e4afcc0591c795a10522a98d9fce926", attempt.RequestSha256); + } + + [Fact] + public void ParsesInstrumentedNativeStartTagQueryResponse() + { + byte[] response = Convert.FromBase64String("CAAAAAEAAAA="); + + HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(response); + + Assert.Equal(8u, parsed.QueryHandle); + Assert.Equal(1u, parsed.TagCount); + } + + [Fact] + public void ParsesInstrumentedNativeGetTagInfoResponse() + { + byte[] response = Convert.FromBase64String( + "AQAAAAPDADGEIoxAWOGHSphLPb7L4KpC7gAAAAkdAE90T3BjVWFQYXJpdHlUZXN0XzAwMS5Db3VudGVyCQQATURBUwIDAQIAAADQV/SUZdjcAQoAAAAAAAAAJEAAAAAAAAAkQP4AAAAAAA=="); + + IReadOnlyList tags = HistorianTagQueryProtocol.ParseGetTagInfoResponse(response); + + HistorianTagInfoResponse tag = Assert.Single(tags); + Assert.Equal("OtOpcUaParityTest_001.Counter", tag.TagName); + Assert.Equal(238u, tag.TagKey); + Assert.Equal(new Guid("408c2284-e158-4a87-984b-3dbecbe0aa42"), tag.TypeId); + Assert.Equal([0x03, 0xC3, 0x00, 0x31], tag.NativeDataTypeDescriptor); + Assert.Equal("MDAS", tag.MetadataProvider); + Assert.Equal(2, tag.NativeTagClass); + Assert.Equal(3, tag.StorageType); + Assert.Equal(1, tag.DeadbandType); + Assert.Equal(2, tag.InterpolationType); + } + + [Fact] + public void ParsesDirectWcfGetTagInfoFromNameResponse() + { + byte[] response = Convert.FromBase64String( + "A8MAMYQijEBY4YdKmEs9vsvgqkLuAAAACR0AT3RPcGNVYVBhcml0eVRlc3RfMDAxLkNvdW50ZXIJBABNREFTAgMBAgAAANBX9JRl2NwBCgAAAAAAAAAkQAAAAAAAACRA/gA="); + + HistorianTagInfoResponse tag = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response); + + Assert.Equal("OtOpcUaParityTest_001.Counter", tag.TagName); + Assert.Equal(238u, tag.TagKey); + Assert.Equal([0x03, 0xC3, 0x00, 0x31], tag.NativeDataTypeDescriptor); + Assert.Equal(Models.HistorianDataType.Int4, HistorianWcfTagClient.MapDataType(tag.NativeDataTypeDescriptor)); + } + + [Fact] + public void MapDataType_UInt2Descriptor_ReturnsUInt2() + { + // Built-in SysTimeSec exposes this descriptor (Runtime AnalogTag.IntegerSize=16, + // SignedInteger=0 → UInt16). + Assert.Equal(Models.HistorianDataType.UInt2, HistorianWcfTagClient.MapDataType([0x03, 0xCF, 0x04, 0x09])); + } + + [Theory] + // Captured descriptors from TagMetadataDescriptorProbeTests against a live local Historian. + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x01 }, Models.HistorianDataType.Float)] // SysDataAcqOverallItemsPerSec (RawType=2, IntegerSize=0) + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x02 }, Models.HistorianDataType.Int1)] // SysClassicDataRedirector (DiscreteTag) + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x09 }, Models.HistorianDataType.UInt2)] // SysCritErrCnt (UInt16, StorageType=Delta) + [InlineData(new byte[] { 0x03, 0xCF, 0x04, 0x09 }, Models.HistorianDataType.UInt2)] // SysTimeSec (UInt16, StorageType=Cyclic) + [InlineData(new byte[] { 0x03, 0xCF, 0x04, 0x11 }, Models.HistorianDataType.UInt4)] // SysConfigStatus (UInt32) + [InlineData(new byte[] { 0x03, 0xC3, 0x00, 0x31 }, Models.HistorianDataType.Int4)] // OtOpcUaParityTest_001.Counter (Int32) + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x43 }, Models.HistorianDataType.DoubleByteString)] // SysString + // Inferred from CDataType predicate IL (IsConvertableToDouble/Int64/UInt64, IsEvent, IsStruct, IsString): + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x03 }, Models.HistorianDataType.SingleByteString)] // string class without bit 0x40 (wide flag) + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x04 }, Models.HistorianDataType.Event)] // IsEvent: low 3 bits == 4 + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x05 }, Models.HistorianDataType.Structure)] // IsStruct: low 3 bits == 5 + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x21 }, Models.HistorianDataType.Double)] // IsConvertableToDouble matches 33 + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x29 }, Models.HistorianDataType.Int2)] // IsConvertableToInt64 matches 41 (= UInt16=0x09 + signed bit 0x20) + // Newly extended HistorianDataType enum entries (codes recovered from same predicate IL): + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x08 }, Models.HistorianDataType.UInt1)] // 1-byte unsigned (IsConvertableToUInt64 matches 8) + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x10 }, Models.HistorianDataType.Guid)] // IsGuid: byte == 16 + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x18 }, Models.HistorianDataType.FileTime)] // IsFileTime: byte == 24 + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x19 }, Models.HistorianDataType.Int8)] // 8-byte signed (IsConvertableToInt64 matches 25) + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x39 }, Models.HistorianDataType.UInt8)] // 8-byte unsigned (IsConvertableToUInt64 matches 57) + [InlineData(new byte[] { 0x03, 0xCF, 0x00, 0x81 }, Models.HistorianDataType.Int1)] // Boolean extended (IsBoolean: byte == 129) + public void MapDataType_KnownDescriptors_ReturnsExpectedType(byte[] descriptor, Models.HistorianDataType expected) + { + Assert.Equal(expected, HistorianWcfTagClient.MapDataType(descriptor)); + } + + [Theory] + // Storage-attribute byte (byte 2) variants should still map to the same data type because + // the dispatch is on byte 3. + [InlineData(new byte[] { 0x03, 0xCF, 0x07, 0x09 }, Models.HistorianDataType.UInt2)] + [InlineData(new byte[] { 0x03, 0xCF, 0xFF, 0x11 }, Models.HistorianDataType.UInt4)] + public void MapDataType_StorageAttributeVariants_DispatchesByDataTypeCode(byte[] descriptor, Models.HistorianDataType expected) + { + Assert.Equal(expected, HistorianWcfTagClient.MapDataType(descriptor)); + } + + [Fact] + public void MapDataType_UnknownDataTypeCode_ThrowsEvidenceMissing() + { + // Byte 3 = 0xFF is not yet observed; should throw rather than guess. + Assert.Throws(() => + HistorianWcfTagClient.MapDataType([0x03, 0xCF, 0x00, 0xFF])); + } + + [Fact] + public void MapDataType_WrongFormatVersion_ThrowsEvidenceMissing() + { + // Byte 0 must be 0x03; anything else throws. + Assert.Throws(() => + HistorianWcfTagClient.MapDataType([0x04, 0xCF, 0x00, 0x09])); + } + + [Fact] + public void Parse_FullShape4Strings_PopulatesDescription() + { + byte[] response = BuildSyntheticTagInfo( + descriptor: [0x03, 0xCF, 0x00, 0x09], + tagKey: 42, + strings: ["TAG", "Tag description here", "TAG", "DOMAIN\\user"], + fixedBlock: [0x03, 0x02, 0x01, 0x00], + trailingDoubles: null, + trailingEu: null); + HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response); + Assert.Equal("TAG", parsed.TagName); + Assert.Equal(42u, parsed.TagKey); + Assert.Equal("Tag description here", parsed.Description); + // 4-string shape uses position 1 as Description AND MetadataProvider for back-compat. + Assert.Equal("Tag description here", parsed.MetadataProvider); + } + + [Fact] + public void Parse_TwoStringShape_DoesNotMisinterpretMetadataProviderAsDescription() + { + byte[] response = BuildSyntheticTagInfo( + descriptor: [0x03, 0xC3, 0x00, 0x31], + tagKey: 99, + strings: ["EXT.TAG.NAME", "MDAS"], + fixedBlock: [0x02, 0x03, 0x01, 0x02], + trailingDoubles: null, + trailingEu: null); + HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response); + Assert.Equal("EXT.TAG.NAME", parsed.TagName); + Assert.Equal("MDAS", parsed.MetadataProvider); + // 2-string shape: don't conflate MetadataProvider with Description. + Assert.Null(parsed.Description); + } + + [Fact] + public void Parse_TrailingDoublesAndEu_PopulatesMinMaxAndUnit() + { + byte[] response = BuildSyntheticTagInfo( + descriptor: [0x03, 0xCF, 0x00, 0x09], + tagKey: 12, + strings: ["TAG", "desc", "TAG", "DOMAIN\\u"], + fixedBlock: [0x03, 0x02, 0x01, 0x00], + trailingDoubles: (0.0, 59.0), + trailingEu: "Seconds"); + HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response); + Assert.Equal(0.0, parsed.MinEU); + Assert.Equal(59.0, parsed.MaxEU); + Assert.Equal("Seconds", parsed.EngineeringUnit); + } + + [Fact] + public void Parse_TrailingNoDoubles_LeavesMinMaxNull() + { + byte[] response = BuildSyntheticTagInfo( + descriptor: [0x03, 0xCF, 0x00, 0x02], + tagKey: 97, + strings: ["DiscreteTag", "Description", "DiscreteTag", "DOMAIN\\u"], + fixedBlock: [0x03, 0x02, 0x01, 0x00], + trailingDoubles: null, + trailingEu: null); + HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response); + Assert.Null(parsed.MinEU); + Assert.Null(parsed.MaxEU); + Assert.Null(parsed.EngineeringUnit); + } + + private static byte[] BuildSyntheticTagInfo( + byte[] descriptor, + uint tagKey, + string[] strings, + byte[] fixedBlock, + (double Min, double Max)? trailingDoubles, + string? trailingEu) + { + using MemoryStream ms = new(); + using BinaryWriter w = new(ms); + w.Write(descriptor); // 4 bytes + w.Write(System.Guid.NewGuid().ToByteArray()); // 16 bytes + w.Write(tagKey); // 4 bytes + foreach (string s in strings) + { + byte[] ascii = System.Text.Encoding.ASCII.GetBytes(s); + w.Write((byte)0x09); + w.Write((ushort)ascii.Length); + w.Write(ascii); + } + w.Write(fixedBlock); // 4 bytes + // Trailing region: padding + (optional doubles aligned to 8) + (optional EU compact ASCII). + // To keep doubles 8-byte aligned within the trailing region, pad to next 8-byte boundary. + long trailingStart = ms.Length; + // Plain alignment: add zero padding so doubles start at a stable 8-byte aligned offset + // within the trailing region — the parser scans alignments 0..7 so any padding works. + if (trailingDoubles is { } d) + { + w.Write(d.Min); + w.Write(d.Max); + } + if (trailingEu is not null) + { + byte[] euAscii = System.Text.Encoding.ASCII.GetBytes(trailingEu); + w.Write((byte)0x09); + w.Write((ushort)euAscii.Length); + w.Write(euAscii); + } + return ms.ToArray(); + } + + [Fact] + public void ParsesManagedWcfLikeTagNamesResponse() + { + byte[] response = Convert.FromBase64String( + "AQAAAB0AAABPAHQATwBwAGMAVQBhAFAAYQByAGkAdAB5AFQAZQBzAHQAXwAwADAAMQAuAEMAbwB1AG4AdABlAHIA"); + + IReadOnlyList tagNames = HistorianTagQueryProtocol.ParseGetLikeTagNamesResponse(response); + + Assert.Equal(["OtOpcUaParityTest_001.Counter"], tagNames); + } + + [Theory] + [InlineData("*", "%")] + [InlineData("Sys*", "Sys%")] + [InlineData("OtOpcUaParityTest%", "OtOpcUaParityTest%")] + public void NormalizesPublicWildcardToHistorianLikeWildcard(string filter, string expected) + { + Assert.Equal(expected, HistorianWcfTagClient.NormalizeLikeFilter(filter)); + } +}