From 85ff1b48df163bcf34b66d440364df11626096bf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 16:01:15 -0400 Subject: [PATCH] =?UTF-8?q?R0.1=20browse=20over=20gRPC=20SHIPPED=20?= =?UTF-8?q?=E2=80=94=20QueryTag=20cracked,=20M0=20gRPC=20parity=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires HistorianClient.BrowseTagNamesAsync over gRPC (Transport==RemoteGrpc) via Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync: StartTagQuery(OData) -> paged QueryTag -> EndTagQuery. Live-verified against a real 2023 R2 server (returns Sys* tags). QueryTag packet-id recovered WITHOUT native disassembly: a .rdata packet-descriptor table in aahClientManaged.dll lists {0x6751,1}=StartTagQuery immediately followed by {0x6752,1}=QueryTag (found via pefile byte-scan of .rdata), confirmed live. Wire format (live-verified): - request btRequest = u16 0x6752 + u16 version(1) + u16 queryType(1=names) + u32 startIndex + u32 count - response btResonse = u32 count + per-name(u32 charCount + UTF-16LE) + trailer (NextIndex/metadata, ignored) - new HistorianTagQueryProtocol.ParseTagNameQueryPage tolerates the trailer - GlobToODataFilter translates the SDK glob filter to OData (Pre*->startswith, *suf->endswith, *mid*->contains, exact->eq); the 2023 R2 metadata-server parses filters as OData. Replaces the earlier RE probe helpers with the shipped browse path. Adds golden-byte (BuildQueryTagRequest) + 8 glob-translation unit tests + gated live browse test. 226 unit tests pass; 5/5 live gRPC tests pass (read, probe, system-param, metadata, browse). Milestone 0 (full gRPC parity) is complete. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- docs/plans/hcal-roadmap.md | 21 +- .../grpc-tag-query-odata.md | 59 ++---- .../Grpc/HistorianGrpcTagClient.cs | 194 +++++++++++++----- src/AVEVA.Historian.Client/HistorianClient.cs | 4 +- .../Wcf/HistorianTagQueryProtocol.cs | 28 +++ .../HistorianGrpcIntegrationTests.cs | 23 +-- .../HistorianGrpcTransportTests.cs | 30 +++ 7 files changed, 244 insertions(+), 115 deletions(-) diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index 8df028c..cb99003 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -36,14 +36,19 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from 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. -- 🟡 **R0.1 Browse over gRPC** — PARTIAL. Probed live 2026-06-21: the 2023 R2 gRPC front door does - **not** hit the 2020 metadata-server-pipe wall. `RetrievalService.StartTagQuery` is **cracked** — it - parses the filter as **OData** (`startswith(TagName,'Sys')`/`contains`/`eq`/empty succeed, returning - the 8-byte `(queryHandle, tagCount)`; SQL-LIKE `%`/glob `*` → `ODataFilter: bad token`). Live: 220 - Sys* tags counted. **Remaining:** the `QueryTag` paging request format (constant native error - type 4 / code 72 across guessed `btRequest` shapes) — needs a native capture, not guessing. Probe - helpers (`HistorianGrpcTagClient.ProbeStartTagQuery`/`ProbeTagQuerySequence`) + a gated StartTagQuery - test are committed. Full finding: `docs/reverse-engineering/grpc-tag-query-odata.md`. +- ✅ **R0.1 Browse over gRPC** — DONE, **LIVE-VERIFIED 2026-06-21**. + `HistorianClient.BrowseTagNamesAsync` routes over gRPC via + `Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync`: StartTagQuery(**OData** filter) → paged + **QueryTag** (`btRequest` = `u16 0x6752 + u16 1 + u16 queryType + u32 startIndex + u32 count`) → + EndTagQuery; response = `u32 count + per-name(u32 charCount + UTF-16LE) + trailer`. The SDK glob + filter is translated by `GlobToODataFilter` (`Pre*`→`startswith`, `*suf`→`endswith`, `*mid*`→ + `contains`, exact→`eq`). The QueryTag packet-id `0x6752` was recovered from a `.rdata` + packet-descriptor table (`{0x6751,1}`=StartTagQuery, `{0x6752,1}`=QueryTag) — no Ghidra needed. + Golden-byte + glob unit tests + gated live test. Full finding: + `docs/reverse-engineering/grpc-tag-query-odata.md`. + +> ✅ **Milestone 0 (gRPC parity) is COMPLETE** — probe, system-param, metadata, and browse all run +> over `RemoteGrpc` and are live-verified against a real 2023 R2 server, alongside the read chain. > ℹ️ **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/docs/reverse-engineering/grpc-tag-query-odata.md b/docs/reverse-engineering/grpc-tag-query-odata.md index b86a97e..4fc28c8 100644 --- a/docs/reverse-engineering/grpc-tag-query-odata.md +++ b/docs/reverse-engineering/grpc-tag-query-odata.md @@ -28,45 +28,30 @@ Success response `btResponse` is the 8-byte `(queryHandle:uint, tagCount:uint)` `*` → empty, `Pre*` → `startswith(TagName,'Pre')`, `*sub*` → `contains(TagName,'sub')`, exact → `TagName eq '...'`. (Escaping single-quotes in names still TBD.) -## QueryTag — OPEN (needs native RE of aahClient.dll) +## QueryTag — CRACKED (2026-06-21), browse SHIPPED `QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that returns the tag-name rows. -Every `btRequest` shape tried returns the constant native error **type 4 / code 72 = -`InvalidPacketId`** (`ArchestrA.CloudHistorian.Contract.ErrorCode.InvalidPacketId = 72`; the empty -buffer alone gives a different code). So the `btRequest` must carry a **packet-id header specific to -QueryTag** that we don't have — the generic `0x6751`/version-1 header (which StartTagQuery accepts) is -rejected here. +The blocker was the packet id: every guessed `btRequest` returned native error **type 4 / code 72 = +`InvalidPacketId`** (`ArchestrA.CloudHistorian.Contract.ErrorCode.InvalidPacketId`). The generic +`0x6751` header that StartTagQuery accepts is the **wrong** id for QueryTag. -**Semantic fields are known** from `ArchestrA.CloudHistorian.Contract`: -- request `QueryTagRequest` = `QueryHandle:uint("q") + QueryType:ushort("t") + StartIndex:uint("s") + TagCount:uint("c")` -- response `QueryTagResponse` = `QueryHandle:uint + TagNames:string[]("t") + NextIndex:uint("i") + TagMetadataBuffer:byte[]("tb")` - — so QueryTag returns the names directly (plus an optional metadata buffer), and pages via StartIndex/NextIndex. +**How it was found (no Ghidra needed):** a `.rdata` **packet-descriptor table** in +`aahClientManaged.dll` lists consecutive `{uint marker, uint version}` entries — +`{0x6751, 1}` (StartTagQuery) immediately followed by **`{0x6752, 1}`** (the paired op). Found by +`pefile` byte-scan of `.rdata` for `51 67 00 00` and dumping the surrounding dwords. Testing `0x6752` +live confirmed it. -What's missing is the **binary `btRequest` packet framing** (the QueryTag packet id + how those fields -are laid out). That serializer lives in **native `aahClient.dll`** — `aahClientManaged.dll` is -**mixed-mode (C++/CLI)** so ilspycmd cannot decompile it, and no managed assembly builds the buffer. -Completing QueryTag therefore requires **native RE (Ghidra/IDA on `aahClient.dll`)** or a **live gRPC -capture** of the stock 2023 R2 client browsing. Do not ship a guessed QueryTag request (project -discipline: no guessed wire bytes). +**QueryTag wire format (live-verified):** +- request `btRequest` = `u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count` + — `queryType = 1` returns tag-name rows (`queryType = 0` returns an empty/count-only page). +- response `btResonse` = `u32 count + per-name(u32 charCount + UTF-16LE) + trailer` + (the trailer is the CloudHistorian `NextIndex`/`TagMetadataBuffer` region — ignored by + `HistorianTagQueryProtocol.ParseTagNameQueryPage`). +- Semantic fields match `ArchestrA.CloudHistorian.Contract.QueryTagRequest` + (`QueryType/StartIndex/TagCount`; the QueryHandle travels in the protobuf `uiQueryHandle`). -Probe helpers live in `Grpc/HistorianGrpcTagClient` (`ProbeStartTagQuery`, `ProbeTagQuerySequence`) -and the gated `StartTagQuery_OverGrpc_AcceptsODataFilter` test pins the StartTagQuery+OData result. - -### Native-RE attempt (2026-06-21) — needs Ghidra/IDA - -The QueryTag packet-id is built in native code (the C++ HCAL inside the **mixed-mode** -`aahClientManaged.dll`). Lightweight tooling was exhausted and is insufficient: -- **ilspycmd** cannot decompile or even list the mixed-mode assembly (throws). -- **capstone** byte-search: the StartTagQuery marker `0x6751` is **not** a plain `mov` immediate in - `.text` (no `66 C7 … 51 67` and no clean `B8 51 67 00 00`); the three `51 67 00 00` hits in `.text` - are coincidental jump-table data (disassembly around them is garbage). The constant lives in the - `.rdata` pool (which holds the `51 67` bytes) and is loaded RIP-relative — so the serializer can't be - found by immediate-scanning without cross-reference analysis. -- Managed metadata (`ArchestrA.CloudHistorian.Contract`) gives the field semantics — - `QueryTagRequest{SessionId, QueryHandle:uint, QueryType:ushort, StartIndex:uint, TagCount:uint}`, - DataContract code `QTLQ` (StartTagQuery = `TLQR`) — but **not** the binary packet-id/framing. - -Finishing QueryTag therefore requires **Ghidra/IDA** (decompiler + xref to the `.rdata` marker constant -to find the serializer and read the QueryTag packet-id + byte layout), or the **live capture** option -(IL-rewrite `Archestra.Historian.GrpcClient.QueryTag` to log `requestBuffer` while a real 2023 R2 -client browses). StartTagQuery (the hard OData part) remains cracked and live-verified. +**Browse is shipped:** `HistorianClient.BrowseTagNamesAsync` routes over gRPC when +`Transport==RemoteGrpc` via `Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync` +(StartTagQuery(OData) → paged QueryTag(0x6752) → EndTagQuery), with the SDK glob filter translated by +`GlobToODataFilter`. Golden-byte + glob unit tests and a gated live test +(`BrowseTagNamesAsync_OverGrpc_ReturnsSystemTags`) cover it. **M0 gRPC parity is complete.** diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs index 487de4c..7600d80 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs @@ -8,7 +8,10 @@ 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 +/// 2023 R2 gRPC tag-metadata + browse client (roadmap items R0.2 metadata, R0.1 browse). +/// Browse drives StartTagQuery (OData filter) → paged QueryTagEndTagQuery +/// (see and docs/reverse-engineering/grpc-tag-query-odata.md). +/// 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 @@ -89,84 +92,84 @@ internal static class HistorianGrpcTagClient return response.BtTagInfos?.ToByteArray() ?? []; } - /// Outcome of a StartTagQuery probe — whether the gRPC front door accepts the op. - internal readonly record struct StartTagQueryProbeResult(bool Success, int ResponseLength, byte[] Response, int ErrorLength, byte[] Error); + // 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; /// - /// Reverse-engineering probe for R0.1 (browse): drives RetrievalService.StartTagQuery with - /// the native filter request and reports whether the 2023 R2 front door accepts it (on 2020 WCF this - /// op fails server-side on the aahMetadataServer pipe regardless of handle format). + /// Browses tag names over gRPC (roadmap item R0.1). Drives + /// StartTagQuery (OData filter) → paged QueryTagEndTagQuery on the + /// RetrievalService. The 2023 R2 metadata-server parses the filter as OData, so the SDK's + /// glob filter is translated via . Each QueryTag page returns + /// uint count + per-name(uint charCount + UTF-16LE), decoded by + /// . /// - internal static StartTagQueryProbeResult ProbeStartTagQuery(HistorianClientOptions options, string filter, CancellationToken cancellationToken) + public static async IAsyncEnumerable BrowseTagNamesAsync( + HistorianClientOptions options, + string filter, + [System.Runtime.CompilerServices.EnumeratorCancellation] 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 = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(filter).RequestBuffer; - GrpcRetrieval.StartTagQueryResponse response = retrievalClient.StartTagQuery( - new GrpcRetrieval.StartTagQueryRequest - { - StrHandle = session.StringHandle, - BtRequest = ByteString.CopyFrom(requestBuffer) - }, - connection.Metadata, - DateTime.UtcNow.Add(options.RequestTimeout), - cancellationToken); - - bool success = response.Status?.BSuccess ?? false; - byte[] resp = response.BtResponse?.ToByteArray() ?? []; - byte[] error = response.Status?.BtError?.ToByteArray() ?? []; - return new StartTagQueryProbeResult(success, resp.Length, resp, error.Length, error); + IReadOnlyList names = await Task.Run(() => BrowseTagNames(options, filter, cancellationToken), cancellationToken).ConfigureAwait(false); + foreach (string name in names) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return name; + } } - /// Outcome of a full StartTagQuery → QueryTag → EndTagQuery probe. - internal readonly record struct TagQuerySequenceResult( - bool StartSuccess, uint QueryHandle, uint TagCount, - bool QuerySuccess, byte[] QueryResponse, byte[] QueryError); - - /// - /// Reverse-engineering probe for R0.1 (browse): runs the full StartTagQuery (OData filter) → - /// QueryTag (paging) → EndTagQuery sequence and returns the raw QueryTag response buffer so its - /// tag-name framing can be discovered. is the candidate - /// QueryTag btRequest buffer (format unknown — swept by the probe). - /// - internal static TagQuerySequenceResult ProbeTagQuerySequence( - HistorianClientOptions options, string odataFilter, byte[] queryRequest, CancellationToken cancellationToken) + private static List 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(odataFilter).RequestBuffer; + 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)) { - return new TagQuerySequenceResult(false, 0, 0, false, [], start.Status?.BtError?.ToByteArray() ?? []); + byte[] error = start.Status?.BtError?.ToByteArray() ?? []; + throw new InvalidOperationException($"gRPC StartTagQuery failed (errorLen={error.Length})."); } - byte[] startResp = start.BtResponse?.ToByteArray() ?? []; - HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(startResp); - + HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(start.BtResponse?.ToByteArray() ?? []); + List names = new(checked((int)parsed.TagCount)); try { - GrpcRetrieval.QueryTagResponse query = retrievalClient.QueryTag( - new GrpcRetrieval.QueryTagRequest + 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)) { - StrHandle = session.StringHandle, - UiQueryHandle = parsed.QueryHandle, - BtRequest = ByteString.CopyFrom(queryRequest) - }, - connection.Metadata, Deadline(), cancellationToken); + byte[] error = query.Status?.BtError?.ToByteArray() ?? []; + throw new InvalidOperationException($"gRPC QueryTag failed (errorLen={error.Length})."); + } - return new TagQuerySequenceResult( - true, parsed.QueryHandle, parsed.TagCount, - query.Status?.BSuccess ?? false, - query.BtResonse?.ToByteArray() ?? [], - query.Status?.BtError?.ToByteArray() ?? []); + IReadOnlyList pageNames = HistorianTagQueryProtocol.ParseTagNameQueryPage(query.BtResonse?.ToByteArray() ?? []); + if (pageNames.Count == 0) + { + break; + } + + names.AddRange(pageNames); + startIndex += (uint)pageNames.Count; + } } finally { @@ -178,6 +181,85 @@ internal static class HistorianGrpcTagClient } catch { /* best-effort cleanup */ } } + + return names; + } + + /// Builds the QueryTag paging request: u16 marker(0x6752) + u16 version + u16 queryType + u32 startIndex + u32 count. + 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(); + } + + /// + /// Translates the SDK's glob filter (* wildcard) into the OData filter the 2023 R2 + /// metadata-server's StartActiveTagnamesQuery expects. Single-quotes are OData-escaped. + /// + /// * / empty → no filter (all tags) + /// Pre*startswith(TagName,'Pre') + /// *sufendswith(TagName,'suf') + /// *mid*contains(TagName,'mid') + /// a*bstartswith(TagName,'a') and endswith(TagName,'b') + /// ExactTagName eq 'Exact' + /// + /// + 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 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; } /// Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE). diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index 7b5513a..2e7f829 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -98,7 +98,9 @@ public sealed class HistorianClient : IAsyncDisposable public IAsyncEnumerable 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 GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs index d14a362..d118c33 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs @@ -102,6 +102,34 @@ internal static class HistorianTagQueryProtocol return tagNames; } + /// + /// Parses one page of a gRPC QueryTag tag-name response: uint count + per-name(uint + /// charCount + UTF-16LE), then a trailing region (NextIndex + optional metadata buffer) that + /// is intentionally ignored. Unlike this tolerates the + /// trailer rather than requiring the buffer to end exactly after the names. + /// + public static IReadOnlyList ParseTagNameQueryPage(ReadOnlySpan response) + { + if (response.Length < 4) + { + return []; + } + + 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; + } + + return tagNames; + } + private static void WriteHistorianString(BinaryWriter writer, string value) { writer.Write((uint)value.Length); diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 0015e87..e932476 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -87,7 +87,7 @@ public sealed class HistorianGrpcIntegrationTests } [Fact] - public async Task StartTagQuery_OverGrpc_AcceptsODataFilter() + public async Task BrowseTagNamesAsync_OverGrpc_ReturnsSystemTags() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) @@ -95,19 +95,16 @@ public sealed class HistorianGrpcIntegrationTests return; } - // R0.1 finding (2026-06-21): on the 2023 R2 gRPC front door the metadata-server pipe IS - // reachable (unlike 2020 WCF) and StartActiveTagnamesQuery parses the filter as OData — - // startswith/contains/eq succeed; SQL-LIKE "%"/glob "*" fail with "ODataFilter: bad token". - // StartTagQuery returns the 8-byte (queryHandle, tagCount) response. The follow-on QueryTag - // paging request format is not yet captured (see roadmap R0.1), so browse is not yet wired. - HistorianClientOptions options = BuildOptions(host); - var result = await Task.Run(() => - AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.ProbeStartTagQuery(options, "startswith(TagName,'Sys')", CancellationToken.None)); + // Full R0.1 browse over gRPC: StartTagQuery(OData) -> paged QueryTag(0x6752) -> EndTagQuery. + HistorianClient client = new(BuildOptions(host)); + List names = []; + await foreach (string name in client.BrowseTagNamesAsync("Sys*", CancellationToken.None)) + { + names.Add(name); + } - Assert.True(result.Success, - $"StartTagQuery(OData) should succeed; errLen={result.ErrorLength} " + - $"err=\"{System.Text.Encoding.ASCII.GetString(result.Error).Replace('\0', '.')}\""); - Assert.Equal(8, result.ResponseLength); // (queryHandle, tagCount) + Assert.NotEmpty(names); + Assert.All(names, n => Assert.StartsWith("Sys", n, StringComparison.Ordinal)); } private static HistorianClientOptions BuildOptions(string host) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs index 5c2e771..f50a0d8 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs @@ -152,6 +152,36 @@ public sealed class HistorianGrpcTransportTests Assert.Equal(expected, buffer); } + [Fact] + public void BuildQueryTagRequest_EncodesMarkerVersionTypeStartCount() + { + // R0.1 QueryTag paging request: u16 0x6752 + u16 1 + u16 queryType + u32 startIndex + u32 count. + byte[] buffer = AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.BuildQueryTagRequest(1, 0, 50); + byte[] expected = + [ + 0x52, 0x67, // marker 0x6752 + 0x01, 0x00, // version 1 + 0x01, 0x00, // queryType 1 (names) + 0x00, 0x00, 0x00, 0x00, // startIndex 0 + 0x32, 0x00, 0x00, 0x00 // count 50 + ]; + Assert.Equal(expected, buffer); + } + + [Theory] + [InlineData("*", "")] + [InlineData("", "")] + [InlineData("Sys*", "startswith(TagName,'Sys')")] + [InlineData("*Total", "endswith(TagName,'Total')")] + [InlineData("*Alarm*", "contains(TagName,'Alarm')")] + [InlineData("Exact.Tag", "TagName eq 'Exact.Tag'")] + [InlineData("Pre*Suf", "startswith(TagName,'Pre') and endswith(TagName,'Suf')")] + [InlineData("O'Brien*", "startswith(TagName,'O''Brien')")] + public void GlobToODataFilter_TranslatesWildcards(string glob, string expected) + { + Assert.Equal(expected, AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.GlobToODataFilter(glob)); + } + [Fact] public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged() {