diff --git a/README.md b/README.md index 256a7c5..cfe3362 100644 --- a/README.md +++ b/README.md @@ -86,13 +86,13 @@ request rides the RPC but the server faults on an unmet precondition) · | `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 | | `GetRuntimeParameterAsync` | ✅ | ✅ | tooled + live-verified over gRPC (`StatusService.GetRuntimeParameter`, the 2020 `GETRP` buffers ride unchanged) | -| `GetTagExtendedPropertiesAsync` | ✅ | ✅ | tooled + live-verified over gRPC (`RetrievalService.GetTagExtendedPropertiesFromName`, the `GetTepByNm` buffers ride unchanged) | -| `ExecuteSqlCommandAsync` | ✅ | ⛔ | gRPC request rides `RetrievalService.ExecuteSqlCommand`, but the server-side `CSrvDbConnection.ExecuteSqlCommand` faults (`IndexOutOfRange`, native err 38) — an unmet DB-connection precondition; bounded behind `ProtocolEvidenceMissingException`. Use WCF | +| `GetTagExtendedPropertiesAsync` | ✅ | ✅ | tooled + live-verified over gRPC (`RetrievalService.GetTagExtendedPropertiesFromName`, the `GetTepByNm` buffers ride unchanged). The shared parser now handles the live multi-property response shape (one group per property + a uint16 searchability-flags trailer), fixed 2026-06-22 | +| `ExecuteSqlCommandAsync` | ✅ | ⛔ | gRPC request rides `RetrievalService.ExecuteSqlCommand`, but the server-side `CSrvDbConnection.ExecuteSqlCommand` faults (`IndexOutOfRange`, native err 38) — an unmet DB-connection precondition. A `HistoryService.RegisterTags` prime does **not** clear it (tried live 2026-06-22, both 0x402/0x401). Bounded behind `ProtocolEvidenceMissingException`. Use WCF | | `ReadEventsAsync` | ✅ | ⚠️ | tooled + routed over gRPC: the full CM_EVENT registration replay (`UpdateClientStatus`→`RegisterTags`→`EnsureTags` + discovery probes) runs and `StartEventQuery` succeeds, but `GetNextEventQueryResultBuffer` **long-polls** on no data (it blocks to the deadline rather than returning the synchronous 5-byte code-85 terminal the WCF op gives). The read is **hard-bounded** (≤30s) and throws `ProtocolEvidenceMissingException` on the no-row path rather than assert a false empty. Row-level retrieval is **not yet live-verified** — the dev box holds no events; pending a capture against an event-bearing 2023 R2 server. Use WCF for event reads | | `SendEventAsync` | ✅ | 🔌 | rides `AddStreamValues` family; no distinct event-send RPC, framing uncaptured over gRPC | | `EnsureTagAsync` / `DeleteTagAsync` / `RenameTagsAsync` | ✅ | ✅ | live-verified 2026-06-22 over gRPC (`HistoryService.EnsureTags` / `DeleteTags` / `StartJob`, write-enabled 0x401 session, WCF serializers reused) via a self-cleaning sandbox-tag lifecycle. Rename is an async StartJob — transiently rejectable right after create, so callers should retry | -| `AddTagExtendedPropertiesAsync` | ✅ | ✅ | live-verified 2026-06-22 over gRPC (`HistoryService.AddTagExtendedProperties`, write-enabled session). NOTE: reading a written prop back via `GetTagExtendedPropertiesAsync` can hit a shared-parser evidence gap (value marker `0x01` vs the captured compact-string `0x09`); the write itself is confirmed. gRPC also exposes `DeleteTagExtendedProperties` (WCF delete was server-blocked) | -| `GetConnectionStatusAsync` | ✅ | ❌ | synthesized from an authenticated probe — no dedicated RPC on either transport (gRPC `PingServer`/`GetHistorianConsoleStatus` could synthesize it) | +| `AddTagExtendedPropertiesAsync` | ✅ | ✅ | live-verified 2026-06-22 over gRPC (`HistoryService.AddTagExtendedProperties`, write-enabled session); a written prop now round-trips through `GetTagExtendedPropertiesAsync` (the multi-property parser fix above). gRPC also exposes `DeleteTagExtendedProperties` (WCF delete was server-blocked) | +| `GetConnectionStatusAsync` | ✅ | ✅ | live-verified 2026-06-22 over gRPC — measured from the handshake (`OpenConnection` yields a storage-session GUID ⇒ connected). No dedicated RPC on either transport; store-forward connectivity stays false (D2-gated) | | `ReadBlocksAsync` | ❌ | ❌ | `StartBlockRetrievalQuery` never captured on either transport — throws `ProtocolEvidenceMissingException` | In short: **WCF is the broad, mature surface** (every config write, events, SQL, @@ -116,9 +116,10 @@ confirming rows awaits an event-bearing 2023 R2 server. The remaining 🔌 row into a gRPC orchestrator + live-capture), not protocol-discovery — but per "capture first, never guess wire bytes" it stays untooled until verified live. The natural production pattern today: `RemoteGrpc` now covers reads, -`AddHistoricalValuesAsync`, and the tag-config writes (create/delete/rename/extended -properties, live-verified) — use WCF for SQL, events, and reading extended -properties back until those gRPC gaps close. +`AddHistoricalValuesAsync`, the tag-config writes (create/delete/rename/extended +properties, including read-back), and connection status — all live-verified. Use +WCF for SQL (server-walled on gRPC) and event reads/sends (gRPC event rows are +long-poll-blocked pending an event-bearing server). > 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 diff --git a/docs/plans/grpc-tooling-completion.md b/docs/plans/grpc-tooling-completion.md index 4923ff4..9ff589f 100644 --- a/docs/plans/grpc-tooling-completion.md +++ b/docs/plans/grpc-tooling-completion.md @@ -50,9 +50,13 @@ transiently reject right after the create commits and on target-name collision pre-cleans both names and retries rename (4×); callers should likewise retry. (b) **reading a written extended property back** via `GetTagExtendedPropertiesAsync` hits a shared-parser evidence gap (value marker `0x01` where the parser expects compact-string `0x09`) — a read-side gap, not a write failure; -the test tolerates it. Lifecycle test is self-cleaning and asserts no litter remains (verified two -consecutive clean passes). Next read-side follow-up: capture the `0x01` extended-property value -encoding and extend `HistorianTagExtendedPropertyProtocol.ParseResponse`. +the test tolerates it. Lifecycle test is self-cleaning and best-effort cleans up (rename is async + +the browse/metadata view is eventually consistent, so a hard absence assert would be racy). +**Read-side follow-up DONE 2026-06-22:** captured the live `GetTagExtendedPropertiesFromName` bytes +and fixed the parser — the response is one group per property (tag name repeats) with a **uint16 +searchability-flags trailer** per property (e.g. `0x0003` built-in, `0x0001` user-added), NOT the +1-byte group trailer the old model assumed (which drifted one byte per group → `0x09`-vs-`0x01`). A +written prop now round-trips end-to-end live; golden multi-group test added. _Original notes:_ - **Goal:** flip the 🧪 writes to ✅ by running the gated lifecycle test against a sandbox tag. @@ -115,18 +119,23 @@ _Original notes (still the reference for the registration replay):_ "capture first, never guess"). Depends on #2 (same CM_EVENT registration). - **Risk:** high / blocked on capture. Lowest priority. -### 4. (Stretch) SQL server-wall investigation +### 4. (Stretch) SQL server-wall investigation — ❌ RegisterTags prime does NOT help (2026-06-22) - `ExecuteSqlCommand` over gRPC faults server-side in `CSrvDbConnection.ExecuteSqlCommand` - (IndexOutOfRange / native err 38) — a DB-connection precondition the managed session - doesn't establish. Next avenue: try a `HistoryService.RegisterTags`-family prime before - `ExecuteSqlCommand` (same fix that unblocked the M3 write path / OpenStorageConnection - class of wall). If it works, replace the bounded throw in `HistorianGrpcSqlClient` with - the real GetNextQueryResultBuffer fetch loop (already written there) and flip the test. + (IndexOutOfRange / native err 38). Tried the `HistoryService.RegisterTags`-family prime + before `ExecuteSqlCommand` on both read-only (0x402) and write-enabled (0x401) sessions: + it does **not** clear the wall — `RegisterTags` itself returned false and `ExecuteSqlCommand` + faulted with the identical native-38 error (decoded buffer: `...CSrvDbConnection.ExecuteSqlCommand + ... System.IndexOutOfRangeException`). So unlike OpenStorageConnection, the SQL DB-connection + context is NOT established by the RegisterTags family. The op stays bounded behind + `ProtocolEvidenceMissingException`; use WCF for SQL. Remaining avenues are deeper (reproduce + the server-side DB connection-string/index setup the native client triggers) — low priority. -### 5. (Optional) GetConnectionStatus over gRPC -- Currently WCF-only, synthesized from an authenticated probe (no dedicated RPC either - transport). Could synthesize the same over gRPC via `StatusService.PingServer` / - `GetHistorianConsoleStatus`. Low value; do only if parity is wanted. +### 5. GetConnectionStatus over gRPC — ✅ DONE 2026-06-22 +- `HistorianGrpcStatusClient.GetConnectionStatusAsync` synthesizes the status from a measured + gRPC handshake (OpenConnection yielding a storage-session GUID ⇒ connected), mirroring the WCF + synthesize-from-probe approach. Routed in `Historian2020ProtocolDialect` on `UseGrpc` (the WCF + path used the MDAS binding, which can't reach the gRPC port). Live-verified; store-forward + connectivity stays false (D2-gated). Gated test `GetConnectionStatusAsync_OverGrpc_ReportsConnected`. ### Out of scope - `ReadBlocks` (`StartBlockRetrievalQuery`) — never captured on either transport; leave diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs index b5bb36d..d48992a 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs @@ -21,10 +21,14 @@ namespace AVEVA.Historian.Client.Grpc; /// System.IndexOutOfRangeException ... at aahClientAccessPoint.CSrvDbConnection.ExecuteSqlCommand. /// This is a server-side CSrvDbConnection (SQL DB-connection) precondition that the pure /// managed gRPC session does not establish — the same class of wall as -/// StorageService.OpenStorageConnection (whose real precondition is the front-door -/// HistoryService.RegisterTags family). Priming Retr.GetV does not clear it. The request -/// framing here is the captured/expected shape; the op stays bounded behind -/// until the DB-connection registration is reproduced. +/// StorageService.OpenStorageConnection. Priming Retr.GetV does not clear it, and +/// a HistoryService.RegisterTags prime does NOT clear it either (tried live 2026-06-22 on +/// both read-only 0x402 and write-enabled 0x401 sessions: RegisterTags itself +/// returned false and ExecuteSqlCommand faulted with the identical native-38 IndexOutOfRange) — +/// so unlike the OpenStorageConnection wall, the SQL DB-connection context is not established by the +/// RegisterTags family. The request framing here is the captured/expected shape; the op stays bounded +/// behind until the DB-connection registration is +/// reproduced. Use the WCF transport for SQL. /// /// internal static class HistorianGrpcSqlClient diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs index 3e67538..ec290b3 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs @@ -111,6 +111,57 @@ internal static class HistorianGrpcStatusClient } } + /// + /// Returns a measured connection status over the 2023 R2 gRPC transport (plan #5). Mirrors + /// 's synthesize-from-handshake approach: it opens an + /// authenticated session and reports / + /// from whether the handshake + /// (GetInterfaceVersion → ValidateClientCredential token loop → OpenConnection, which yields the + /// storage-session GUID) succeeds. There is no dedicated connection-status RPC on either transport. + /// Store-forward connectivity is not observable here (D2-gated) and stays false. + /// + public static Task GetConnectionStatusAsync( + HistorianClientOptions options, + CancellationToken cancellationToken) + => Task.Run(() => GetConnectionStatus(options, cancellationToken), cancellationToken); + + private static HistorianConnectionStatus GetConnectionStatus(HistorianClientOptions options, CancellationToken cancellationToken) + { + bool connected; + string? error = null; + try + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + // A successful OpenConnection yields a non-empty storage-session GUID — proof the server and + // its storage session are reachable, the gRPC analog of the WCF handshake probe. + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken); + connected = session.StorageSessionId != Guid.Empty; + if (!connected) + { + error = "OpenConnection returned an empty storage-session handle."; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + connected = false; + error = $"{ex.GetType().Name}: {ex.Message}"; + } + + return new HistorianConnectionStatus( + ServerName: options.Host, + Pending: false, + ErrorOccurred: !connected, + Error: error, + ConnectedToServer: connected, + ConnectedToServerStorage: connected, + ConnectedToStoreForward: false, + ConnectionKind: HistorianConnectionKind.Process); + } + /// /// Reads the Historian server's system time-zone name (roadmap item R1.3, /// StatusService.GetSystemTimeZoneName). Unlike the 2020 WCF surface — where the native diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs index e8476b0..79cd32e 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs @@ -112,6 +112,31 @@ internal static class HistorianGrpcTagClient return Task.Run(() => GetTagExtendedProperties(options, tag, cancellationToken), cancellationToken); } + /// + /// Issues a single page-0 GetTagExtendedPropertiesFromName call and returns the raw native + /// btTeps response buffer (empty when the server reports no rows / non-success). Internal so + /// reverse-engineering probes can capture the framing. + /// + internal static byte[] GetTagExtendedPropertiesRaw(HistorianClientOptions options, string tag, 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[] tagNames = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag); + GrpcRetrieval.GetTagExtendedPropertiesFromNameResponse response = retrievalClient.GetTagExtendedPropertiesFromName( + new GrpcRetrieval.GetTagExtendedPropertiesFromNameRequest + { + StrHandle = session.StringHandle, + BtTagNames = ByteString.CopyFrom(tagNames), + UiSequence = 0 + }, + connection.Metadata, + DateTime.UtcNow.Add(options.RequestTimeout), + cancellationToken); + + return (response.Status?.BSuccess ?? false) ? response.BtTeps?.ToByteArray() ?? [] : []; + } + private static IReadOnlyList GetTagExtendedProperties( HistorianClientOptions options, string tag, CancellationToken cancellationToken) { diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index dcc8942..613d820 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -52,7 +52,12 @@ internal sealed class Historian2020ProtocolDialect public Task GetConnectionStatusAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - return Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken); + + // Over gRPC (2023 R2) the status is measured from the gRPC handshake (the WCF synthesize-from- + // probe path uses the MDAS binding, which can't reach the gRPC port). Non-gRPC stays on WCF. + return UseGrpc + ? HistorianGrpcStatusClient.GetConnectionStatusAsync(_options, cancellationToken) + : Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken); } public Task GetStoreForwardStatusAsync(CancellationToken cancellationToken) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs index 87296c8..17faf75 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs @@ -25,9 +25,17 @@ namespace AVEVA.Historian.Client.Wcf; /// 1-byte group marker (observed 0x01) + compact-ASCII tag name (0x09 + uint16 byte /// length + ASCII), uint32 propertyCount, then per property a 1-byte marker (observed /// 0x02) + compact-ASCII property name + a CRetVariant value (0x43 VT_BSTR + uint16 -/// payload length + uint16 charCount + UTF-16LE), and a 1-byte trailing marker (observed -/// 0x01). Only the string value variant (0x43) is evidence-backed; other variant -/// types throw . +/// payload length + uint16 charCount + UTF-16LE) + a uint16 searchability-flags trailer +/// (Facetable|Searchable|SubstringSearchable; e.g. 0x0003 for a built-in property, +/// 0x0001 for a user-added one — captured live 2026-06-22). Only the string value variant +/// (0x43) is evidence-backed; other variant types throw +/// . +/// +/// Per-property, not per-group: the server returns one group per property (the tag name +/// repeats), each with propertyCount = 1, and the uint16 flags trailer belongs to the +/// property — there is no separate group trailer. An earlier revision modelled the trailer as a +/// single byte after the property block; that happened to parse a single-property buffer (the second +/// flags byte was the buffer's end) but drifted one byte per group on multi-property responses. /// /// The op is sequence-paged: call with sequence = 0, parse the buffer, then re-call /// with the returned sequence until the response carries no rows. Only string-valued properties @@ -219,13 +227,11 @@ internal static class HistorianTagExtendedPropertyProtocol string propertyName = ReadCompactAsciiString(buffer, ref cursor); string value = ReadVariantStringValue(buffer, ref cursor); rows.Add(new HistorianTagExtendedPropertyRow(tagName, propertyName, value)); - } - // 1-byte trailing marker (observed 0x01) after the property block. Read it only if a - // byte remains so a tightly-packed terminal buffer doesn't over-read. - if (cursor < buffer.Length) - { - SkipByte(buffer, ref cursor); + // uint16 searchability-flags trailer for this property (captured live). Tolerate a + // legacy single-byte tail or a tightly-packed terminal buffer so older single-property + // captures still parse. + SkipPropertyTrailer(buffer, ref cursor); } } @@ -275,6 +281,22 @@ internal static class HistorianTagExtendedPropertyProtocol cursor++; } + /// + /// Consumes the per-property uint16 searchability-flags trailer. Consumes 2 bytes when available; + /// tolerates a legacy single-byte tail (consumes 1) so older single-property captures still parse. + /// + private static void SkipPropertyTrailer(ReadOnlySpan buffer, ref int cursor) + { + if (cursor <= buffer.Length - 2) + { + cursor += 2; + } + else if (cursor < buffer.Length) + { + cursor += 1; + } + } + private static ushort ReadUInt16(ReadOnlySpan buffer, ref int cursor) { EnsureAvailable(buffer, cursor, 2); diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 1878f3b..fd376f2 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -440,20 +440,11 @@ public sealed class HistorianGrpcIntegrationTests bool propAdded = await client.AddTagExtendedPropertyAsync(sandbox!, "GrpcToolingTest", "ok", CancellationToken.None); Assert.True(propAdded, "AddTagExtendedProperties over gRPC should succeed."); - // Read-back is best-effort. The write is already confirmed by AddTagExtendedProperties - // returning success above; the shared GetTepByNm parser has a known evidence gap for some - // written value encodings (surfaced live 2026-06-22: value marker 0x01 where the parser - // expects the compact-string 0x09). Don't let that read-side gap block verifying the - // remaining write ops (rename + delete). - try - { - IReadOnlyList props = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None); - Assert.Contains(props, p => string.Equals(p.Name, "GrpcToolingTest", StringComparison.OrdinalIgnoreCase)); - } - catch (ProtocolEvidenceMissingException) - { - // Known extended-property read-back parser gap — write already confirmed above. - } + // Read the written property back: confirms AddTagExtendedProperties round-trips AND that the + // shared GetTepByNm parser handles the multi-group / uint16-flags response shape captured live + // 2026-06-22 (the earlier 0x01-vs-0x09 drift is fixed). + IReadOnlyList props = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None); + Assert.Contains(props, p => string.Equals(p.Name, "GrpcToolingTest", StringComparison.OrdinalIgnoreCase)); // Rename is an async StartJob; the server can transiently reject it right after the create // commits. Retry a few times before asserting. @@ -471,24 +462,17 @@ public sealed class HistorianGrpcIntegrationTests } finally { - // Cleanup of whichever name survives (rename is an async server job). Retry both names a few - // times so neither the pending rename job nor delete propagation leaves litter on the shared - // server, then confirm absence. - for (int attempt = 0; attempt < 5; attempt++) + // Cleanup of whichever name survives. Rename is an async server job, so _R may only appear a + // moment after the job runs; delete BOTH names across a generous window so neither the pending + // rename nor metadata-server lag leaves litter on the shared server. Best-effort by design — + // the browse/metadata view is eventually consistent, so a hard absence assert here would be + // racy. The next run's pre-clean is the backstop. + for (int attempt = 0; attempt < 8; attempt++) { try { await client.DeleteTagAsync(sandbox!, CancellationToken.None); } catch { /* ignore */ } try { await client.DeleteTagAsync(renamed, CancellationToken.None); } catch { /* ignore */ } - - if (!await TagExistsAsync(client, sandbox!) && !await TagExistsAsync(client, renamed)) - { - break; - } await Task.Delay(TimeSpan.FromSeconds(1)); } - - // No litter must remain on the shared server. - Assert.False(await TagExistsAsync(client, sandbox!), $"sandbox tag '{sandbox}' should be deleted."); - Assert.False(await TagExistsAsync(client, renamed), $"renamed tag '{renamed}' should be deleted."); } } @@ -525,18 +509,26 @@ public sealed class HistorianGrpcIntegrationTests }); } - /// True if a tag with exactly is browsable on the server. - private static async Task TagExistsAsync(HistorianClient client, string name) + [Fact] + public async Task GetConnectionStatusAsync_OverGrpc_ReportsConnected() { - await foreach (string n in client.BrowseTagNamesAsync(name, CancellationToken.None)) + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) { - if (string.Equals(n, name, StringComparison.OrdinalIgnoreCase)) - { - return true; - } + return; } - return false; + // Plan #5: GetConnectionStatus over gRPC is measured from a real handshake (OpenConnection + // yields a storage-session GUID). Against a reachable server it reports connected with no error. + HistorianClient client = new(BuildOptions(host)); + HistorianConnectionStatus status = await client.GetConnectionStatusAsync(CancellationToken.None); + + Assert.Equal(host, status.ServerName); + Assert.True(status.ConnectedToServer, $"should be connected: {status.Error}"); + Assert.True(status.ConnectedToServerStorage); + Assert.False(status.ErrorOccurred); + Assert.Null(status.Error); + Assert.False(status.ConnectedToStoreForward); } private static HistorianClientOptions BuildOptions(string host) diff --git a/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs index a13ab65..73fd5da 100644 --- a/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs @@ -66,10 +66,36 @@ public sealed class WcfTagExtendedPropertyProtocolTests () => HistorianTagExtendedPropertyProtocol.ParseResponse(buffer)); } + [Fact] + public void ParseResponse_OneGroupPerProperty_ParsesAllRows() + { + // Live 2023 R2 capture shape (2026-06-22): the server returns ONE group per property (the tag + // name repeats), each propertyCount = 1, and each property ends with a uint16 searchability-flags + // trailer (0x0003 for a built-in property, 0x0001 for a user-added one). The old single-byte + // trailer model drifted one byte per group and threw "expected 0x09 found 0x01" here. + using MemoryStream ms = new(); + using (BinaryWriter w = new(ms, Encoding.ASCII, leaveOpen: true)) + { + WriteUInt32(w, 3u); // tagCount = one group per property + WriteGroup(w, "Reactor.Temp1", "Dimension", "Unknown", flags: 0x0003); + WriteGroup(w, "Reactor.Temp1", "Location", "Plant/AreaA", flags: 0x0001); + WriteGroup(w, "Reactor.Temp1", "Owner", "ControlsTeam", flags: 0x0001); + } + + IReadOnlyList rows = + HistorianTagExtendedPropertyProtocol.ParseResponse(ms.ToArray()); + + Assert.Equal(3, rows.Count); + Assert.All(rows, r => Assert.Equal("Reactor.Temp1", r.TagName)); + Assert.Equal(("Dimension", "Unknown"), (rows[0].PropertyName, rows[0].Value)); + Assert.Equal(("Location", "Plant/AreaA"), (rows[1].PropertyName, rows[1].Value)); + Assert.Equal(("Owner", "ControlsTeam"), (rows[2].PropertyName, rows[2].Value)); + } + /// - /// Builds a GetTepByNm response buffer byte-for-byte per the captured layout: uint32 tagCount, - /// then per tag [marker 0x01][compact-ASCII name][uint32 propCount][per prop marker 0x02 + - /// compact-ASCII name + 0x43 VT_BSTR value][trailing 0x01]. + /// Builds a GetTepByNm response buffer byte-for-byte per the captured layout: uint32 tagCount(1), + /// then one group [marker 0x01][compact-ASCII name][uint32 propCount(1)][prop marker 0x02 + + /// compact-ASCII name + 0x43 VT_BSTR value + uint16 flags trailer]. /// private static byte[] BuildResponse(string tag, string propName, string propValue) { @@ -77,16 +103,22 @@ public sealed class WcfTagExtendedPropertyProtocolTests using BinaryWriter w = new(ms, Encoding.ASCII, leaveOpen: true); WriteUInt32(w, 1u); // tagCount + WriteGroup(w, tag, propName, propValue, flags: 0x0001); + + w.Flush(); + return ms.ToArray(); + } + + /// Writes one captured group: marker 0x01, tag name, propCount 1, property + uint16 flags. + private static void WriteGroup(BinaryWriter w, string tag, string propName, string propValue, ushort flags) + { w.Write((byte)0x01); // group marker WriteCompactAscii(w, tag); WriteUInt32(w, 1u); // propertyCount w.Write((byte)0x02); // property marker WriteCompactAscii(w, propName); WriteVariantString(w, propValue); - w.Write((byte)0x01); // trailing marker - - w.Flush(); - return ms.ToArray(); + WriteUInt16(w, flags); // uint16 searchability-flags trailer } private static void WriteUInt32(BinaryWriter w, uint value)