From 0e19adae68820cfd935362b4f75b1d175229c500 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 14:35:52 -0400 Subject: [PATCH] gRPC M0 R0.2: tag metadata over gRPC (GetTagInfosFromName, live-verified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes HistorianClient.GetTagMetadataAsync over gRPC when Transport==RemoteGrpc, via the new Grpc/HistorianGrpcTagClient calling RetrievalService.GetTagInfosFromName (the plural string-handle metadata op). - String handle = the Open2 storage-session GUID formatted uppercase (the format that resolves the native string-handle path); threaded out of the shared handshake via a new HistorianGrpcHandshake.Session { ClientHandle, StorageSessionId, StringHandle }. - Request btTagNames = uint count + per-name(uint charCount + UTF-16LE) — golden-byte unit-tested (BuildTagNamesBuffer). - Response btTagInfos = uint count + CTagMetadata records — decoded by the existing HistorianTagQueryProtocol.ParseGetTagInfoResponse; data type via the shared MapDataType. The 2020 WCF string-handle wall does NOT apply on the gRPC front door, as the string-handle-wall RE note predicted. LIVE-VERIFIED against a real 2023 R2 server: GetTagMetadataAsync returns the requested tag with a valid decoded data type. 216 unit tests pass. Captured framing confirmed live then discarded; no tag names or identities committed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- docs/plans/hcal-roadmap.md | 7 ++ .../Grpc/HistorianGrpcHandshake.cs | 24 +++- .../Grpc/HistorianGrpcTagClient.cs | 110 ++++++++++++++++++ src/AVEVA.Historian.Client/HistorianClient.cs | 4 +- .../HistorianGrpcIntegrationTests.cs | 20 ++++ .../HistorianGrpcTransportTests.cs | 17 +++ 6 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index 37a1d0b..685aa10 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -29,6 +29,13 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from (`StatusService.GetSystemParameter`); routed in the dialect. Built + unit-tested + **LIVE-VERIFIED 2026-06-21** against a real 2023 R2 server (returned `HistorianVersion`). Code path is the proven handshake + a single string-in/string-out RPC. +- ✅ **R0.2 Tag metadata over gRPC** — `Grpc/HistorianGrpcTagClient.GetTagMetadataAsync` + (`RetrievalService.GetTagInfosFromName`, the plural **string-handle** op). `GetTagMetadataAsync` + routes over gRPC when `Transport==RemoteGrpc`. Request `btTagNames` = `uint count + per-name(uint + charCount + UTF-16LE)` (golden-byte unit-tested); response `btTagInfos` = `uint count + CTagMetadata` + records (reuses `ParseGetTagInfoResponse`); string handle = uppercase Open2 storage GUID. The 2020 + WCF string-handle wall does **not** apply on the gRPC front door (as predicted). **LIVE-VERIFIED + 2026-06-21** — `GetTagMetadataAsync` returned the requested tag + a valid data type. > ℹ️ **Auth note (2026-06-21, resolved):** an apparent NTLM round-1 `SEC_E_LOGON_DENIED` blocker > turned out to be a **test-harness credential-parsing bug**, not a server/account/SDK issue — the diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs index a65ed1c..787adf0 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs @@ -20,10 +20,30 @@ namespace AVEVA.Historian.Client.Grpc; /// internal static class HistorianGrpcHandshake { + /// + /// The handles produced by a successful OpenConnection. is the + /// transient uint session token used by StartQuery/GetSystemParameter and the other + /// uint-handle ops. is the storage-session GUID used (formatted + /// uppercase via ) by the string-handle ops + /// (GetTagInfosFromName, GetTagExtendedPropertiesFromName, ExecuteSqlCommand, ...). + /// + internal readonly record struct Session(uint ClientHandle, Guid StorageSessionId) + { + /// The storage GUID in the uppercase "D" form the native string-handle ops require. + public string StringHandle => StorageSessionId.ToString("D").ToUpperInvariant(); + } + + /// Convenience overload for callers that only need the uint client handle. 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); @@ -68,7 +88,7 @@ internal static class HistorianGrpcHandshake throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length})."); } - (uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response); - return clientHandle; + (uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response); + return new Session(clientHandle, storageSessionId); } } diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs new file mode 100644 index 0000000..83433bf --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs @@ -0,0 +1,110 @@ +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; + +/// +/// 2023 R2 gRPC tag-metadata client (roadmap item R0.2). Unlike the WCF singular +/// GetTagInfoFromName (a uint-handle op), the gRPC front door exposes the plural +/// RetrievalService.GetTagInfosFromName — a string-handle op keyed off the Open2 +/// storage-session GUID (uppercase). The request btTagNames buffer and response +/// btTagInfos buffer carry the proven native encodings: +/// +/// request btTagNames = uint count + per-name(uint charCount + UTF-16LE) +/// response btTagInfos = uint tagCount + per-tag CTagMetadata record +/// (the same record decodes) +/// +/// 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 +/// docs/reverse-engineering/wcf-string-handle-wall.md. +/// +internal static class HistorianGrpcTagClient +{ + public static Task 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 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); + } + + /// + /// Issues a single GetTagInfosFromName call and returns the raw native btTagInfos + /// response buffer. Internal so reverse-engineering probes can capture the framing. + /// + internal static byte[] GetTagInfosRaw(HistorianClientOptions options, IReadOnlyList 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() ?? []; + } + + /// Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE). + internal static byte[] BuildTagNamesBuffer(IReadOnlyList 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(); + } +} diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index 277b362..7b5513a 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -104,7 +104,9 @@ public sealed class HistorianClient : IAsyncDisposable public Task 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 GetConnectionStatusAsync(CancellationToken cancellationToken = default) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 6cbafa1..a140dfe 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -66,6 +66,26 @@ public sealed class HistorianGrpcIntegrationTests Assert.False(string.IsNullOrWhiteSpace(version)); } + [Fact] + public async Task GetTagMetadataAsync_OverGrpc_ReturnsRequestedTag() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + string? tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(tag) + || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) + { + return; + } + + HistorianClient client = new(BuildOptions(host)); + HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(tag, CancellationToken.None); + + Assert.NotNull(metadata); + Assert.Equal(tag, metadata!.Name); + // A real metadata record decodes to a known data type (descriptor passed MapDataType). + Assert.True(Enum.IsDefined(metadata.DataType)); + } + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs index ca62a4c..5c2e771 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs @@ -135,6 +135,23 @@ public sealed class HistorianGrpcTransportTests Assert.Equal("20.0.000", response.StrParameterValue); } + [Fact] + public void BuildTagNamesBuffer_EncodesCountThenLengthPrefixedUtf16Names() + { + // R0.2 request framing: uint count + per-name(uint charCount + UTF-16LE). Golden bytes. + byte[] buffer = AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.BuildTagNamesBuffer(["AB", "C"]); + + byte[] expected = + [ + 0x02, 0x00, 0x00, 0x00, // count = 2 + 0x02, 0x00, 0x00, 0x00, // "AB" char count = 2 + 0x41, 0x00, 0x42, 0x00, // 'A','B' UTF-16LE + 0x01, 0x00, 0x00, 0x00, // "C" char count = 1 + 0x43, 0x00 // 'C' UTF-16LE + ]; + Assert.Equal(expected, buffer); + } + [Fact] public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged() {