Files
histsdk/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs
T
Joseph Doherty 3525653c2b fix(grpc): extended-property read parser + GetConnectionStatus over gRPC
- HistorianTagExtendedPropertyProtocol.ParseResponse: fix the multi-property/
  multi-group response shape captured live from the 2023 R2 server. The server
  returns one group per property (the tag name repeats), each propertyCount=1, and
  a uint16 searchability-flags trailer per property (0x0003 built-in, 0x0001 user-
  added) — NOT the single-byte group trailer the old model assumed, which drifted
  one byte per group and threw "expected 0x09 found 0x01" on any buffer with more
  than one property. Now reads the per-property uint16 trailer (tolerates a legacy
  1-byte tail). Fixes read-back on both WCF and gRPC. Adds GetTagExtendedPropertiesRaw
  for future captures.
- HistorianGrpcStatusClient.GetConnectionStatusAsync (plan #5): synthesize connection
  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).
- HistorianGrpcSqlClient: record the negative plan-#4 result — a HistoryService.
  RegisterTags prime does NOT clear the server-side CSrvDbConnection fault (tried live
  on both 0x402/0x401); the op stays bounded behind ProtocolEvidenceMissingException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
2026-06-22 06:03:38 -04:00

241 lines
12 KiB
C#

using Google.Protobuf;
using Grpc.Core;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
namespace AVEVA.Historian.Client.Grpc;
/// <summary>
/// 2023 R2 gRPC status client (roadmap item R0.3). Mirrors
/// <see cref="Wcf.HistorianWcfStatusClient"/> over the gRPC transport: it opens an authenticated
/// History session via <see cref="HistorianGrpcHandshake"/> and queries the StatusService for the
/// resulting client handle. <c>GetSystemParameter</c> carries the parameter name as a protobuf
/// string and returns the value string directly — there is no opaque native buffer to decode.
/// </summary>
internal static class HistorianGrpcStatusClient
{
public static Task<string?> GetSystemParameterAsync(
HistorianClientOptions options,
string parameterName,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
return Task.Run(() => GetSystemParameter(options, parameterName, cancellationToken), cancellationToken);
}
private static string? GetSystemParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken)
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken);
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
GrpcStatus.GetSystemParameterResponse response = statusClient.GetSystemParameter(
new GrpcStatus.GetSystemParameterRequest { UiHandle = clientHandle, StrParameterName = parameterName },
connection.Metadata,
DateTime.UtcNow.Add(options.RequestTimeout),
cancellationToken);
return (response.Status?.BSuccess ?? false) ? response.StrParameterValue : null;
}
/// <summary>
/// Returns a <em>measured</em> store-forward status over the 2023 R2 gRPC transport (R4.3).
/// <para>
/// Unlike the non-gRPC <see cref="Wcf.HistorianWcfStatusClient"/> path — which synthesizes an
/// all-false result <em>without contacting the server</em> — this opens an authenticated session
/// and calls <c>StatusService.GetHistorianConsoleStatus</c>, the only SF-adjacent signal reachable
/// from a pure managed client. (The direct <c>StorageService.GetSFParameter</c> /
/// <c>GetRemainingSnapshotsSize</c> RPCs that carry the SF buffer magnitude require the
/// <c>OpenStorageConnection</c> storage-engine console handle, which is gated behind the D2
/// storage-engine-pipe wall and is unobtainable here — see
/// <c>docs/plans/store-forward-cache-reverse-engineering.md</c> §9.7.)
/// </para>
/// <para>
/// Semantics: a successful console-status read means the server is reachable and its storage
/// console is reporting normally ⇒ the not-storing baseline (all flags false), but now
/// <em>measured</em> rather than blindly assumed. If the server cannot be reached/authenticated,
/// or the console-status call itself fails, <see cref="HistorianStoreForwardStatus.ErrorOccurred"/>
/// is set with the underlying error. The active-SF state (<see cref="HistorianStoreForwardStatus.Storing"/>
/// / <see cref="HistorianStoreForwardStatus.Pending"/> / <see cref="HistorianStoreForwardStatus.DataStored"/>
/// magnitude) is NOT observable from this signal and remains false; populating it requires the
/// D2-gated storage-console path.
/// </para>
/// </summary>
public static Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(
HistorianClientOptions options,
CancellationToken cancellationToken)
=> Task.Run(() => GetStoreForwardStatus(options, cancellationToken), cancellationToken);
private static HistorianStoreForwardStatus GetStoreForwardStatus(HistorianClientOptions options, CancellationToken cancellationToken)
{
HistorianStoreForwardStatus NotStoring(bool errorOccurred, string? error) => new(
ServerName: options.Host,
Pending: false,
ErrorOccurred: errorOccurred,
Error: error,
DataStored: false,
Storing: false,
ConnectionKind: HistorianConnectionKind.Process);
try
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
GrpcStatus.GetHistorianConsoleStatusResponse response = statusClient.GetHistorianConsoleStatus(
new GrpcStatus.GetHistorianConsoleStatusRequest { StrHandle = session.StringHandle },
connection.Metadata,
DateTime.UtcNow.Add(options.RequestTimeout),
cancellationToken);
if (response.Status?.BSuccess ?? false)
{
// Measured: server reachable, storage console reporting normally → not-storing baseline.
return NotStoring(errorOccurred: false, error: null);
}
byte[] err = response.Status?.BtError?.ToByteArray() ?? [];
string detail = err.Length == 0 ? "GetHistorianConsoleStatus returned failure." : Convert.ToHexString(err);
return NotStoring(errorOccurred: true, error: $"GetHistorianConsoleStatus failed: {detail}");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Server unreachable / auth failed — genuinely measured: report it instead of a silent all-false.
return NotStoring(errorOccurred: true, error: $"{ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>
/// Returns a <em>measured</em> connection status over the 2023 R2 gRPC transport (plan #5). Mirrors
/// <see cref="Wcf.HistorianWcfStatusClient"/>'s synthesize-from-handshake approach: it opens an
/// authenticated session and reports <see cref="HistorianConnectionStatus.ConnectedToServer"/> /
/// <see cref="HistorianConnectionStatus.ConnectedToServerStorage"/> 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.
/// </summary>
public static Task<HistorianConnectionStatus> 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);
}
/// <summary>
/// Reads the Historian server's system time-zone name (roadmap item R1.3,
/// <c>StatusService.GetSystemTimeZoneName</c>). Unlike the 2020 WCF surface — where the native
/// <c>GetSystemTimeZoneName</c> is a client-side stub that returns an empty string — the 2023 R2
/// gRPC front door returns the real Windows time-zone display name (live-verified:
/// "Eastern Daylight Time"). Takes the transient <c>uint</c> client handle; the response carries
/// the value as a protobuf string with no opaque buffer to decode.
/// </summary>
public static Task<string?> GetSystemTimeZoneNameAsync(
HistorianClientOptions options,
CancellationToken cancellationToken)
=> Task.Run(() => GetSystemTimeZoneName(options, cancellationToken), cancellationToken);
private static string? GetSystemTimeZoneName(HistorianClientOptions options, CancellationToken cancellationToken)
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken);
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
GrpcStatus.GetSystemTimeZoneNameResponse response = statusClient.GetSystemTimeZoneName(
new GrpcStatus.GetSystemTimeZoneNameRequest { UiHandle = clientHandle },
connection.Metadata,
DateTime.UtcNow.Add(options.RequestTimeout),
cancellationToken);
if (!(response.Status?.BSuccess ?? false))
{
return null;
}
string? value = response.StrSystemTimeZoneName;
return string.IsNullOrEmpty(value) ? null : value;
}
/// <summary>
/// Reads a Historian runtime parameter over gRPC (<c>StatusService.GetRuntimeParameter</c>).
/// The request/response byte buffers are the proven 2020 <c>GETRP</c> wire format
/// (<see cref="HistorianRuntimeParameterProtocol"/>) carried unchanged inside the protobuf
/// <c>btRequest</c>/<c>btResponse</c> fields; the op keys on the uppercase string session handle.
/// </summary>
public static Task<string?> GetRuntimeParameterAsync(
HistorianClientOptions options,
string parameterName,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
return Task.Run(() => GetRuntimeParameter(options, parameterName, cancellationToken), cancellationToken);
}
private static string? GetRuntimeParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken)
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
byte[] request = HistorianRuntimeParameterProtocol.SerializeRequest(parameterName);
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
GrpcStatus.GetRuntimeParameterResponse response = statusClient.GetRuntimeParameter(
new GrpcStatus.GetRuntimeParameterRequest
{
StrHandle = session.StringHandle,
BtRequest = ByteString.CopyFrom(request)
},
connection.Metadata,
DateTime.UtcNow.Add(options.RequestTimeout),
cancellationToken);
if (!(response.Status?.BSuccess ?? false))
{
return null;
}
byte[] responseBuffer = response.BtResponse?.ToByteArray() ?? [];
return HistorianRuntimeParameterProtocol.ParseSingleStringResult(responseBuffer);
}
}