diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index 685aa10..8df028c 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -36,6 +36,14 @@ 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`. > â„šī¸ **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 new file mode 100644 index 0000000..1650f6a --- /dev/null +++ b/docs/reverse-engineering/grpc-tag-query-odata.md @@ -0,0 +1,42 @@ +# R0.1 browse over gRPC — StartTagQuery takes an OData filter (2026-06-21) + +Live-probed `RetrievalService.StartTagQuery` / `QueryTag` against a real **2023 R2** server over the +gRPC front door (string-handle = uppercase Open2 storage GUID). Key result: **browse is feasible on +2023 R2** — the 2020 WCF "metadata-server pipe" wall does **not** block here. + +## StartTagQuery — CRACKED + +`StartTagQuery(strHandle, btRequest)` where `btRequest` = the native +`marker(26449) + version(1) + WriteHistorianString(filter)` buffer +(`HistorianTagQueryProtocol.CreateStartTagQueryAttempt`). The server runs +`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over `\\.\pipe\aahMetadataServer\console` and +**parses the filter string as OData** (not SQL-LIKE). Swept filters: + +| filter | result | +|---|---| +| `startswith(TagName,'Sys')` | ✅ success, 8-byte response | +| `contains(TagName,'Sys')` | ✅ success | +| `TagName eq 'SysTimeSec'` | ✅ success | +| `` (empty) | ✅ success (all tags) | +| `Sys*` / `*` | ❌ `ODataFilter ... bad token` | +| `TagName like 'Sys%'` / `Name like 'Sys%'` | ❌ rejected | + +Success response `btResponse` is the 8-byte `(queryHandle:uint, tagCount:uint)` pair +(`ParseStartTagQueryResponse`). Live: `startswith(TagName,'Sys')` → tagCount = 220. + +**Implication for the public API:** browse must translate the SDK's glob filter to OData — +`*` → empty, `Pre*` → `startswith(TagName,'Pre')`, `*sub*` → `contains(TagName,'sub')`, +exact → `TagName eq '...'`. (Escaping single-quotes in names still TBD.) + +## QueryTag — OPEN (one capture away) + +`QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that should return the actual +tag-name rows. Every `btRequest` shape tried returns a constant native error **type 4 / code 72** +(independent of content: empty, count, column-name historian-string, `$select=TagName`, +marker+version+name all give the same `04 48000000`). The constant code regardless of input means the +request *framing* is wrong, not the field values — this needs a **native capture** of the real 2023 R2 +client driving a browse to recover the exact `QueryTag` `btRequest` (and the row framing in +`btResonse`). Do not ship a guessed QueryTag request (project discipline: no guessed wire bytes). + +Probe helpers live in `Grpc/HistorianGrpcTagClient` (`ProbeStartTagQuery`, `ProbeTagQuerySequence`) +and the gated `StartTagQuery_OverGrpc_AcceptsODataFilter` test pins the StartTagQuery+OData result. diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs index 83433bf..487de4c 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs @@ -89,6 +89,97 @@ 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); + + /// + /// 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). + /// + internal static StartTagQueryProbeResult ProbeStartTagQuery(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); + 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); + } + + /// 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) + { + 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; + 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[] startResp = start.BtResponse?.ToByteArray() ?? []; + HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(startResp); + + try + { + GrpcRetrieval.QueryTagResponse query = retrievalClient.QueryTag( + new GrpcRetrieval.QueryTagRequest + { + StrHandle = session.StringHandle, + UiQueryHandle = parsed.QueryHandle, + BtRequest = ByteString.CopyFrom(queryRequest) + }, + connection.Metadata, Deadline(), cancellationToken); + + return new TagQuerySequenceResult( + true, parsed.QueryHandle, parsed.TagCount, + query.Status?.BSuccess ?? false, + query.BtResonse?.ToByteArray() ?? [], + query.Status?.BtError?.ToByteArray() ?? []); + } + finally + { + try + { + retrievalClient.EndTagQuery( + new GrpcRetrieval.EndTagQueryRequest { StrHandle = session.StringHandle, UiQueryHandle = parsed.QueryHandle }, + connection.Metadata, Deadline(), CancellationToken.None); + } + catch { /* best-effort cleanup */ } + } + } + /// Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE). internal static byte[] BuildTagNamesBuffer(IReadOnlyList tags) { diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index a140dfe..0015e87 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -86,6 +86,30 @@ public sealed class HistorianGrpcIntegrationTests Assert.True(Enum.IsDefined(metadata.DataType)); } + [Fact] + public async Task StartTagQuery_OverGrpc_AcceptsODataFilter() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) + { + 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)); + + 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) + } + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");