gRPC M0 R0.2: tag metadata over gRPC (GetTagInfosFromName, live-verified)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -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
|
(`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
|
2026-06-21** against a real 2023 R2 server (returned `HistorianVersion`). Code path is the proven
|
||||||
handshake + a single string-in/string-out RPC.
|
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
|
> ℹ️ **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
|
> turned out to be a **test-harness credential-parsing bug**, not a server/account/SDK issue — the
|
||||||
|
|||||||
@@ -20,10 +20,30 @@ namespace AVEVA.Historian.Client.Grpc;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class HistorianGrpcHandshake
|
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(
|
public static uint OpenAuthenticatedConnection(
|
||||||
HistorianGrpcConnection connection,
|
HistorianGrpcConnection connection,
|
||||||
HistorianClientOptions options,
|
HistorianClientOptions options,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
=> OpenSession(connection, options, cancellationToken).ClientHandle;
|
||||||
|
|
||||||
|
public static Session OpenSession(
|
||||||
|
HistorianGrpcConnection connection,
|
||||||
|
HistorianClientOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
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}).");
|
throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}).");
|
||||||
}
|
}
|
||||||
|
|
||||||
(uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
(uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
||||||
return clientHandle;
|
return new Session(clientHandle, storageSessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2023 R2 gRPC tag-metadata client (roadmap item R0.2). 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() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
|||||||
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default)
|
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
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)
|
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -66,6 +66,26 @@ public sealed class HistorianGrpcIntegrationTests
|
|||||||
Assert.False(string.IsNullOrWhiteSpace(version));
|
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)
|
private static HistorianClientOptions BuildOptions(string host)
|
||||||
{
|
{
|
||||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||||
|
|||||||
@@ -135,6 +135,23 @@ public sealed class HistorianGrpcTransportTests
|
|||||||
Assert.Equal("20.0.000", response.StrParameterValue);
|
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]
|
[Fact]
|
||||||
public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged()
|
public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user