Merge re/grpc-2023r2-handshake: M0 gRPC parity (probe/system-param/metadata/browse) + handshake fix
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Shared 2023 R2 gRPC authentication handshake. Opens an authenticated History session over an
|
||||
/// existing <see cref="HistorianGrpcConnection"/> and returns the transient client handle used by
|
||||
/// the Retrieval/Status services. Extracted from <see cref="HistorianGrpcReadOrchestrator"/> so the
|
||||
/// read, status, and (future) browse/metadata gRPC paths all drive the identical chain:
|
||||
/// <c>HistoryService.GetInterfaceVersion → StorageService.ValidateClientCredential (token loop) →
|
||||
/// HistoryService.OpenConnection</c>. The byte payloads (OpenConnection3 v6 request, NTLM token
|
||||
/// framing) are the proven 2020 protocol and transfer unchanged inside protobuf <c>bytes</c> fields.
|
||||
///
|
||||
/// See <see cref="HistorianGrpcReadOrchestrator"/> for the op-routing rationale (the Negotiate loop
|
||||
/// belongs on StorageService.ValidateClientCredential, NOT HistoryService.ExchangeKey).
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcHandshake
|
||||
{
|
||||
/// <summary>
|
||||
/// The handles produced by a successful OpenConnection. <see cref="ClientHandle"/> is the
|
||||
/// transient <c>uint</c> session token used by StartQuery/GetSystemParameter and the other
|
||||
/// uint-handle ops. <see cref="StorageSessionId"/> is the storage-session GUID used (formatted
|
||||
/// uppercase via <see cref="StringHandle"/>) by the string-handle ops
|
||||
/// (GetTagInfosFromName, GetTagExtendedPropertiesFromName, ExecuteSqlCommand, ...).
|
||||
/// </summary>
|
||||
internal readonly record struct Session(uint ClientHandle, Guid StorageSessionId)
|
||||
{
|
||||
/// <summary>The storage GUID in the uppercase "D" form the native string-handle ops require.</summary>
|
||||
public string StringHandle => StorageSessionId.ToString("D").ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Convenience overload for callers that only need the uint client handle.</summary>
|
||||
public static uint OpenAuthenticatedConnection(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
=> OpenSession(connection, options, cancellationToken).ClientHandle;
|
||||
|
||||
public static Session OpenSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
|
||||
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, options);
|
||||
|
||||
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
||||
HistorianNativeHandshake.RunTokenRounds(
|
||||
(handle, wrapped, _) =>
|
||||
{
|
||||
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
|
||||
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
byte[] serverOutput = response.OutBuff?.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, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
||||
return new Session(clientHandle, storageSessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Grpc.Core;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC reachability probe (roadmap item R0.4). Mirrors <see cref="Wcf.HistorianWcfProbe"/>
|
||||
/// over the gRPC transport: it calls the unauthenticated <c>GetInterfaceVersion</c> RPC on the
|
||||
/// History, Retrieval, and Status services and applies the same success criteria. No credentials
|
||||
/// are required — these RPCs run before the SSPI/Negotiate token loop — so the probe works even
|
||||
/// when authentication is unavailable.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcProbe
|
||||
{
|
||||
public static async Task<bool> ProbeAsync(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return await Task.Run(() => Probe(options, cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool Probe(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
DateTime deadline = DateTime.UtcNow.Add(options.ConnectTimeout > TimeSpan.Zero ? options.ConnectTimeout : TimeSpan.FromSeconds(5));
|
||||
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
GrpcHistory.GetInterfaceVersionResponse history = historyClient.GetInterfaceVersion(
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrieval = retrievalClient.GetRetrievalInterfaceVersion(
|
||||
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
GrpcStatus.GetStatusInterfaceVersionResponse status = statusClient.GetStatusInterfaceVersion(
|
||||
new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||
|
||||
return history.UiError == 0
|
||||
&& history.UiVersion > 0
|
||||
&& retrieval.UiError == 0
|
||||
&& retrieval.UiVersion > 0
|
||||
&& status.UiError == 0;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
@@ -16,17 +15,23 @@ namespace AVEVA.Historian.Client.Grpc;
|
||||
///
|
||||
/// Operation mapping (2020 WCF → 2023 R2 gRPC):
|
||||
/// Hist.GetInterfaceVersion → HistoryService.GetInterfaceVersion
|
||||
/// Hist.ValidateClientCredential (loop) → HistoryService.ExchangeKey (loop)
|
||||
/// Hist.ValidateClientCredential (loop) → StorageService.ValidateClientCredential (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.
|
||||
/// LIVE-VERIFIED 2026-06-21 against a real 2023 R2 server (interface versions: History=12,
|
||||
/// Retrieval=4, Storage=4). The SSPI/Negotiate token loop maps to
|
||||
/// <c>StorageService.ValidateClientCredential(Handle, InBuff)→(status, OutBuff)</c> — the op that
|
||||
/// kept the 2020 inBuff/outBuff token framing. The gRPC HistoryService dropped
|
||||
/// ValidateClientCredential and gained <c>ExchangeKey</c>, but ExchangeKey is a SEPARATE
|
||||
/// key-exchange/cert-path op, NOT the Negotiate loop: feeding it an NTLM token is rejected at
|
||||
/// round 0 regardless of credentials. An earlier revision wrongly routed the loop to ExchangeKey;
|
||||
/// routing it to StorageService.ValidateClientCredential completes the full read chain. The byte
|
||||
/// payloads (OpenConnection3 v6, token framing, DataQueryRequest, row buffers) are the proven 2020
|
||||
/// protocol and transfer unchanged — only the History interface integer differs (12 vs the 2020
|
||||
/// value 11), and that version is buffer-compatible (a live read returns rows).
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcReadOrchestrator
|
||||
{
|
||||
@@ -159,50 +164,7 @@ internal sealed class HistorianGrpcReadOrchestrator
|
||||
}
|
||||
|
||||
private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
|
||||
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options);
|
||||
|
||||
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;
|
||||
}
|
||||
=> HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, _options, cancellationToken);
|
||||
|
||||
private List<HistorianSample> RunQuery(
|
||||
HistorianGrpcConnection connection,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Grpc.Core;
|
||||
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC status client (roadmap item R0.3). Mirrors
|
||||
/// <see cref="Wcf.HistorianWcfStatusClient"/> over the gRPC transport: it opens an authenticated
|
||||
/// History session via <see cref="HistorianGrpcHandshake"/> and queries the StatusService for the
|
||||
/// resulting client handle. <c>GetSystemParameter</c> carries the parameter name as a protobuf
|
||||
/// string and returns the value string directly — there is no opaque native buffer to decode.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcStatusClient
|
||||
{
|
||||
public static Task<string?> GetSystemParameterAsync(
|
||||
HistorianClientOptions options,
|
||||
string parameterName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
|
||||
return Task.Run(() => GetSystemParameter(options, parameterName, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private static string? GetSystemParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken);
|
||||
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
GrpcStatus.GetSystemParameterResponse response = statusClient.GetSystemParameter(
|
||||
new GrpcStatus.GetSystemParameterRequest { UiHandle = clientHandle, StrParameterName = parameterName },
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
return (response.Status?.BSuccess ?? false) ? response.StrParameterValue : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC tag-metadata + browse client (roadmap items R0.2 metadata, R0.1 browse).
|
||||
/// Browse drives <c>StartTagQuery</c> (OData filter) → paged <c>QueryTag</c> → <c>EndTagQuery</c>
|
||||
/// (see <see cref="BrowseTagNamesAsync"/> and <c>docs/reverse-engineering/grpc-tag-query-odata.md</c>).
|
||||
/// Unlike the WCF singular
|
||||
/// <c>GetTagInfoFromName</c> (a <c>uint</c>-handle op), the gRPC front door exposes the plural
|
||||
/// <c>RetrievalService.GetTagInfosFromName</c> — a <b>string-handle</b> op keyed off the Open2
|
||||
/// storage-session GUID (uppercase). The request <c>btTagNames</c> buffer and response
|
||||
/// <c>btTagInfos</c> buffer carry the proven native encodings:
|
||||
/// <list type="bullet">
|
||||
/// <item>request <c>btTagNames</c> = <c>uint count</c> + per-name(<c>uint charCount</c> + UTF-16LE)</item>
|
||||
/// <item>response <c>btTagInfos</c> = <c>uint tagCount</c> + per-tag CTagMetadata record
|
||||
/// (the same record <see cref="HistorianTagQueryProtocol.ParseGetTagInfoResponse"/> decodes)</item>
|
||||
/// </list>
|
||||
/// The string-handle "wall" that blocks this op family on the 2020 WCF transport does not apply on
|
||||
/// the gRPC front door (different envelope/registration) — see
|
||||
/// <c>docs/reverse-engineering/wcf-string-handle-wall.md</c>.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcTagClient
|
||||
{
|
||||
public static Task<HistorianTagMetadata?> GetTagMetadataAsync(
|
||||
HistorianClientOptions options,
|
||||
string tag,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return Task.Run(() => GetTagMetadata(options, tag, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private static HistorianTagMetadata? GetTagMetadata(HistorianClientOptions options, string tag, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] tagInfos = GetTagInfosRaw(options, [tag], cancellationToken);
|
||||
if (tagInfos.Length < 4)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<HistorianTagInfoResponse> parsed = HistorianTagQueryProtocol.ParseGetTagInfoResponse(tagInfos);
|
||||
if (parsed.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
HistorianTagInfoResponse info = parsed[0];
|
||||
return new HistorianTagMetadata(
|
||||
Name: info.TagName,
|
||||
Key: info.TagKey,
|
||||
DataType: HistorianWcfTagClient.MapDataType(info.NativeDataTypeDescriptor),
|
||||
Description: info.Description ?? info.MetadataProvider,
|
||||
EngineeringUnit: info.EngineeringUnit ?? string.Empty,
|
||||
MinRaw: info.MinEU,
|
||||
MaxRaw: info.MaxEU);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a single <c>GetTagInfosFromName</c> call and returns the raw native <c>btTagInfos</c>
|
||||
/// response buffer. Internal so reverse-engineering probes can capture the framing.
|
||||
/// </summary>
|
||||
internal static byte[] GetTagInfosRaw(HistorianClientOptions options, IReadOnlyList<string> tags, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
byte[] requestBuffer = BuildTagNamesBuffer(tags);
|
||||
GrpcRetrieval.GetTagInfosFromNameResponse response = retrievalClient.GetTagInfosFromName(
|
||||
new GrpcRetrieval.GetTagInfosFromNameRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtTagNames = ByteString.CopyFrom(requestBuffer),
|
||||
UiSequence = 0
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
if (!(response.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC GetTagInfosFromName failed (errorLen={error.Length}).");
|
||||
}
|
||||
|
||||
return response.BtTagInfos?.ToByteArray() ?? [];
|
||||
}
|
||||
|
||||
// QueryTag (browse paging) request framing, recovered from the .rdata packet-descriptor table
|
||||
// in aahClientManaged.dll (entries {0x6751,1}=StartTagQuery, {0x6752,1}=QueryTag) and confirmed
|
||||
// live: btRequest = u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count.
|
||||
private const ushort QueryTagPacketMarker = 0x6752;
|
||||
private const ushort TagQueryHeaderVersion = 1;
|
||||
private const ushort QueryTagModeNames = 1; // queryType 1 returns tag-name rows
|
||||
private const uint BrowsePageSize = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Browses tag names over gRPC (roadmap item R0.1). Drives
|
||||
/// <c>StartTagQuery</c> (OData filter) → paged <c>QueryTag</c> → <c>EndTagQuery</c> on the
|
||||
/// RetrievalService. The 2023 R2 metadata-server parses the filter as <b>OData</b>, so the SDK's
|
||||
/// glob filter is translated via <see cref="GlobToODataFilter"/>. Each QueryTag page returns
|
||||
/// <c>uint count + per-name(uint charCount + UTF-16LE)</c>, decoded by
|
||||
/// <see cref="HistorianTagQueryProtocol.ParseGetLikeTagNamesResponse"/>.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<string> BrowseTagNamesAsync(
|
||||
HistorianClientOptions options,
|
||||
string filter,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<string> names = await Task.Run(() => BrowseTagNames(options, filter, cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||
foreach (string name in names)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return name;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> BrowseTagNames(HistorianClientOptions options, string filter, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||
|
||||
byte[] startRequest = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(GlobToODataFilter(filter)).RequestBuffer;
|
||||
GrpcRetrieval.StartTagQueryResponse start = retrievalClient.StartTagQuery(
|
||||
new GrpcRetrieval.StartTagQueryRequest { StrHandle = session.StringHandle, BtRequest = ByteString.CopyFrom(startRequest) },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
if (!(start.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] error = start.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC StartTagQuery failed (errorLen={error.Length}).");
|
||||
}
|
||||
|
||||
HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(start.BtResponse?.ToByteArray() ?? []);
|
||||
List<string> names = new(checked((int)parsed.TagCount));
|
||||
try
|
||||
{
|
||||
uint startIndex = 0;
|
||||
while (names.Count < parsed.TagCount)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
uint page = Math.Min(BrowsePageSize, parsed.TagCount - (uint)names.Count);
|
||||
GrpcRetrieval.QueryTagResponse query = retrievalClient.QueryTag(
|
||||
new GrpcRetrieval.QueryTagRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
UiQueryHandle = parsed.QueryHandle,
|
||||
BtRequest = ByteString.CopyFrom(BuildQueryTagRequest(QueryTagModeNames, startIndex, page))
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
if (!(query.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] error = query.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC QueryTag failed (errorLen={error.Length}).");
|
||||
}
|
||||
|
||||
IReadOnlyList<string> pageNames = HistorianTagQueryProtocol.ParseTagNameQueryPage(query.BtResonse?.ToByteArray() ?? []);
|
||||
if (pageNames.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
names.AddRange(pageNames);
|
||||
startIndex += (uint)pageNames.Count;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
retrievalClient.EndTagQuery(
|
||||
new GrpcRetrieval.EndTagQueryRequest { StrHandle = session.StringHandle, UiQueryHandle = parsed.QueryHandle },
|
||||
connection.Metadata, Deadline(), CancellationToken.None);
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/// <summary>Builds the QueryTag paging request: u16 marker(0x6752) + u16 version + u16 queryType + u32 startIndex + u32 count.</summary>
|
||||
internal static byte[] BuildQueryTagRequest(ushort queryType, uint startIndex, uint count)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||||
writer.Write(QueryTagPacketMarker);
|
||||
writer.Write(TagQueryHeaderVersion);
|
||||
writer.Write(queryType);
|
||||
writer.Write(startIndex);
|
||||
writer.Write(count);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translates the SDK's glob filter (<c>*</c> wildcard) into the OData filter the 2023 R2
|
||||
/// metadata-server's <c>StartActiveTagnamesQuery</c> expects. Single-quotes are OData-escaped.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>*</c> / empty → no filter (all tags)</item>
|
||||
/// <item><c>Pre*</c> → <c>startswith(TagName,'Pre')</c></item>
|
||||
/// <item><c>*suf</c> → <c>endswith(TagName,'suf')</c></item>
|
||||
/// <item><c>*mid*</c> → <c>contains(TagName,'mid')</c></item>
|
||||
/// <item><c>a*b</c> → <c>startswith(TagName,'a') and endswith(TagName,'b')</c></item>
|
||||
/// <item><c>Exact</c> → <c>TagName eq 'Exact'</c></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static string GlobToODataFilter(string filter)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter) || filter == "*")
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
static string Esc(string s) => s.Replace("'", "''");
|
||||
|
||||
bool starStart = filter.StartsWith('*');
|
||||
bool starEnd = filter.EndsWith('*');
|
||||
string core = filter.Trim('*');
|
||||
if (core.Length == 0)
|
||||
{
|
||||
return string.Empty; // "**" etc.
|
||||
}
|
||||
|
||||
if (filter.IndexOf('*') < 0)
|
||||
{
|
||||
return $"TagName eq '{Esc(filter)}'";
|
||||
}
|
||||
|
||||
if (starStart && starEnd && !core.Contains('*'))
|
||||
{
|
||||
return $"contains(TagName,'{Esc(core)}')";
|
||||
}
|
||||
|
||||
if (starEnd && !core.Contains('*') && !starStart)
|
||||
{
|
||||
return $"startswith(TagName,'{Esc(core)}')";
|
||||
}
|
||||
|
||||
if (starStart && !core.Contains('*') && !starEnd)
|
||||
{
|
||||
return $"endswith(TagName,'{Esc(core)}')";
|
||||
}
|
||||
|
||||
// Internal wildcard(s): anchor on the prefix before the first '*' and the suffix after the last.
|
||||
string prefix = filter[..filter.IndexOf('*')];
|
||||
string suffix = filter[(filter.LastIndexOf('*') + 1)..];
|
||||
List<string> parts = [];
|
||||
if (prefix.Length > 0)
|
||||
{
|
||||
parts.Add($"startswith(TagName,'{Esc(prefix)}')");
|
||||
}
|
||||
if (suffix.Length > 0)
|
||||
{
|
||||
parts.Add($"endswith(TagName,'{Esc(suffix)}')");
|
||||
}
|
||||
return parts.Count > 0 ? string.Join(" and ", parts) : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE).</summary>
|
||||
internal static byte[] BuildTagNamesBuffer(IReadOnlyList<string> tags)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||||
|
||||
writer.Write((uint)tags.Count);
|
||||
foreach (string tag in tags)
|
||||
{
|
||||
writer.Write((uint)tag.Length);
|
||||
if (tag.Length > 0)
|
||||
{
|
||||
writer.Write(Encoding.Unicode.GetBytes(tag));
|
||||
}
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? await Grpc.HistorianGrpcProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false)
|
||||
: await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
||||
@@ -129,13 +131,17 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
||||
return HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? Grpc.HistorianGrpcTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken)
|
||||
: HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? Grpc.HistorianGrpcTagClient.GetTagMetadataAsync(_options, tag, cancellationToken)
|
||||
: HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -43,6 +43,15 @@ internal static class HistorianServerVersionGate
|
||||
public const uint RetrievalInterfaceVersion = 4;
|
||||
public const uint TransactionInterfaceVersion = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 R2 gRPC HistoryService reports interface version 12. It is buffer-compatible with
|
||||
/// the 2020 version 11 — the OpenConnection3 v6 / token / DataQueryRequest / row buffers are
|
||||
/// byte-identical — confirmed by a live end-to-end gRPC read against a real 2023 R2 server
|
||||
/// (2026-06-21). So both 11 and 12 are accepted for History. (Retrieval reported 4, matching
|
||||
/// the 2020 value, so it needs no widening.)
|
||||
/// </summary>
|
||||
public const uint HistoryInterfaceVersionGrpc2023R2 = 12;
|
||||
|
||||
/// <summary>
|
||||
/// True when the service interface reports a meaningful version that should be matched.
|
||||
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0).
|
||||
@@ -56,7 +65,7 @@ internal static class HistorianServerVersionGate
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>The interface version this SDK's serializers target for a value-gated service.</summary>
|
||||
/// <summary>The canonical interface version this SDK's serializers target for a value-gated service.</summary>
|
||||
public static uint ExpectedVersion(HistorianServiceInterface service) => service switch
|
||||
{
|
||||
HistorianServiceInterface.History => HistoryInterfaceVersion,
|
||||
@@ -65,6 +74,18 @@ internal static class HistorianServerVersionGate
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// All interface versions accepted for a value-gated service. Usually a single value, but
|
||||
/// History accepts both the 2020 value (11) and the buffer-compatible 2023 R2 gRPC value (12).
|
||||
/// </summary>
|
||||
public static uint[] AcceptedVersions(HistorianServiceInterface service) => service switch
|
||||
{
|
||||
HistorianServiceInterface.History => [HistoryInterfaceVersion, HistoryInterfaceVersionGrpc2023R2],
|
||||
HistorianServiceInterface.Retrieval => [RetrievalInterfaceVersion],
|
||||
HistorianServiceInterface.Transaction => [TransactionInterfaceVersion],
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="ProtocolEvidenceMissingException"/> when version verification is enabled
|
||||
/// and the server's reported interface version differs from the version this SDK targets.
|
||||
@@ -80,14 +101,15 @@ internal static class HistorianServerVersionGate
|
||||
return;
|
||||
}
|
||||
|
||||
uint expected = ExpectedVersion(service);
|
||||
if (reportedVersion == expected)
|
||||
uint[] accepted = AcceptedVersions(service);
|
||||
if (Array.IndexOf(accepted, reportedVersion) >= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string acceptedList = string.Join(", ", accepted);
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"{service} interface version {reportedVersion} (this SDK's serializers target version {expected}); " +
|
||||
$"{service} interface version {reportedVersion} (this SDK's serializers target version {acceptedList}); " +
|
||||
$"set {nameof(HistorianClientOptions)}.{nameof(HistorianClientOptions.VerifyServerInterfaceVersion)}=false to bypass at your own risk");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,9 @@ internal sealed class Historian2020ProtocolDialect
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
return Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
|
||||
return UseGrpc
|
||||
? HistorianGrpcStatusClient.GetSystemParameterAsync(_options, name, cancellationToken)
|
||||
: Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string?> GetRuntimeParameterAsync(string name, CancellationToken cancellationToken)
|
||||
|
||||
@@ -33,7 +33,7 @@ internal static class HistorianNativeHandshake
|
||||
/// upper-case context-key GUID, <paramref name="wrappedToken"/> is the AVEVA-wrapped SSPI
|
||||
/// token (round byte + length + token). The WCF path maps this to
|
||||
/// <c>Hist.ValidateClientCredential</c>; the gRPC path maps it to
|
||||
/// <c>HistoryService.ExchangeKey</c> (the renamed handshake op).
|
||||
/// <c>StorageService.ValidateClientCredential</c> (the op that kept the 2020 token framing).
|
||||
/// </summary>
|
||||
internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round);
|
||||
|
||||
@@ -70,7 +70,8 @@ internal static class HistorianNativeHandshake
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"Credential token round {round} rejected (errorLen={error.Length}).");
|
||||
throw new InvalidOperationException(
|
||||
$"Credential token round {round} rejected (errorLen={error.Length}).{DescribeError(error)}");
|
||||
}
|
||||
|
||||
ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput);
|
||||
@@ -162,4 +163,32 @@ internal static class HistorianNativeHandshake
|
||||
int slash = userName.IndexOf('\\');
|
||||
return slash > 0 ? userName[(slash + 1)..] : userName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a diagnostic suffix for a rejected credential round: the decoded native error
|
||||
/// (type/code/name) plus a short hex + printable-ASCII preview of the server error buffer.
|
||||
/// Keeps secrets out — error buffers carry server status codes/messages, not credentials.
|
||||
/// </summary>
|
||||
private static string DescribeError(byte[] error)
|
||||
{
|
||||
if (error.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
HistorianNativeError? native = HistorianOpen2Protocol.TryReadNativeError(error);
|
||||
string nativePart = native is null
|
||||
? string.Empty
|
||||
: $" native(type={native.Type}, code={native.Code}{(native.Name is null ? string.Empty : $", {native.Name}")})";
|
||||
|
||||
ReadOnlySpan<byte> preview = error.AsSpan(0, Math.Min(error.Length, 64));
|
||||
string hex = Convert.ToHexString(preview);
|
||||
char[] ascii = new char[preview.Length];
|
||||
for (int i = 0; i < preview.Length; i++)
|
||||
{
|
||||
ascii[i] = preview[i] is >= 0x20 and < 0x7F ? (char)preview[i] : '.';
|
||||
}
|
||||
|
||||
return $"{nativePart} hex={hex} ascii=\"{new string(ascii)}\"";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,34 @@ internal static class HistorianTagQueryProtocol
|
||||
return tagNames;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses one page of a gRPC <c>QueryTag</c> tag-name response: <c>uint count + per-name(uint
|
||||
/// charCount + UTF-16LE)</c>, then a trailing region (NextIndex + optional metadata buffer) that
|
||||
/// is intentionally ignored. Unlike <see cref="ParseGetLikeTagNamesResponse"/> this tolerates the
|
||||
/// trailer rather than requiring the buffer to end exactly after the names.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ParseTagNameQueryPage(ReadOnlySpan<byte> response)
|
||||
{
|
||||
if (response.Length < 4)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
int cursor = 0;
|
||||
uint count = ReadUInt32(response, ref cursor);
|
||||
List<string> tagNames = new(checked((int)count));
|
||||
for (uint index = 0; index < count; index++)
|
||||
{
|
||||
uint charLength = ReadUInt32(response, ref cursor);
|
||||
int byteLength = checked((int)charLength * 2);
|
||||
EnsureAvailable(response, cursor, byteLength);
|
||||
tagNames.Add(Encoding.Unicode.GetString(response.Slice(cursor, byteLength)));
|
||||
cursor += byteLength;
|
||||
}
|
||||
|
||||
return tagNames;
|
||||
}
|
||||
|
||||
private static void WriteHistorianString(BinaryWriter writer, string value)
|
||||
{
|
||||
writer.Write((uint)value.Length);
|
||||
|
||||
Reference in New Issue
Block a user