dbb5c99c53
Captured the stock 2023 R2 client doing a gRPC event read (50 rows flowed) to resolve the open "gRPC event ROW retrieval returns zero rows" item. Two captured differences from our SDK's path; this lands the first (necessary) one plus the capture tooling. - HistorianEventQueryProtocol.CreateStartEventQueryAttempts: add a `version` parameter (default 5 = the 2020 WCF format, unchanged). The gRPC event orchestrator now opts into version 6 — the leading `06` plus a 5-byte trailing zero pad — which is the envelope the stock 2023 R2 client sends. The two buffers are otherwise byte-identical (filter block, UTC string, metadata namespace). Golden test Version6EmptyFilterMatchesCapturedGrpcEnvelope pins it. - Grpc2023CaptureHarness: new `capture-event` scenario drives HistorianAccess over an Event-type gRPC connection (CreateEventQuery -> EventQueryArgs -> StartQuery -> MoveNext) so the wide-net instrument-grpc-nonstream rewrite dumps StartEventQuery.requestBuffer + the row result. Hostname defaults sanitized to HISTORIAN_GRPC_HOST / "localhost" (removed hardcoded server name). NECESSARY BUT NOT SUFFICIENT: live validation shows v6 alone does not make rows flow — the read also requires an Event-type connection, which our SDK's v6 Open2 format cannot express (see the companion docs commit). The gated ReadEventsAsync_OverGrpc_* test correctly still pins the no-row throw. 322/322 offline tests pass; WCF event path unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
128 lines
6.2 KiB
C#
128 lines
6.2 KiB
C#
using AVEVA.Historian.Client.Models;
|
|
using AVEVA.Historian.Client.Wcf;
|
|
|
|
namespace AVEVA.Historian.Client.Tests;
|
|
|
|
public sealed class WcfEventQueryProtocolTests
|
|
{
|
|
// Filter block (offset 0x1E into pRequestBuff) captured from a native
|
|
// EventQuery.AddEventFilter("Area", Equal, "RetestFilterArea") StartEventQuery, via
|
|
// instrument-wcf-writemessage.
|
|
private const string CaptureFilterBlockHex =
|
|
"000001000000010000000400000041007200650061000100000000000100000009100052657465737446696c7465724172656100";
|
|
private const int FilterBlockOffset = 0x1E;
|
|
|
|
[Fact]
|
|
public void SerializerMatchesInstrumentedNativeEventFilterBlock()
|
|
{
|
|
HistorianEventQueryAttempt attempt = Assert.Single(HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
|
|
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc),
|
|
5,
|
|
new HistorianEventFilter("Area", HistorianEventComparison.Equal, "RetestFilterArea")));
|
|
|
|
byte[] expectedBlock = Convert.FromHexString(CaptureFilterBlockHex);
|
|
byte[] actualBlock = attempt.RequestBuffer[FilterBlockOffset..(FilterBlockOffset + expectedBlock.Length)];
|
|
|
|
Assert.Equal("native-filter-version5", attempt.Name);
|
|
Assert.Equal(expectedBlock, actualBlock);
|
|
}
|
|
|
|
[Fact]
|
|
public void FilterBlockEncodesComparisonOperatorAsUInt16()
|
|
{
|
|
// Equal = 0, Contains = 12 — the op field is the only byte that differs between them
|
|
// (offset 0x38 in pRequestBuff = 0x1A into the filter block).
|
|
byte[] equal = HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
|
|
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc),
|
|
5, new HistorianEventFilter("Area", HistorianEventComparison.Equal, "X"))[0].RequestBuffer;
|
|
byte[] contains = HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
|
|
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc),
|
|
5, new HistorianEventFilter("Area", HistorianEventComparison.Contains, "X"))[0].RequestBuffer;
|
|
|
|
Assert.Equal(0x38, FilterBlockOffset + 0x1A);
|
|
Assert.Equal(0, BitConverter.ToUInt16(equal, 0x38));
|
|
Assert.Equal(12, BitConverter.ToUInt16(contains, 0x38));
|
|
}
|
|
|
|
[Fact]
|
|
public void NullFilterStillProducesTheEmptyFilterBuffer()
|
|
{
|
|
HistorianEventQueryAttempt withNull = HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
|
|
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc), 3)[0];
|
|
|
|
Assert.Equal("native-empty-filter-version5", withNull.Name);
|
|
Assert.Equal(65, withNull.RequestBuffer.Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void SerializerMatchesInstrumentedNativeEventRequest()
|
|
{
|
|
HistorianEventQueryAttempt attempt = Assert.Single(HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
|
|
new DateTime(2026, 4, 25, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646),
|
|
new DateTime(2026, 5, 2, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646),
|
|
3));
|
|
|
|
byte[] expected = Convert.FromBase64String(
|
|
"BQBuHAVXwdTcAW5c6X9B2twBAwAAAAAAAAAAAAEAAAAAAAAAAAAAAQADAAAAVQBUAEMAAQEAAAEAAAEAAAAAAAA=");
|
|
|
|
Assert.Equal(expected, attempt.RequestBuffer);
|
|
Assert.Equal("6b955b02087047a3199a8c74f3eee85c3b49aaa29b05de12eff2dd536f2da0d5", attempt.RequestSha256);
|
|
}
|
|
|
|
[Fact]
|
|
public void Version6EmptyFilterMatchesCapturedGrpcEnvelope()
|
|
{
|
|
// Captured 2026-06-22 from the stock 2023 R2 client (docs/reverse-engineering/grpc-event-query-capture.md):
|
|
// the v6 StartEventQuery request is byte-identical to the v5 buffer except byte 0 (version 6) and a
|
|
// 5-byte trailing zero pad (70 vs 65 bytes). The 2023 R2 server returns event rows only for v6.
|
|
DateTime start = new DateTime(2026, 4, 25, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646);
|
|
DateTime end = new DateTime(2026, 5, 2, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646);
|
|
|
|
byte[] v5 = HistorianEventQueryProtocol.CreateStartEventQueryAttempts(start, end, 3)[0].RequestBuffer;
|
|
HistorianEventQueryAttempt v6Attempt = Assert.Single(
|
|
HistorianEventQueryProtocol.CreateStartEventQueryAttempts(start, end, 3, filter: null, version: 6));
|
|
byte[] v6 = v6Attempt.RequestBuffer;
|
|
|
|
Assert.Equal("native-empty-filter-version6", v6Attempt.Name);
|
|
Assert.Equal(6, v6Attempt.Version);
|
|
Assert.Equal(70, v6.Length);
|
|
Assert.Equal([0x06, 0x00], v6[..2]);
|
|
|
|
// v6 == v5 with byte 0 -> 6 and 5 trailing zero bytes appended.
|
|
byte[] expected = new byte[70];
|
|
Array.Copy(v5, expected, v5.Length);
|
|
expected[0] = 0x06;
|
|
Assert.Equal(expected, v6);
|
|
Assert.Equal([0x00, 0x00, 0x00, 0x00, 0x00], v6[^5..]);
|
|
}
|
|
|
|
[Fact]
|
|
public void NativeEmptyFilterAttemptMatchesDecompiledSaveOrder()
|
|
{
|
|
HistorianEventQueryAttempt attempt = Assert.Single(HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
|
|
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
|
3));
|
|
|
|
byte[] actual = attempt.RequestBuffer;
|
|
|
|
Assert.Equal("native-empty-filter-version5", attempt.Name);
|
|
Assert.Equal(3, HistorianEventQueryProtocol.QueryRequestTypeEvent);
|
|
Assert.Equal(65, actual.Length);
|
|
Assert.Equal([0x05, 0x00], actual[..2]);
|
|
Assert.Equal(3u, BitConverter.ToUInt32(actual, 18));
|
|
Assert.Equal(0u, BitConverter.ToUInt32(actual, 22));
|
|
Assert.Equal(0, BitConverter.ToUInt16(actual, 26));
|
|
Assert.Equal(1, BitConverter.ToUInt16(actual, 28));
|
|
Assert.Equal([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], actual[30..37]);
|
|
Assert.Equal(65_536u, BitConverter.ToUInt32(actual, 37));
|
|
Assert.Equal([0x03, 0x00, 0x00, 0x00, 0x55, 0x00, 0x54, 0x00, 0x43, 0x00], actual[41..51]);
|
|
Assert.Equal([0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00], actual[51..61]);
|
|
Assert.Equal([0x00, 0x00, 0x00, 0x00], actual[^4..]);
|
|
}
|
|
}
|