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);