R0.1 browse over gRPC SHIPPED — QueryTag cracked, M0 gRPC parity complete

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-21 16:01:15 -04:00
parent 630295bd18
commit 85ff1b48df
7 changed files with 244 additions and 115 deletions
@@ -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()
{