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