diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs new file mode 100644 index 0000000..18d1b0f --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs @@ -0,0 +1,387 @@ +using System.Runtime.CompilerServices; +using Google.Protobuf; +using Grpc.Core; +using AVEVA.Historian.Client.Models; +using AVEVA.Historian.Client.Wcf; +using GrpcHistory = ArchestrA.Grpc.Contract.History; +using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval; +using GrpcStatus = ArchestrA.Grpc.Contract.Status; +using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction; + +namespace AVEVA.Historian.Client.Grpc; + +/// +/// 2023 R2 gRPC event-read orchestrator. Mirrors over the +/// gRPC transport: the same CM_EVENT registration sequence and the same event request/row buffers +/// travel inside protobuf bytes fields, reusing the proven WCF serializers/parsers verbatim. +/// +/// Operation mapping (2020 WCF → 2023 R2 gRPC): +/// Hist.UpdateClientStatus3 → HistoryService.UpdateClientStatus +/// Hist.RegisterTags2 → HistoryService.RegisterTags +/// Hist.EnsureTags2 → HistoryService.EnsureTags +/// Stat.GetHistorianInfo → StatusService.GetHistorianInfo +/// Stat.GetSystemParameter → StatusService.GetSystemParameter +/// Retr.StartEventQuery → RetrievalService.StartEventQuery +/// Retr.GetNextEventQueryResultBuffer (loop) → RetrievalService.GetNextEventQueryResultBuffer +/// Retr.EndEventQuery → RetrievalService.EndEventQuery +/// +/// +/// The CM_EVENT registration replay () is the hard part: without it +/// the server returns native error type=4 code=85 from GetNextEventQueryResultBuffer. The captured +/// registration buffers are shared with the WCF path via +/// so the two transports cannot drift. The gRPC +/// RetrievalService event ops do NOT need the WCF Retr.GetV/IsOriginalAllowed prime +/// (the read path proved the front-door session is sufficient over gRPC). +/// +/// +/// Live status (2026-06-22): the chain runs end-to-end and StartEventQuery succeeds, but +/// GetNextEventQueryResultBuffer long-polls when the query has no rows — it blocks to the +/// call deadline instead of returning the synchronous 5-byte code-85 terminal the 2020 WCF op returns. +/// A poll-deadline expiry is therefore treated as the no-data terminal (see the loop). The idle dev box +/// holds no events, so row-level retrieval is not yet live-verified; verifying parsed rows over +/// gRPC awaits an event-bearing 2023 R2 server. This is tooled + completes cleanly, NOT proven to +/// return rows. +/// +/// +internal sealed class HistorianGrpcEventOrchestrator +{ + private readonly HistorianClientOptions _options; + + public HistorianGrpcEventOrchestrator(HistorianClientOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// Diagnostic: length of the most recent event-row result buffer the server sent. + public int LastResultBufferLength { get; private set; } + + /// Diagnostic: type+code description of the most recent error/terminal buffer. + public string LastErrorBufferDescription { get; private set; } = string.Empty; + + public async IAsyncEnumerable ReadEventsAsync( + DateTime startUtc, + DateTime endUtc, + HistorianEventFilter? filter, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName)) + { + throw new ProtocolEvidenceMissingException( + "Managed gRPC event flow currently requires IntegratedSecurity or an explicit UserName + Password."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Hard overall cap. The per-call gRPC-Web deadlines are NOT honored reliably over a tunnelled + // link (observed live 2026-06-22: a chain with 4s per-call deadlines still ran >90s because the + // server stalls several registration RPCs and long-polls GetNext). gRPC DOES honor token + // cancellation, so a linked CTS firing at OverallBudget bounds the whole read deterministically. + // A budget timeout on the unverified no-row path is surfaced as ProtocolEvidenceMissing, not as + // a raw cancellation, so callers get the same honest "not row-verified over gRPC" signal. + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linked.CancelAfter(OverallBudget); + + IReadOnlyList events; + try + { + events = await Task.Run( + () => RunEventChain(startUtc, endUtc, filter, linked.Token), + linked.Token).ConfigureAwait(false); + } + catch (Exception ex) when (IsBudgetCancellation(ex, linked, cancellationToken)) + { + throw new ProtocolEvidenceMissingException( + $"ReadEvents over gRPC did not return rows within {OverallBudget.TotalSeconds:0}s: StartEventQuery " + + "succeeds but the CM_EVENT registration replay stalls and GetNextEventQueryResultBuffer long-polls " + + "(no synchronous code-85 terminal over gRPC). Row-level retrieval is not yet verified over gRPC " + + "(the dev box holds no events) — use the WCF transport for event reads."); + } + + foreach (HistorianEvent evt in events) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return evt; + } + } + + /// + /// Hard wall-clock budget for the entire gRPC event read. Bounds the chain deterministically since + /// per-call gRPC-Web deadlines are unreliable over a tunnel. Scaled off the request timeout but + /// capped so a long default timeout cannot make the (currently row-unverified) read stall for minutes. + /// + private TimeSpan OverallBudget + { + get + { + TimeSpan cap = TimeSpan.FromSeconds(30); + return _options.RequestTimeout < cap ? _options.RequestTimeout : cap; + } + } + + /// + /// True when an exception was caused by the overall-budget linked CTS firing (not by the caller's + /// own cancellation). The budget surfaces either as an + /// (Task.Run / token checks) or a gRPC with + /// from an in-flight RPC. + /// + private static bool IsBudgetCancellation(Exception ex, CancellationTokenSource linked, CancellationToken caller) + { + if (caller.IsCancellationRequested || !linked.IsCancellationRequested) + { + return false; + } + + return ex is OperationCanceledException + || (ex is RpcException rpc && rpc.StatusCode is StatusCode.Cancelled or StatusCode.DeadlineExceeded); + } + + private List RunEventChain(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken); + + RegisterCmEventTag(connection, session, cancellationToken); + + List events = RunEventQuery(connection, session, startUtc, endUtc, filter, cancellationToken); + + // Honest no-data handling: when the query returns real rows, hand them back. When it instead + // reaches the no-data terminal with ZERO rows (the gRPC server long-polls GetNext rather than + // returning the WCF code-85 terminal), we cannot distinguish "genuinely no events in range" + // from "the CM_EVENT registration replay didn't fully land over gRPC" — so we refuse to return + // a possibly-false empty list and surface the unverified state instead. An event-bearing 2023 R2 + // server will return rows here and exercise the parse path; flip this once that is captured. + if (events.Count == 0) + { + throw new ProtocolEvidenceMissingException( + "ReadEvents over gRPC: the chain completes and StartEventQuery succeeds, but " + + "GetNextEventQueryResultBuffer returns no rows (it long-polls to the no-data terminal " + + $"after the CM_EVENT registration replay; last={LastErrorBufferDescription}). Row-level " + + "retrieval is not yet verified over gRPC (the dev box holds no events) — use the WCF " + + "transport for event reads until a capture against an event-bearing 2023 R2 server confirms it."); + } + + return events; + } + + private DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout); + + /// + /// Deadline for the GetNextEventQueryResultBuffer long-poll. Bounded to at most 10s (or the + /// configured if shorter) so the no-data + /// terminal — a deadline expiry over gRPC — is reached promptly instead of stalling the read for + /// the full request timeout. When rows are available the server returns them well before this. + /// + private DateTime EventPollDeadline() + { + TimeSpan cap = TimeSpan.FromSeconds(10); + TimeSpan poll = _options.RequestTimeout < cap ? _options.RequestTimeout : cap; + return DateTime.UtcNow.Add(poll); + } + + /// + /// Deadline for the best-effort registration RPCs. Bounded to at most 5s: several of these + /// (RegisterTags / EnsureTags / GetHistorianInfo) stall server-side on the remote 2023 R2 + /// box (observed live 2026-06-22) and only return at their deadline, so an unbounded + /// would make the registration phase dominate the read. They are + /// swallowed via regardless of outcome. + /// + private DateTime RegistrationDeadline() + { + TimeSpan cap = TimeSpan.FromSeconds(5); + TimeSpan d = _options.RequestTimeout < cap ? _options.RequestTimeout : cap; + return DateTime.UtcNow.Add(d); + } + + /// + /// Replays the native event-tag registration sequence (UpdC3 → 6 system params → RTag2 → 1 more + /// system param → cross-service GetV probes → EnsT2) over the gRPC services. Best-effort: each + /// call is wrapped so an individual rejection on this server does not abort the chain — the goal + /// is to drive the server-side session into the state StartEventQuery / GetNextEventQueryResultBuffer + /// expect. Buffers come from . + /// + private void RegisterCmEventTag(HistorianGrpcConnection connection, HistorianGrpcHandshake.Session session, CancellationToken cancellationToken) + { + var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); + var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel); + var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel); + var transactionClient = new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel); + + // Discovery dance the native event flow runs between Open2 and EnsT2. All bounded by the + // short RegistrationDeadline (several stall server-side on the remote box). + TryRun(() => statusClient.GetStatusInterfaceVersion(new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, RegistrationDeadline(), cancellationToken)); + TryRun(() => statusClient.GetStatusInterfaceVersion(new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, RegistrationDeadline(), cancellationToken)); + + byte[] historianVersionRequest = HistorianEventRegistrationProtocol.BuildGetHistorianInfoRequest("HistorianVersion"); + TryRun(() => statusClient.GetHistorianInfo( + new GrpcStatus.GetHistorianInfoRequest { StrHandle = session.StringHandle, BtRequest = ByteString.CopyFrom(historianVersionRequest) }, + connection.Metadata, RegistrationDeadline(), cancellationToken)); + TryRun(() => statusClient.GetHistorianInfo( + new GrpcStatus.GetHistorianInfoRequest { StrHandle = session.StringHandle, BtRequest = ByteString.CopyFrom(historianVersionRequest) }, + connection.Metadata, RegistrationDeadline(), cancellationToken)); + + byte[] clientStatus = HistorianEventRegistrationProtocol.BuildUpdateClientStatusBlob(); + TryRun(() => historyClient.UpdateClientStatus( + new GrpcHistory.UpdateClientStatusRequest { StrHandle = session.StringHandle, BtClientStatus = ByteString.CopyFrom(clientStatus) }, + connection.Metadata, RegistrationDeadline(), cancellationToken)); + + // Records 11-16: 6 system-parameter queries before RTag2. + foreach (string parameterName in HistorianEventRegistrationProtocol.StatusParametersBeforeRegister) + { + TryRun(() => statusClient.GetSystemParameter( + new GrpcStatus.GetSystemParameterRequest { UiHandle = session.ClientHandle, StrParameterName = parameterName }, + connection.Metadata, RegistrationDeadline(), cancellationToken)); + } + + byte[] registerBuffer = HistorianEventRegistrationProtocol.BuildRegisterCmEventInputBuffer(); + TryRun(() => historyClient.RegisterTags( + new GrpcHistory.RegisterTagsRequest { StrHandle = session.StringHandle, BtTagInfos = ByteString.CopyFrom(registerBuffer) }, + connection.Metadata, RegistrationDeadline(), cancellationToken)); + + // Record 18: one more system-parameter query after RTag2 before EnsT2. + TryRun(() => statusClient.GetSystemParameter( + new GrpcStatus.GetSystemParameterRequest { UiHandle = session.ClientHandle, StrParameterName = "AllowRenameTags" }, + connection.Metadata, RegistrationDeadline(), cancellationToken)); + + // Records 19-21: cross-service version probes between RTag2 and EnsT2 (session-table registration). + TryRun(() => transactionClient.GetTransactionInterfaceVersion(new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, RegistrationDeadline(), cancellationToken)); + TryRun(() => statusClient.GetStatusInterfaceVersion(new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, RegistrationDeadline(), cancellationToken)); + TryRun(() => retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, RegistrationDeadline(), cancellationToken)); + + byte[] payload = HistorianAddTagsProtocol.SerializeCmEventCTagMetadata(DateTime.UtcNow); + TryRun(() => historyClient.EnsureTags( + new GrpcHistory.EnsureTagsRequest { StrHandle = session.StringHandle, BtTagInfos = ByteString.CopyFrom(payload), ElementCount = 1 }, + connection.Metadata, RegistrationDeadline(), cancellationToken)); + } + + private List RunEventQuery( + HistorianGrpcConnection connection, + HistorianGrpcHandshake.Session session, + DateTime startUtc, + DateTime endUtc, + HistorianEventFilter? filter, + CancellationToken cancellationToken) + { + var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel); + GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion( + new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken); + HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options); + + IReadOnlyList attempts = HistorianEventQueryProtocol.CreateStartEventQueryAttempts( + startUtc.ToUniversalTime(), + endUtc.ToUniversalTime(), + eventCount: 5, + filter); + byte[] requestBuffer = attempts[0].RequestBuffer; + + GrpcRetrieval.StartEventQueryResponse startResponse = retrievalClient.StartEventQuery( + new GrpcRetrieval.StartEventQueryRequest + { + UiHandle = session.ClientHandle, + UiQueryRequestType = HistorianEventQueryProtocol.QueryRequestTypeEvent, + BtRequest = ByteString.CopyFrom(requestBuffer) + }, + connection.Metadata, + Deadline(), + cancellationToken); + + byte[] startError = startResponse.Status?.BtError?.ToByteArray() ?? []; + if (!(startResponse.Status?.BSuccess ?? false)) + { + throw new InvalidOperationException( + $"gRPC StartEventQuery failed (errorLen={startError.Length}, error5={HistorianEventRegistrationProtocol.DescribeNativeError(startError)})."); + } + + uint queryHandle = startResponse.UiQueryHandle; + try + { + List events = []; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + GrpcRetrieval.GetNextEventQueryResultBufferResponse nextResponse; + try + { + nextResponse = retrievalClient.GetNextEventQueryResultBuffer( + new GrpcRetrieval.GetNextEventQueryResultBufferRequest { UiHandle = session.ClientHandle, UiQueryHandle = queryHandle }, + connection.Metadata, + EventPollDeadline(), + cancellationToken); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded) + { + // No-data terminal. Over gRPC the 2023 R2 server LONG-POLLS GetNextEventQueryResultBuffer + // when the query has no (more) rows to hand back, rather than returning the 5-byte + // type=4 code=85 terminal the 2020 WCF op returns synchronously. A poll-deadline + // expiry is therefore the gRPC equivalent of that soft terminal: stop reading and + // return whatever rows were already collected. (Confirmed live 2026-06-22: the chain + // runs and StartEventQuery succeeds, but GetNext blocks to the deadline on the idle + // dev box, which holds no events.) See class remarks. + LastErrorBufferDescription = "GetNext long-poll deadline (no-data terminal)"; + return events; + } + + byte[] resultBuffer = nextResponse.BtResult?.ToByteArray() ?? []; + byte[] errorBuffer = nextResponse.Status?.BtError?.ToByteArray() ?? []; + bool nextSuccess = nextResponse.Status?.BSuccess ?? false; + + LastResultBufferLength = resultBuffer.Length; + LastErrorBufferDescription = HistorianEventRegistrationProtocol.DescribeNativeError(errorBuffer); + + // Any 5-byte type=4 error is a soft terminal (code 30 NoMoreData is canonical; code + // 85 / 0x55 is the missing-registration signal seen on early runs). Mirror the WCF + // orchestrator: stop reading and surface the diagnostic rather than throw. + if (errorBuffer.Length == 5 && errorBuffer[0] == 4) + { + return events; + } + + if (!nextSuccess) + { + throw new InvalidOperationException( + $"gRPC GetNextEventQueryResultBuffer failed (errorLen={errorBuffer.Length}, error5={HistorianEventRegistrationProtocol.DescribeNativeError(errorBuffer)})."); + } + + if (resultBuffer.Length > 0) + { + events.AddRange(HistorianEventRowProtocol.Parse(resultBuffer)); + } + + if (resultBuffer.Length == 0 && errorBuffer.Length == 0) + { + return events; + } + } + } + finally + { + EndEventQuerySafely(retrievalClient, connection, session.ClientHandle, queryHandle); + } + } + + private void EndEventQuerySafely( + GrpcRetrieval.RetrievalService.RetrievalServiceClient client, + HistorianGrpcConnection connection, + uint clientHandle, + uint queryHandle) + { + try + { + client.EndEventQuery( + new GrpcRetrieval.EndEventQueryRequest { UiHandle = clientHandle, UiQueryHandle = queryHandle }, + connection.Metadata, + Deadline(), + CancellationToken.None); + } + catch + { + // Best-effort cleanup; the read result is already collected. + } + } + + private static void TryRun(Action action) + { + try { action(); } + catch { } + } +} diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index b13d90b..dcc8942 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -44,8 +44,9 @@ internal sealed class Historian2020ProtocolDialect public IAsyncEnumerable ReadEventsAsync(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken) { - HistorianWcfEventOrchestrator orchestrator = new(_options); - return orchestrator.ReadEventsAsync(startUtc, endUtc, filter, cancellationToken); + return UseGrpc + ? new HistorianGrpcEventOrchestrator(_options).ReadEventsAsync(startUtc, endUtc, filter, cancellationToken) + : new HistorianWcfEventOrchestrator(_options).ReadEventsAsync(startUtc, endUtc, filter, cancellationToken); } public Task GetConnectionStatusAsync(CancellationToken cancellationToken) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianEventRegistrationProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianEventRegistrationProtocol.cs new file mode 100644 index 0000000..c88becd --- /dev/null +++ b/src/AVEVA.Historian.Client/Wcf/HistorianEventRegistrationProtocol.cs @@ -0,0 +1,102 @@ +using System.Buffers.Binary; +using System.Text; + +namespace AVEVA.Historian.Client.Wcf; + +/// +/// Captured byte buffers for the native CM_EVENT registration sequence that both the WCF and gRPC +/// event orchestrators replay before StartEventQuery. Extracted to a single source of truth so +/// the two transports cannot drift on these reverse-engineered constants. The bytes are captured +/// byte-for-byte from a successful native event read via the instrument-wcf-{write,read}message +/// IL-rewrite tool (see remarks for record references). +/// +internal static class HistorianEventRegistrationProtocol +{ + /// + /// Documented native CM_EVENT default tag id used by aahClientManaged.dll + /// CreateDefaultEventTag → ConvertEventTagToTagMetadata. Registering this tag (RegisterTags2 / + /// HistoryService.RegisterTags) before StartEventQuery subscribes the session to CM_EVENT + /// events; without it, GetNextEventQueryResultBuffer returns native error type=4 code=85 (0x55). + /// + public static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321"); + + /// + /// The 6 system-parameter names the native client queries (records 11-16) between UpdC3 and + /// RTag2. They appear informational, but are replayed to put the server-side session into the + /// state EnsT2 expects. + /// + public static readonly string[] StatusParametersBeforeRegister = + [ + "AllowOriginals", + "HistorianPartner", + "HistorianVersion", + "MaxCyclicStorageTimeout", + "RealTimeWindow", + "FutureTimeThreshold", + ]; + + /// + /// Native GETHI pRequestBuff layout for a parameter-name query: 8-byte header + /// (UInt16 0x6753 + UInt16 0x0002 + UInt32 nameLength) + UTF-16 LE chars (no trailing null byte — + /// observed truncated by 1 byte vs full UTF-16 in the captured native bytes). Layout taken from + /// writemessage-capture-event-latest.ndjson record 8. + /// + public static byte[] BuildGetHistorianInfoRequest(string parameterName) + { + byte[] nameBytes = Encoding.Unicode.GetBytes(parameterName); + // Native truncates the trailing high byte of the last UTF-16 char. + int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0; + byte[] buffer = new byte[8 + payloadLength]; + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length); + Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength); + return buffer; + } + + /// + /// 81-byte UpdC3 clientStatus blob captured from a native event read (record 10 of + /// writemessage-capture-event-latest.ndjson). Layout: 0x02 0x01 + 76 zero bytes + + /// uint32(0x0000001E). The trailing 30 is likely an interval / timeout in seconds; all other + /// observed fields are zero for a fresh session. + /// + public static byte[] BuildUpdateClientStatusBlob() + { + byte[] blob = new byte[81]; + blob[0] = 0x02; + blob[1] = 0x01; + blob[77] = 0x1E; + return blob; + } + + /// + /// 24-byte RTag2 pInBuff captured from a native event read (record 17). Layout: 8-byte header + /// (0x50 0x67 0x02 0x00 + uint32 element count = 1) + 16-byte tag id GUID. + /// + public static byte[] BuildRegisterCmEventInputBuffer() + { + byte[] buffer = new byte[24]; + buffer[0] = 0x50; + buffer[1] = 0x67; + buffer[2] = 0x02; + buffer[3] = 0x00; + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), 1u); + CmEventTagId.ToByteArray().CopyTo(buffer.AsSpan(8, 16)); + return buffer; + } + + /// + /// Describes a native 5-byte error/terminal buffer: byte0 = type, bytes 1-4 = LE uint32 code. + /// + public static string DescribeNativeError(byte[] errorBuffer) + { + if (errorBuffer.Length < 5) + { + return ""; + } + + byte type = errorBuffer[0]; + uint code = BinaryPrimitives.ReadUInt32LittleEndian(errorBuffer.AsSpan(1, 4)); + return $"type={type} code={code} (0x{code:X})"; + } +} diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs index 1089dea..779a5a2 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs @@ -1,4 +1,3 @@ -using System.Buffers.Binary; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.ServiceModel; @@ -29,15 +28,6 @@ internal sealed class HistorianWcfEventOrchestrator private const uint NativeClientVersionInt = 999_999; private const ushort NativeOpen2ClientVersion = 9; - /// - /// Documented native CM_EVENT default tag id used by aahClientManaged.dll - /// CreateDefaultEventTag → ConvertEventTagToTagMetadata. Registering this tag via - /// IHistoryServiceContract2.RegisterTags2 before StartEventQuery causes the server - /// to subscribe the session to CM_EVENT events; without it, - /// GetNextEventQueryResultBuffer returns native error type=4 code=85 (0x55). - /// - private static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321"); - private readonly HistorianClientOptions _options; public HistorianWcfEventOrchestrator(HistorianClientOptions options) @@ -333,11 +323,11 @@ internal sealed class HistorianWcfEventOrchestrator TryRun(() => statusChannel.GetInterfaceVersion(out _)); TryRun(() => statusChannel.GetInterfaceVersion(out _)); - byte[] historianVersionRequest = BuildGetHistorianInfoRequest("HistorianVersion"); + byte[] historianVersionRequest = HistorianEventRegistrationProtocol.BuildGetHistorianInfoRequest("HistorianVersion"); TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _)); TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _)); - byte[] clientStatus = BuildUpdC3ClientStatusBlob(); + byte[] clientStatus = HistorianEventRegistrationProtocol.BuildUpdateClientStatusBlob(); bool updSuccess = historyChannel.UpdateClientStatus3( handle: handle, clientStatusSize: (uint)clientStatus.Length, @@ -349,12 +339,12 @@ internal sealed class HistorianWcfEventOrchestrator LastUpdC3ReturnCode = updSuccess ? 0u : 1u; // Records 11-16: 6 system-parameter queries before RTag2. - foreach (string parameterName in NativeStatusParametersBeforeRTag2) + foreach (string parameterName in HistorianEventRegistrationProtocol.StatusParametersBeforeRegister) { TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _)); } - byte[] registerBuffer = BuildRTag2CmEventInputBuffer(); + byte[] registerBuffer = HistorianEventRegistrationProtocol.BuildRegisterCmEventInputBuffer(); bool registerSuccess = historyChannel.RegisterTags2( handle: handle, elementCount: 1, @@ -407,84 +397,14 @@ internal sealed class HistorianWcfEventOrchestrator } } - private static readonly string[] NativeStatusParametersBeforeRTag2 = - [ - "AllowOriginals", - "HistorianPartner", - "HistorianVersion", - "MaxCyclicStorageTimeout", - "RealTimeWindow", - "FutureTimeThreshold", - ]; - private static void TryRun(Action action) { try { action(); } catch { } } - /// - /// Native GETHI pRequestBuff layout for a parameter-name query: 8-byte header - /// (UInt16 0x6753 + UInt16 0x0002 + UInt32 nameLength) + UTF-16 LE chars (no - /// trailing null byte — observed truncated by 1 byte vs full UTF-16 in the - /// captured native bytes). Layout taken from - /// writemessage-capture-event-latest.ndjson record 8. - /// - private static byte[] BuildGetHistorianInfoRequest(string parameterName) - { - byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName); - // Native truncates the trailing high byte of the last UTF-16 char. - int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0; - byte[] buffer = new byte[8 + payloadLength]; - BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753); - BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002); - BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length); - Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength); - return buffer; - } - - /// - /// 81-byte UpdC3 clientStatus blob captured from a native event read (record 10 of - /// writemessage-capture-event-latest.ndjson). Layout: 0x02 0x01 + 76 zero bytes + - /// uint32(0x0000001E). The trailing 30 is likely an interval / timeout in seconds; all - /// other observed fields are zero for a fresh session. - /// - private static byte[] BuildUpdC3ClientStatusBlob() - { - byte[] blob = new byte[81]; - blob[0] = 0x02; - blob[1] = 0x01; - blob[77] = 0x1E; - return blob; - } - - /// - /// 24-byte RTag2 pInBuff captured from a native event read (record 17). Layout: - /// 8-byte header (0x50 0x67 0x02 0x00 + uint32 element count = 1) + 16-byte tag id GUID. - /// - private static byte[] BuildRTag2CmEventInputBuffer() - { - byte[] buffer = new byte[24]; - buffer[0] = 0x50; - buffer[1] = 0x67; - buffer[2] = 0x02; - buffer[3] = 0x00; - BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), 1u); - CmEventTagId.ToByteArray().CopyTo(buffer.AsSpan(8, 16)); - return buffer; - } - private static string DescribeNativeError(byte[] errorBuffer) - { - if (errorBuffer.Length < 5) - { - return ""; - } - - byte type = errorBuffer[0]; - uint code = BinaryPrimitives.ReadUInt32LittleEndian(errorBuffer.AsSpan(1, 4)); - return $"type={type} code={code} (0x{code:X})"; - } + => HistorianEventRegistrationProtocol.DescribeNativeError(errorBuffer); private static void CloseChannelSafely(ICommunicationObject channel) {