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()
{