From 6d8a7d48f8baf191c8efdb668f89c82fd4cb685d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 22 Jun 2026 00:40:13 -0400 Subject: [PATCH 1/2] tests(grpc): live-verify aggregate + at-time over gRPC at WCF parity The gRPC integration suite was missing live coverage for ReadAggregateAsync and ReadAtTimeAsync, the two tooled gRPC reads the WCF suite already exercises. Add them so the full tooled gRPC surface is live-tested like WCF. - ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows - ReadAggregateAsync_OverGrpc_AcceptsRetrievalMode (Min/MaxWithTime, BestFit) - ReadAtTimeAsync_OverGrpc_ReturnsRequestedTimestamps The aggregate tests self-calibrate their window from a real raw sample (SeedAggregateWindowAsync): the interpolating modes (TimeWeightedAverage / Min/MaxWithTime) do a slow bounding-value scan and return empty when the window has no raw data, so a fixed "last N hours" window blows the per-call deadline against an idle server. Anchoring the window where data actually exists keeps the scan cheap and returns rows on idle or live servers alike. Adds an optional HISTORIAN_GRPC_TIMEOUT knob (per-call deadline override) for slow links. Full tooled gRPC surface now live-green: 15/15 gRPC integration tests pass against a real 2023 R2 server; 313 offline tests green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../HistorianGrpcIntegrationTests.cs | 126 +++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 36bbdd6..7cfd9cd 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -233,6 +233,124 @@ public sealed class HistorianGrpcIntegrationTests Assert.Contains(samples, s => s.NumericValue is { } v && Math.Abs(v - expected) < 0.01); } + [Fact] + public async Task ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)) + { + return; + } + + HistorianClient client = new(BuildOptions(host)); + + // Self-calibrate the window from a real raw sample. The 2023 R2 box may be idle (no recent + // collection), so a "last N hours" window can be empty AND make the interpolating modes do a + // slow bounding-value scan. Seeding from where data actually exists makes this robust on any + // server state and keeps the per-bucket scan cheap. See HISTORIAN_GRPC_TIMEOUT for slow links. + (DateTime startUtc, DateTime endUtc)? window = await SeedAggregateWindowAsync(client, testTag!); + if (window is null) + { + return; // tag has no data anywhere in the lookback — nothing to aggregate + } + + List samples = []; + await foreach (HistorianAggregateSample sample in client.ReadAggregateAsync( + testTag!, window.Value.startUtc, window.Value.endUtc, + RetrievalMode.TimeWeightedAverage, + TimeSpan.FromMinutes(10), + CancellationToken.None)) + { + samples.Add(sample); + } + + Assert.NotEmpty(samples); + Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); + Assert.All(samples, s => Assert.Equal(RetrievalMode.TimeWeightedAverage, s.RetrievalMode)); + } + + // Exercises the "QueryType byte = native enum ordinal" mapping over the gRPC StartQuery envelope + // for a few non-default retrieval modes — the server must accept each without error. Window is + // seeded from real data (idle-server-safe); rows may legitimately be empty for some modes. + [Theory] + [InlineData(RetrievalMode.MinimumWithTime)] + [InlineData(RetrievalMode.MaximumWithTime)] + [InlineData(RetrievalMode.BestFit)] + public async Task ReadAggregateAsync_OverGrpc_AcceptsRetrievalMode(RetrievalMode mode) + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)) + { + return; + } + + HistorianClient client = new(BuildOptions(host)); + + (DateTime startUtc, DateTime endUtc)? window = await SeedAggregateWindowAsync(client, testTag!); + if (window is null) + { + return; + } + + List samples = []; + await foreach (HistorianAggregateSample s in client.ReadAggregateAsync( + testTag!, window.Value.startUtc, window.Value.endUtc, mode, TimeSpan.FromMinutes(10), CancellationToken.None)) + { + samples.Add(s); + } + + // Absence of an exception proves the QueryType byte was accepted; pin the echoed mode. + Assert.All(samples, s => Assert.Equal(mode, s.RetrievalMode)); + } + + /// + /// Finds a 1-hour window that actually contains raw data for by reading a + /// real raw sample over a wide lookback, then anchoring the window at that sample. Returns null + /// when the tag has no data in the lookback. This keeps the aggregate tests independent of whether + /// the live 2023 R2 box is actively collecting. + /// + private static async Task<(DateTime startUtc, DateTime endUtc)?> SeedAggregateWindowAsync(HistorianClient client, string tag) + { + DateTime endUtc = DateTime.UtcNow; + DateTime startUtc = endUtc - TimeSpan.FromDays(30); + + await foreach (HistorianSample s in client.ReadRawAsync(tag, startUtc, endUtc, maxValues: 1, CancellationToken.None)) + { + DateTime anchor = s.TimestampUtc; + return (anchor - TimeSpan.FromMinutes(1), anchor + TimeSpan.FromHours(1)); + } + + return null; + } + + [Fact] + public async Task ReadAtTimeAsync_OverGrpc_ReturnsRequestedTimestamps() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)) + { + return; + } + + HistorianClient client = new(BuildOptions(host)); + + DateTime nowUtc = DateTime.UtcNow; + DateTime[] timestamps = + [ + nowUtc - TimeSpan.FromDays(1), + nowUtc - TimeSpan.FromHours(12), + nowUtc - TimeSpan.FromHours(1) + ]; + + IReadOnlyList samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None); + + Assert.NotEmpty(samples); + Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); + } + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); @@ -242,6 +360,11 @@ public sealed class HistorianGrpcIntegrationTests ? parsed : HistorianClientOptions.DefaultGrpcPort; bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase); + // Optional per-call deadline override (seconds) for slow/remote boxes — heavier aggregate + // modes over a tunnelled link can exceed the 30s default. Falls back to the SDK default. + TimeSpan timeout = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0 + ? TimeSpan.FromSeconds(secs) + : new HistorianClientOptions { Host = host }.RequestTimeout; return new HistorianClientOptions { @@ -253,7 +376,8 @@ public sealed class HistorianGrpcIntegrationTests ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"), IntegratedSecurity = !explicitCreds, UserName = user ?? string.Empty, - Password = password ?? string.Empty + Password = password ?? string.Empty, + RequestTimeout = timeout }; } } From b80ac07942ed168c970c6fbc08104ea765adc232 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 22 Jun 2026 00:40:13 -0400 Subject: [PATCH 2/2] docs(grpc): transport matrix + correct the obsolete v12 version-gate note Document the WCF-vs-gRPC surface and fix a stale claim. - README: add a "Transport matrix (WCF vs gRPC)" section with a per-operation table. Mark the config ops the gRPC server exposes-but-untooled with a distinct legend state (recovered RPC + bytes buffer named) vs genuinely unavailable, so "not tooled" is not conflated with "not possible". - README: document the gRPC live-test env vars (HISTORIAN_GRPC_HOST/_PORT/_TLS/ _DNSID/_TIMEOUT/_WRITE_SANDBOX_TAG) and refresh the Status section (test count + the live-verified gRPC surface). - The "gRPC requires VerifyServerInterfaceVersion=false against a v12 server" note was obsolete: the gate already accepts History 11 AND 12 (AcceptedVersions), and the live gRPC suite runs with the default verification on. Corrected in the README, CLAUDE.md, and the HistorianServerVersionGate docstring. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- CLAUDE.md | 2 +- README.md | 151 +++++++++++++++++- .../HistorianServerVersionGate.cs | 9 +- 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6742561..5413e41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ Three layered subsystems, intentionally decoupled so protocol parsing can be uni - **`Wcf/`** — managed WCF/MDAS layer. The Historian uses Net.TCP on port `32568` with a custom `application/x-mdas` content type wrapping a binary SOAP 1.2 / WS-Addressing 1.0 envelope. `MdasMessageEncoder` + `MdasMessageEncodingBindingElement` implement that wrapper. `HistorianWcfBindingFactory` produces three flavors: plain MDAS, MDAS+Windows transport (used for `/Hist-Integrated`), and MDAS+certificate (used for `/HistCert`). Service paths live in `HistorianWcfServiceNames`. WCF data contracts (`Wcf/Contracts/`) are reproduced from server-side static analysis and are versioned per native interface (e.g., `IRetrievalServiceContract2..4`). - **`Protocol/`** — binary frame layer (`HistorianFrameReader`/`Writer`, `HistorianBinaryPrimitives`, `HistorianMessageType`). `Historian2020ProtocolDialect` is the version-anchored bridge between `HistorianClient` and the frame layer; methods without sufficient evidence throw `ProtocolEvidenceMissingException` rather than guessing wire bytes. - **`Transport/`** — pluggable `IHistorianTransport` (default: TCP). Tests inject a fake transport. -- **`Grpc/`** — 2023 R2 gRPC transport (`HistorianTransport.RemoteGrpc`). The recovered protobuf contract lives in `Grpc/Protos/*.proto` and is compiled to client stubs at build time by `Grpc.Tools`. `HistorianGrpcChannelFactory` builds a gRPC-Web/HTTP-1.1 channel (default port `32565`, optional TLS, gzip) matching the stock 2023 R2 client. `HistorianGrpcReadOrchestrator` mirrors `HistorianWcfReadOrchestrator` but over gRPC: it reuses the exact native serializers/parsers — the same Open2 buffer, SSPI/NTLM tokens, and `DataQueryRequest`/result buffers travel inside protobuf `bytes` fields. The 2020→gRPC op map: `Hist.ValCl`→`StorageService.ValidateClientCredential` (the SSPI/Negotiate token loop), `Hist.Open2`→`HistoryService.OpenConnection`, `Retr.StartQuery2`→`RetrievalService.StartQuery`, `Retr.GetNextQueryResultBuffer2`→`RetrievalService.GetNextQueryResultBuffer`. The transport-agnostic handshake (Open2 request builder + SSPI token loop + response decode) is shared via `Wcf/HistorianNativeHandshake`. **Live-verified 2026-06-21 against a real 2023 R2 server** (interface versions History=12, Retrieval=4, Storage=4): the full read chain returns rows. NOTE: `HistoryService.ExchangeKey` is a SEPARATE key-exchange/cert-path op, NOT the Negotiate loop — an earlier revision wrongly routed the token loop there and it was rejected at round 0 regardless of credentials; the loop belongs on `StorageService.ValidateClientCredential` (which kept the 2020 inBuff/outBuff token framing). The byte payloads are the proven 2020 protocol and transfer unchanged; only the History interface integer differs (12 vs 11) and is buffer-compatible, so `VerifyServerInterfaceVersion=false` is currently required against a v12 server (the gate still pins History=11). Gated live test: set `HISTORIAN_GRPC_HOST` (+ `HISTORIAN_TEST_TAG`, optional `HISTORIAN_GRPC_PORT`/`HISTORIAN_GRPC_TLS`/`HISTORIAN_GRPC_DNSID`); reach the live 2023 R2 box via [[reference_2023r2_live_server_access]]. +- **`Grpc/`** — 2023 R2 gRPC transport (`HistorianTransport.RemoteGrpc`). The recovered protobuf contract lives in `Grpc/Protos/*.proto` and is compiled to client stubs at build time by `Grpc.Tools`. `HistorianGrpcChannelFactory` builds a gRPC-Web/HTTP-1.1 channel (default port `32565`, optional TLS, gzip) matching the stock 2023 R2 client. `HistorianGrpcReadOrchestrator` mirrors `HistorianWcfReadOrchestrator` but over gRPC: it reuses the exact native serializers/parsers — the same Open2 buffer, SSPI/NTLM tokens, and `DataQueryRequest`/result buffers travel inside protobuf `bytes` fields. The 2020→gRPC op map: `Hist.ValCl`→`StorageService.ValidateClientCredential` (the SSPI/Negotiate token loop), `Hist.Open2`→`HistoryService.OpenConnection`, `Retr.StartQuery2`→`RetrievalService.StartQuery`, `Retr.GetNextQueryResultBuffer2`→`RetrievalService.GetNextQueryResultBuffer`. The transport-agnostic handshake (Open2 request builder + SSPI token loop + response decode) is shared via `Wcf/HistorianNativeHandshake`. **Live-verified 2026-06-21 against a real 2023 R2 server** (interface versions History=12, Retrieval=4, Storage=4): the full read chain returns rows. NOTE: `HistoryService.ExchangeKey` is a SEPARATE key-exchange/cert-path op, NOT the Negotiate loop — an earlier revision wrongly routed the token loop there and it was rejected at round 0 regardless of credentials; the loop belongs on `StorageService.ValidateClientCredential` (which kept the 2020 inBuff/outBuff token framing). The byte payloads are the proven 2020 protocol and transfer unchanged; only the History interface integer differs (12 vs 11) and is buffer-compatible. The version gate now accepts BOTH 11 and 12 for History (`HistorianServerVersionGate.AcceptedVersions`), so a v12 server passes with the default `VerifyServerInterfaceVersion=true` — no opt-out needed (the earlier requirement to set it false is obsolete). Gated live test: set `HISTORIAN_GRPC_HOST` (+ `HISTORIAN_TEST_TAG`, optional `HISTORIAN_GRPC_PORT`/`HISTORIAN_GRPC_TLS`/`HISTORIAN_GRPC_DNSID`); reach the live 2023 R2 box via [[reference_2023r2_live_server_access]]. - **`Models/`** — public DTOs and enums (`HistorianSample`, `RetrievalMode`, etc.). `HistorianDataValue` represents the discriminated value type. `InternalsVisibleTo` exposes internals to the test assembly and the reverse-engineering tool. diff --git a/README.md b/README.md index fc9c59e..3e95762 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,126 @@ auth) and `RemoteTcpCertificate` (server-cert TLS) are now live-verified for `ProbeAsync`; `RemoteTcpIntegrated` is additionally live-verified for the full read / browse / metadata / event / status surface. +## Transport matrix (WCF vs gRPC) + +The SDK speaks two wire protocols. **WCF** is the 2020 binary MDAS protocol over +Net.TCP `32568` (transports `LocalPipe`, `RemoteTcpIntegrated`, +`RemoteTcpCertificate`); **gRPC** is the 2023 R2 HCAL transport over HTTP/2 +`32565` (transport `RemoteGrpc`). The core read chain reuses the *same* native +Open2 buffers and SSPI tokens on both — on gRPC they simply ride inside protobuf +`bytes` fields — so reads are at parity. The surfaces diverge at the edges. + +Legend: ✅ tooled + live-verified · ⚠️ tooled, partial/synthesized · +🔌 **the gRPC server exposes the RPC (recovered in `Grpc/Protos/*.proto`) but the +SDK doesn't drive it yet** — untooled/uncaptured, *not* a protocol gap · +❌ unavailable on that transport. + +| Operation | WCF | gRPC | Notes | +|---|:---:|:---:|---| +| `ProbeAsync` | ✅ | ✅ | | +| `ReadRawAsync` | ✅ | ✅ | | +| `ReadAggregateAsync` | ✅ | ✅ | all 16 retrieval modes | +| `ReadAtTimeAsync` | ✅ | ✅ | | +| `BrowseTagNamesAsync` | ✅ | ✅ | | +| `GetTagMetadataAsync` | ✅ | ✅ | | +| `GetSystemParameterAsync` | ✅ | ✅ | | +| `AddHistoricalValuesAsync` | ❌ | ✅ | historical/backfill writes ride `HistoryService.AddStreamValues`; non-gRPC throws `ProtocolEvidenceMissingException` | +| `GetServerTimeZoneAsync` | ❌ | ✅ | 2020 `GetSystemTimeZoneName` is a client-side stub (empty); WCF throws | +| `GetStoreForwardStatusAsync` | ⚠️ | ✅ | gRPC contacts the server (measured idle-state, reports `ErrorOccurred`); WCF returns synthesized all-false. Active-SF magnitude is D2-gated on both | +| `ReadEventsAsync` | ✅ | 🔌 | gRPC `RetrievalService.StartEventQuery` / `GetNextEventQueryResultBuffer` / `EndEventQuery` recovered (`bytes btRequest` + handle); not tooled over gRPC | +| `SendEventAsync` | ✅ | 🔌 | rides `AddStreamValues` family; no distinct event-send RPC, framing uncaptured over gRPC | +| `EnsureTagAsync` / `DeleteTagAsync` / `RenameTagsAsync` | ✅ | 🔌 | gRPC `HistoryService.EnsureTags` / `DeleteTags` / `StartJob`(+`GetJobStatus`) recovered (`bytes btTagInfos`/`btTagnames`/`btInput` + handle) | +| `GetTagExtendedPropertiesAsync` / `AddTagExtendedPropertiesAsync` | ✅ | 🔌 | gRPC `RetrievalService.GetTagExtendedPropertiesFromName` + `HistoryService.AddTagExtendedProperties`; gRPC also exposes `DeleteTagExtendedProperties` (WCF delete was server-blocked) | +| `ExecuteSqlCommandAsync` | ✅ | 🔌 | gRPC `RetrievalService.ExecuteSqlCommand` (`StrCommand` + `uiOption`, mirrors WCF `ExeC`/`GetR`) | +| `GetRuntimeParameterAsync` | ✅ | 🔌 | gRPC `StatusService.GetRuntimeParameter` (`bytes btRequest` + handle) | +| `GetConnectionStatusAsync` | ✅ | ❌ | synthesized from an authenticated probe — no dedicated RPC on either transport (gRPC `PingServer`/`GetHistorianConsoleStatus` could synthesize it) | +| `ReadBlocksAsync` | ❌ | ❌ | `StartBlockRetrievalQuery` never captured on either transport — throws `ProtocolEvidenceMissingException` | + +In short: **WCF is the broad, mature surface** (every config write, events, SQL, +and all reads), while **gRPC is the narrower *tooled* surface** — but the 2023 R2 +gRPC *contract* is actually a **superset** of WCF. Every 🔌 row above has a +recovered RPC carrying the **same opaque `bytes` buffers the existing WCF +serializers already emit**, keyed by the same `strHandle`/`uiHandle` session +handle the gRPC read path already obtains. So these are **capture-and-wire** items +(route the existing serializer into a gRPC orchestrator + golden-capture the +framing), **not** protocol-discovery items. We have only *buffer-verified* two +gRPC families live — the read chain and `AddStreamValues` — so per the +"capture first, never guess wire bytes" rule the 🔌 rows stay untooled until each +is captured. The natural production pattern today remains WCF for config/reads and +`RemoteGrpc` reserved for `AddHistoricalValuesAsync`. + +> A 2023 R2 server reports History interface version 12 (vs. 11 on 2020). The +> connect-time version gate accepts both — they are byte-compatible — so gRPC +> works against a v12 server with the default `VerifyServerInterfaceVersion=true`; +> no opt-out is required. + +## Resilience subsystems (M4) + +Two **pure client-side** subsystems layered on top of `HistorianClient`. They use +only the public SDK surface — no extra reverse-engineering, no server-side +protocol — and are fully unit-tested without a live server. + +### Store-and-forward (`AVEVA.Historian.Client.StoreForward`) + +A durable local outbox that buffers writes when the Historian is unreachable and +replays them on reconnect. `HistorianStoreForwardWriter` wraps a `HistorianClient` +(or any `IHistorianWriteSink`) and persists each enqueued write to an +`IHistorianOutboxStore` — `FileHistorianOutboxStore` (crash-durable, atomic +JSON-per-entry, FIFO by filename sequence, corrupt-file quarantine) or +`InMemoryHistorianOutboxStore`. A background drain loop retries with FIFO +head-of-line ordering, optional `MaxDeliveryAttempts` dead-lettering, and a +`DropOldest`/`Reject` overflow policy. + +```csharp +using AVEVA.Historian.Client.StoreForward; + +await using HistorianStoreForwardWriter writer = new( + client, + new FileHistorianOutboxStore(@"C:\ProgramData\histsdk\outbox")); +await writer.StartAsync(); // background drain on reconnect + +await writer.EnqueueHistoricalValuesAsync("MyTag", + [new HistorianHistoricalValue(DateTime.UtcNow, 42.0)]); // returns immediately, durable + +HistorianStoreForwardStatusSnapshot status = await writer.GetStatusAsync(); +// status.PendingCount / .Storing / .ErrorOccurred — mirrors the server SF semantics +``` + +This is a pragmatic outbox, **not** the bit-faithful native SF cache +(`Forward*Snapshot` decode), which stays deferred. + +### Multi-historian redundancy (`AVEVA.Historian.Client.Redundancy`) + +`HistorianRedundantClient` fronts N members as one logical client. Reads fail over +to the next healthy member in priority order (streaming reads fail over only +*before the first row* — mid-stream failures propagate to avoid duplicated/skipped +rows); writes fan out (`AllMembers`/`PreferredOnly`) under an `All`/`Any` +acknowledgement policy and return a per-member `HistorianRedundantWriteResult`. A +failure-threshold demotion + background watchdog restores recovered members. + +```csharp +using AVEVA.Historian.Client.Redundancy; + +await using HistorianRedundantClient cluster = new( +[ + new HistorianClientMember("primary", primaryClient), + new HistorianClientMember("secondary", secondaryClient), +]); +await cluster.StartAsync(); // health watchdog + +await foreach (HistorianSample s in cluster.ReadRawAsync("MyTag", startUtc, endUtc, 100)) +{ + // served by the first healthy member; transparently fails over on connect +} + +HistorianRedundantWriteResult w = await cluster.AddHistoricalValuesAsync("MyTag", + [new HistorianHistoricalValue(DateTime.UtcNow, 42.0)]); // fanned out across members +``` + +Backing a member's writes with a `HistorianStoreForwardWriter` gives the pragmatic +equivalent of native ReSyncTags: a down member buffers locally and replays on +recovery. + ## Build & test ```powershell @@ -80,6 +200,28 @@ $env:HISTORIAN_USER, $env:HISTORIAN_PASSWORD $env:HISTORIAN_TAG_FILTER = 'Sys*' # or any LIKE-pattern ``` +The 2023 R2 `RemoteGrpc` transport has its own gated live suite +(`HistorianGrpcIntegrationTests`) covering the full tooled gRPC surface — probe, +raw / aggregate (incl. multiple retrieval modes) / at-time reads, browse, +metadata, system-parameter, server time-zone, and measured store-forward status. +It skips cleanly unless `HISTORIAN_GRPC_HOST` is set: + +```powershell +$env:HISTORIAN_GRPC_HOST = 'my-2023r2-host' # gates the gRPC suite +$env:HISTORIAN_TEST_TAG = 'SysTimeSec' +$env:HISTORIAN_USER, $env:HISTORIAN_PASSWORD # required for the authenticated ops +# Optional: +$env:HISTORIAN_GRPC_PORT = '32565' # default 32565 +$env:HISTORIAN_GRPC_TLS = 'true' # gRPC over TLS +$env:HISTORIAN_GRPC_DNSID = 'my-2023r2-host' # cert DNS name when connecting by IP +$env:HISTORIAN_GRPC_TIMEOUT = '120' # per-call deadline (s); raise for slow links +$env:HISTORIAN_WRITE_SANDBOX_TAG = 'MyFloatTag' # gates the AddHistoricalValues write test +``` + +The aggregate tests self-calibrate their query window from a real raw sample, so +they pass against an idle 2023 R2 server (no recent collection) as well as a +live-collecting one. + ## Repository layout ``` @@ -165,10 +307,17 @@ property dictionary → Retr.EndEventQuery → Hist.Close2 ## Status -165 unit + live integration tests pass (`dotnet test --logger "console;verbosity=minimal"`). +313 unit + live integration tests pass (`dotnet test --logger "console;verbosity=minimal"`). Full SDK surface — reads, browse, metadata, status, plus the two write ops (`EnsureTagAsync` / `DeleteTagAsync`) — verified end-to-end against both a local Historian (`LocalPipe`) and a remote Historian (`RemoteTcpIntegrated` over Net.TCP with Windows transport auth). `RemoteTcpCertificate` ProbeAsync is live-verified; deeper coverage over the cert transport plus the explicit-credentials path await additional verification. + +The 2023 R2 `RemoteGrpc` transport's full tooled surface is live-verified against +a real 2023 R2 server: probe, raw / aggregate (TimeWeightedAverage + +Minimum/MaximumWithTime + BestFit) / at-time reads, browse, metadata, +system-parameter, server time-zone, and measured store-forward status — plus the +gRPC-only `AddHistoricalValuesAsync` backfill write. The remaining gRPC config ops +are exposed by the server but untooled (see the transport matrix above). diff --git a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs index ba9f984..96930be 100644 --- a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs +++ b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs @@ -32,10 +32,11 @@ internal enum HistorianServiceInterface /// The Status (Stat) service's GetInterfaceVersion returns 0 (not a real /// version), so the Status interface is validated for reachability only, never value. /// -/// A 2023 R2 gRPC server may report different integers even though it carries the same -/// proven 2020 native buffers; until those integers are captured, point such a server at -/// this gate with set to -/// . +/// A 2023 R2 gRPC server reports History interface version 12 even though it carries the +/// same proven 2020 native buffers. That value is captured and accepted (see +/// ), so a v12 server passes with the default +/// =; +/// the opt-out is only a safety valve for some future, not-yet-captured interface integer. /// internal static class HistorianServerVersionGate {