diff --git a/docs/reverse-engineering/handoff.md b/docs/reverse-engineering/handoff.md
index d619bcd..812a14f 100644
--- a/docs/reverse-engineering/handoff.md
+++ b/docs/reverse-engineering/handoff.md
@@ -76,7 +76,17 @@ reuses the proven 2020 WCF byte serializers/parsers unchanged inside protobuf
`capture-event` harness (native, returns rows).
2. **R4.3 active-SF magnitude** — needs an **SF-active server** (D2 storage-engine
console handle).
-3. **SendEvent over gRPC** — **capture-gated**: no distinct RPC, framing uncaptured.
+3. **SendEvent over gRPC** — ✅ **SHIPPED + LIVE-VALIDATED 2026-06-23.** `SendEventAsync`
+ now routes over `RemoteGrpc` (`HistorianGrpcEventWriteOrchestrator`). Captured the native
+ client live (`capture-send-event` harness scenario): the send rides
+ `HistoryService.AddStreamValues` with the **same "OS" (0x534F) buffer the WCF path uses**
+ (`HistorianEventWriteProtocol` — "no distinct RPC" confirmed true), on a v8 Event session +
+ CM_EVENT registration. The write-enabled Event open is **byte-identical** to the read-only one
+ (diffed live — only per-session crypto differs), so the existing event-open path is reused
+ unchanged. End-to-end: pure-managed SDK send → `BSuccess=true` → event read back from the live
+ server (markers `SdkSendProbe`/`SdkCaptureProbe` confirmed in returned rows). Golden-tested
+ (`GrpcEventSendProtocolTests`) + gated live test (`SendEventAsync_OverGrpc_AcceptsEvent`,
+ opt-in `HISTORIAN_GRPC_EVENT_SEND=1`).
4. **ExecuteSqlCommand over gRPC** — **server-walled** (`CSrvDbConnection`;
RegisterTags prime doesn't help). Use WCF for SQL.
5. **R4.2 revision EDITS** — storage-engine-pipe-only on BOTH transports (the D2 wall).
@@ -123,18 +133,17 @@ with these refinements:
would differ is native and not on the wire. One untested low-effort check
remains: byte-diff a captured **Event-connection** EnsureTags/RegisterTags
against our replay (the 83-vs-86-byte EnsT gap was never actually compared).
-- **Item 3 (SendEvent over gRPC)** — **sharpened from "maybe no RPC" to a precise
- capture.** RPC **confirmed** = `HistoryService.AddStreamValues` (the "no distinct
- RPC" note is TRUE; an event rides the same RPC as a streamed sample, discriminated
- inside `btValues`). Public API `HistorianAccess.AddStreamedValue(HistorianEvent)`
- → native `AddHistorianValue`; prereqs known (write-enabled Event conn, CM_EVENT
- tag handle, quality 192); field set/order recovered from `HistorianEvent.PackToVtq`.
- **Only the `btValues` VTQ byte layout is missing** — built by native
- `CCommonArchestraEventValue::PackToVtq` and copied out as an opaque `CDataChunk`.
- Our read parser already decodes the inverse property-bag format. **Capturable
- against the local Historian** (instrument `PackToVtq` output / the `AddStreamValues`
- body) → then build `HistorianEventWriteProtocol` and reuse the
- `HistorianGrpcHistoricalWriteOrchestrator` plumbing.
+- **Item 3 (SendEvent over gRPC)** — ✅ **SHIPPED + LIVE-VALIDATED 2026-06-23** (was
+ "capturable"). RPC confirmed = `HistoryService.AddStreamValues` (the "no distinct RPC"
+ note is TRUE). The `btValues` VTQ buffer turned out to be already-owned: our M2
+ `HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer` ("OS" buffer, decoded from
+ the WCF event-send) is the transport-independent `PackToVtq` equivalent and the gRPC send
+ uses it **verbatim** (live capture: sig `OS`/0x534F, CM_EVENT GUID, identical framing — NOT
+ the historical write's "ON" buffer). The write-enabled Event open is byte-identical to the
+ read-only one (live diff). So SendEvent-over-gRPC was pure assembly:
+ `HistorianGrpcEventWriteOrchestrator` = existing v8 Event open + existing CM_EVENT
+ registration + `AddStreamValues`(OS buffer). End-to-end live-validated (send → `BSuccess`
+ → read back from the live server). Golden-tested + gated live test.
- **Item 4 (ExecuteSql over gRPC)** — **confirmed walled + explained.** The stock
client gates SQL **out client-side**: `HistorianAccess.ExecuteSqlCommand` returns
`OperationNotSupported` when `IsManagedHistorian(node)` or `!IsProcessConnectionRequested()`
diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventWriteOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventWriteOrchestrator.cs
new file mode 100644
index 0000000..6971e60
--- /dev/null
+++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventWriteOrchestrator.cs
@@ -0,0 +1,136 @@
+using Google.Protobuf;
+using AVEVA.Historian.Client.Models;
+using AVEVA.Historian.Client.Wcf;
+using GrpcHistory = ArchestrA.Grpc.Contract.History;
+using GrpcStatus = ArchestrA.Grpc.Contract.Status;
+
+namespace AVEVA.Historian.Client.Grpc;
+
+///
+/// 2023 R2 gRPC orchestrator for the event SEND ().
+/// Captured live from the native 2023 R2 client (capture-send-event scenario,
+/// 2026-06-23): an event send rides HistoryService.AddStreamValues with the SAME
+/// "OS" (0x534F) storage-sample buffer the WCF AddS2 path uses
+/// () — NOT a distinct event RPC and NOT the historical
+/// write's "ON" buffer. The native client's write-enabled Event OpenConnection
+/// request is byte-identical to the read-only event open (the ReadOnly arg does not change the v8
+/// open buffer; diffed live — only the per-session client key + credential token differ), so the
+/// existing event path is reused unchanged. The
+/// chain on a single Event session:
+///
+/// - OpenConnection (v8 Event, ExchangeKey ECDH auth) → string storage handle
+/// - CM_EVENT registration: UpdateClientStatus → RegisterTags → EnsureTags (the same
+/// buffers the gRPC event READ replays — verified byte-identical to the capture)
+/// - HistoryService.AddStreamValues(strHandle, "OS" event buffer)
+///
+/// Only original events ( = 0) with string-valued
+/// properties have a captured encoding; others throw
+/// from .
+///
+internal sealed class HistorianGrpcEventWriteOrchestrator
+{
+ private readonly HistorianClientOptions _options;
+
+ public HistorianGrpcEventWriteOrchestrator(HistorianClientOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ /// Diagnostic: type+code description of the most recent AddStreamValues error buffer.
+ public string LastSendErrorDescription { get; private set; } = string.Empty;
+
+ /// Diagnostic: outcomes of the CM_EVENT registration RPCs.
+ public string RegistrationDiag { get; private set; } = string.Empty;
+
+ public Task SendEventAsync(HistorianEvent evt, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
+ {
+ throw new ProtocolEvidenceMissingException(
+ "Managed gRPC event send currently requires IntegratedSecurity or an explicit UserName + Password.");
+ }
+
+ if (evt.RevisionVersion != 0)
+ {
+ throw new ProtocolEvidenceMissingException(
+ "Only original events (RevisionVersion = 0) have a captured send encoding; " +
+ "revision/update/delete event sends are not yet supported.");
+ }
+
+ return Task.Run(() => Run(evt, cancellationToken), cancellationToken);
+ }
+
+ private bool Run(HistorianEvent evt, CancellationToken cancellationToken)
+ {
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
+
+ // The event SEND uses the same v8 Event connection as the event READ. The write-enabled
+ // open buffer is byte-identical to the read-only one (verified live), so OpenSession's
+ // event path is reused unchanged.
+ HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
+ connection, _options, cancellationToken, eventConnection: true);
+
+ RegisterCmEventTag(connection, session, cancellationToken);
+
+ var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
+ byte[] pBuf = HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer(evt, DateTime.UtcNow);
+
+ GrpcHistory.AddStreamValuesResponse response = historyClient.AddStreamValues(
+ new GrpcHistory.AddStreamValuesRequest
+ {
+ StrHandle = session.StringHandle,
+ BtValues = ByteString.CopyFrom(pBuf),
+ },
+ connection.Metadata,
+ DateTime.UtcNow.Add(_options.RequestTimeout),
+ cancellationToken);
+
+ byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
+ LastSendErrorDescription = HistorianEventRegistrationProtocol.DescribeNativeError(error);
+ return response.Status?.BSuccess ?? false;
+ }
+
+ ///
+ /// Replays the CM_EVENT registration the native event connection performs before a send:
+ /// UpdateClientStatus → RegisterTags(CM_EVENT) → EnsureTags(CM_EVENT). The buffers are shared
+ /// with the gRPC event READ path ( +
+ /// ) and were verified
+ /// byte-identical to the live capture. Best-effort: an individual rejection does not abort the
+ /// send (the server may already hold CM_EVENT registered for the session).
+ ///
+ private void RegisterCmEventTag(HistorianGrpcConnection connection, HistorianGrpcHandshake.Session session, CancellationToken cancellationToken)
+ {
+ var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
+ DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
+
+ byte[] clientStatus = HistorianEventRegistrationProtocol.BuildUpdateClientStatusBlob();
+ try
+ {
+ historyClient.UpdateClientStatus(
+ new GrpcHistory.UpdateClientStatusRequest { StrHandle = session.StringHandle, BtClientStatus = ByteString.CopyFrom(clientStatus) },
+ connection.Metadata, Deadline(), cancellationToken);
+ }
+ catch { /* best-effort */ }
+
+ byte[] registerBuffer = HistorianEventRegistrationProtocol.BuildRegisterCmEventInputBuffer();
+ try
+ {
+ GrpcHistory.RegisterTagsResponse rt = historyClient.RegisterTags(
+ new GrpcHistory.RegisterTagsRequest { StrHandle = session.StringHandle, BtTagInfos = ByteString.CopyFrom(registerBuffer) },
+ connection.Metadata, Deadline(), cancellationToken);
+ RegistrationDiag += $"RTag={rt.Status?.BSuccess}; ";
+ }
+ catch (Exception ex) { RegistrationDiag += $"RTag=EX:{ex.GetType().Name}; "; }
+
+ byte[] payload = HistorianAddTagsProtocol.SerializeCmEventEnsureTagsGrpc(DateTime.UtcNow);
+ try
+ {
+ GrpcHistory.EnsureTagsResponse et = historyClient.EnsureTags(
+ new GrpcHistory.EnsureTagsRequest { StrHandle = session.StringHandle, BtTagInfos = ByteString.CopyFrom(payload), ElementCount = 1 },
+ connection.Metadata, Deadline(), cancellationToken);
+ RegistrationDiag += $"EnsT={et.Status?.BSuccess}; ";
+ }
+ catch (Exception ex) { RegistrationDiag += $"EnsT=EX:{ex.GetType().Name}; "; }
+ }
+}
diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs
index d2974bb..73c9b26 100644
--- a/src/AVEVA.Historian.Client/HistorianClient.cs
+++ b/src/AVEVA.Historian.Client/HistorianClient.cs
@@ -114,18 +114,24 @@ public sealed class HistorianClient : IAsyncDisposable
}
///
- /// Sends a single to the Historian's built-in CM_EVENT tag
- /// over the WCF event pipeline (Open2 event mode → CM_EVENT registration → AddS2). The
- /// event is appended to the historian's event history and is readable back via
- /// / the v_AlarmEventHistory2 view. Only original
- /// events ( = 0) with string-valued properties
- /// are supported; other property value types and revision/update/delete events throw
+ /// Sends a single to the Historian's built-in CM_EVENT tag.
+ /// Over WCF this runs Open2 event mode → CM_EVENT registration → AddS2; over the 2023 R2
+ /// transport it runs the captured-equivalent
+ /// v8 Event OpenConnection → CM_EVENT registration → HistoryService.AddStreamValues
+ /// with the same "OS" event buffer (live-captured 2026-06-23 — the send rides the same RPC
+ /// and buffer as the WCF path, not a distinct event RPC). The event is appended to the
+ /// historian's event history and is readable back via /
+ /// the v_AlarmEventHistory2 view. Only original events
+ /// ( = 0) with string-valued properties are
+ /// supported; other property value types and revision/update/delete events throw
/// until their wire encoding is captured.
///
public Task SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(historianEvent);
- return new HistorianWcfEventOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken);
+ return _options.Transport == HistorianTransport.RemoteGrpc
+ ? new Grpc.HistorianGrpcEventWriteOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken)
+ : new HistorianWcfEventOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken);
}
///
diff --git a/tests/AVEVA.Historian.Client.Tests/GrpcEventSendProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/GrpcEventSendProtocolTests.cs
new file mode 100644
index 0000000..ac85a95
--- /dev/null
+++ b/tests/AVEVA.Historian.Client.Tests/GrpcEventSendProtocolTests.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Buffers.Binary;
+using AVEVA.Historian.Client.Wcf;
+using Xunit;
+
+namespace AVEVA.Historian.Client.Tests;
+
+///
+/// Golden-byte coverage for the 2023 R2 gRPC event-SEND registration buffers, pinned against a live
+/// native capture (capture-send-event scenario, 2026-06-23). The send itself rides
+/// HistoryService.AddStreamValues with the same "OS" buffer the WCF path uses
+/// (, already golden-tested in
+/// WcfEventWriteProtocolTests); what is gRPC-specific is the CM_EVENT registration the event
+/// connection performs first (RegisterTags + the 86-byte gRPC EnsureTags). These fixtures are the raw
+/// bytes the native client sent on the wire — they carry no identity (CM_EVENT / "AnE Event" /
+/// constant tag + event-type GUIDs / a registration FILETIME).
+///
+public class GrpcEventSendProtocolTests
+{
+ // GrpcHistoryClient.RegisterTags.tagInfos captured from the native event connection: the packet
+ // header 50 67 02 00 + count(1) + the 16-byte CM_EVENT tag GUID.
+ private const string CapturedRegisterTagsHex =
+ "506702000100000045813b35f05d464da253871aef49b321";
+
+ // GrpcHistoryClient.EnsureTags.tagInfos captured from the native event connection (86 bytes): the
+ // 8-byte EnsureTags header + CM_EVENT CTagMetadata + a registration FILETIME + the …e01f2f27
+ // event-type GUID.
+ private const string CapturedEnsureTagsHex =
+ "4e670300010000000386000545813b35f05d464da253871aef49b321090800434d5f4556454e54090900416e45204576656e7402020100000001000000004e18d6bd4503dd0142ae595fb63b604791a5ab0be01f2f27";
+
+ [Fact]
+ public void BuildRegisterCmEventInputBuffer_MatchesNativeGrpcCapture()
+ {
+ byte[] expected = Convert.FromHexString(CapturedRegisterTagsHex);
+ byte[] actual = HistorianEventRegistrationProtocol.BuildRegisterCmEventInputBuffer();
+ Assert.Equal(expected, actual);
+ }
+
+ [Fact]
+ public void SerializeCmEventEnsureTagsGrpc_MatchesNativeGrpcCapture()
+ {
+ byte[] expected = Convert.FromHexString(CapturedEnsureTagsHex);
+ Assert.Equal(86, expected.Length);
+
+ // The only run-varying field is the registration FILETIME (the 8 bytes immediately before the
+ // trailing 16-byte event-type GUID). Feed the captured time back so the comparison is exact.
+ long filetime = BinaryPrimitives.ReadInt64LittleEndian(expected.AsSpan(expected.Length - 24, 8));
+ DateTime createdUtc = DateTime.FromFileTimeUtc(filetime);
+
+ byte[] actual = HistorianAddTagsProtocol.SerializeCmEventEnsureTagsGrpc(createdUtc);
+ Assert.Equal(expected, actual);
+ }
+}
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
index 5eaf91e..ce47b55 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
@@ -233,6 +233,37 @@ public sealed class HistorianGrpcIntegrationTests
Assert.Contains(samples, s => s.NumericValue is { } v && Math.Abs(v - expected) < 0.01);
}
+ [Fact]
+ public async Task SendEventAsync_OverGrpc_AcceptsEvent()
+ {
+ string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
+ // Gated additionally on a dedicated opt-in so this WRITE test never runs by accident — it
+ // appends a clearly-marked test event to the server's event history. Captured 2026-06-23:
+ // the gRPC event send rides HistoryService.AddStreamValues with the same "OS" buffer the WCF
+ // path uses (HistorianEventWriteProtocol), on a v8 Event session + CM_EVENT registration.
+ if (string.IsNullOrWhiteSpace(host)
+ || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))
+ || Environment.GetEnvironmentVariable("HISTORIAN_GRPC_EVENT_SEND") is null)
+ {
+ return;
+ }
+
+ HistorianClient client = new(BuildOptions(host));
+
+ var evt = new HistorianEvent(
+ Id: Guid.NewGuid(),
+ EventTimeUtc: DateTime.UtcNow,
+ ReceivedTimeUtc: DateTime.UtcNow,
+ Type: "SdkSendProbe",
+ SourceName: "SdkSendProbe",
+ Namespace: "SdkCapture",
+ RevisionVersion: 0,
+ Properties: new Dictionary { ["SdkProbeProp"] = "SdkProbeValue" });
+
+ bool sent = await client.SendEventAsync(evt, CancellationToken.None);
+ Assert.True(sent, "gRPC SendEvent should be accepted by the server (AddStreamValues BSuccess).");
+ }
+
[Fact]
public async Task ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows()
{
diff --git a/tools/AVEVA.Historian.Grpc2023CaptureHarness/Program.cs b/tools/AVEVA.Historian.Grpc2023CaptureHarness/Program.cs
index 323c465..fbd6616 100644
--- a/tools/AVEVA.Historian.Grpc2023CaptureHarness/Program.cs
+++ b/tools/AVEVA.Historian.Grpc2023CaptureHarness/Program.cs
@@ -92,8 +92,10 @@ namespace AVEVA.Historian.Grpc2023CaptureHarness
return DeleteTag(managedDll, args);
case "capture-event":
return CaptureEvent(managedDll, args);
+ case "capture-send-event":
+ return CaptureSendEvent(managedDll, args);
default:
- Console.Error.WriteLine($"Unknown scenario '{scenario}'. Supported: load-check, connect, capture-write, delete-tag, capture-event.");
+ Console.Error.WriteLine($"Unknown scenario '{scenario}'. Supported: load-check, connect, capture-write, delete-tag, capture-event, capture-send-event.");
return 1;
}
}
@@ -603,6 +605,132 @@ namespace AVEVA.Historian.Grpc2023CaptureHarness
}
}
+ ///
+ /// Drives the native 2023 R2 client through an event SEND so the IL-rewritten GrpcClient dumps
+ /// the AddStreamValues.btValues (the event VTQ storage-sample buffer — resolves whether a gRPC
+ /// event send uses the "OS" or "ON" outer signature) AND the Event-connection EnsureTags.btTagInfos
+ /// (the 83-vs-86-byte CM_EVENT registration byte-diff). Opens a WRITE-ENABLED Event connection,
+ /// builds a clearly-marked test HistorianEvent, calls AddStreamedValue, then CloseConnection to
+ /// flush the queued event onto the wire. WRITES a real test event into the server's event history.
+ /// Run with --grpc-rewrite pointing at the instrumented copy and AVEVA_HISTORIAN_RE_CAPTURE set.
+ /// Usage: capture-send-event [--server ] [--port 32565] [--cert ]
+ /// [--event-type SdkCaptureProbe] [--flush-seconds 6]
+ ///
+ private static int CaptureSendEvent(string managedDll, string[] args)
+ {
+ Assembly asm = Assembly.LoadFrom(managedDll);
+ Type accessType = Req(asm, "ArchestrA.HistorianAccess");
+ Type connArgsType = Req(asm, "ArchestrA.HistorianConnectionArgs");
+ Type connModeType = Req(asm, "ArchestrA.HistorianConnectionMode");
+ Type connTypeType = Req(asm, "ArchestrA.HistorianConnectionType");
+ Type errorType = Req(asm, "ArchestrA.HistorianAccessError");
+ Type eventType = Req(asm, "ArchestrA.HistorianEvent");
+ Type propTypeEnum = Req(asm, "ArchestrA.HistorianEventPropertyType");
+ Type certInfoType = Req(asm, "ArchestrA.CertificateInfo");
+ Type secModeType = Req(asm, "ArchestrA.HistorianSecurityMode");
+
+ string server = GetOption(args, "--server") ?? Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? "localhost";
+ int port = int.TryParse(GetOption(args, "--port"), out int p) ? p : 32565;
+ string certName = GetOption(args, "--cert") ?? server;
+ string evtTypeName = GetOption(args, "--event-type") ?? "SdkCaptureProbe";
+ int flushSeconds = int.TryParse(GetOption(args, "--flush-seconds"), out int fs) ? fs : 6;
+ string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
+ string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
+ if (string.IsNullOrEmpty(user))
+ {
+ Console.Error.WriteLine("Set HISTORIAN_USER/HISTORIAN_PASSWORD.");
+ return 1;
+ }
+
+ if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE")))
+ {
+ string defaultCap = Path.GetFullPath(Path.Combine(
+ "artifacts", "reverse-engineering", "grpc-event-capture", "send-event-capture.ndjson"));
+ Environment.SetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE", defaultCap);
+ }
+ Console.WriteLine($"Capture sink: {Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE")}");
+
+ object connArgs = Activator.CreateInstance(connArgsType)!;
+ SetProp(connArgs, "ServerName", server);
+ SetProp(connArgs, "TcpPort", checked((ushort)port));
+ SetProp(connArgs, "ConnectionMode", Enum.Parse(connModeType, "Historian"));
+ SetProp(connArgs, "ConnectionType", Enum.Parse(connTypeType, "Event")); // EVENT connection
+ SetProp(connArgs, "ReadOnly", false); // WRITE-enabled
+ SetProp(connArgs, "IntegratedSecurity", false);
+ SetProp(connArgs, "AllowUnTrustedConnection", true);
+ SetProp(connArgs, "UserName", user);
+ SetProp(connArgs, "Password", password ?? string.Empty);
+ object certInfo = Activator.CreateInstance(certInfoType)!;
+ TrySetProp(certInfo, "CertificateName", certName);
+ TrySetProp(certInfo, "SecurityMode", Enum.Parse(secModeType, "TransportCertificate"));
+ TrySetProp(connArgs, "SecurityInfo", certInfo);
+
+ object access = Activator.CreateInstance(accessType)!;
+ object?[] openArgs = { connArgs, Activator.CreateInstance(errorType) };
+ Console.WriteLine($"OpenConnection: server={server} port={port} type=Event readonly=false");
+ bool opened;
+ try
+ {
+ opened = (bool)accessType.GetMethod("OpenConnection", new[] { connArgsType, errorType.MakeByRefType() })!
+ .Invoke(access, openArgs)!;
+ }
+ catch (TargetInvocationException tie)
+ {
+ Console.Error.WriteLine($"OpenConnection threw: {tie.InnerException?.GetType().Name}: {tie.InnerException?.Message}");
+ return 2;
+ }
+ Console.WriteLine($"OpenConnection returned: {opened} err={DescribeError(openArgs[1])}");
+ if (!opened) { return 2; }
+
+ try
+ {
+ // Build a clearly-marked test event. Required: Type (≤32 chars), Id, EventTime.
+ object evt = Activator.CreateInstance(eventType)!;
+ SetProp(evt, "Type", evtTypeName);
+ TrySetProp(evt, "Id", Guid.NewGuid());
+ TrySetProp(evt, "EventTime", DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc));
+ TrySetProp(evt, "Namespace", "SdkCapture");
+ TrySetProp(evt, "Source", "SdkCaptureProbe");
+
+ // One string property to exercise the property-bag framing.
+ MethodInfo? addProp = eventType.GetMethods().FirstOrDefault(m =>
+ m.Name == "AddProperty" && m.GetParameters().Length == 4);
+ if (addProp != null)
+ {
+ try
+ {
+ object strEnum = Enum.Parse(propTypeEnum, "String", true);
+ object?[] apArgs = { "SdkProbeProp", "SdkProbeValue", strEnum, Activator.CreateInstance(errorType) };
+ addProp.Invoke(evt, apArgs);
+ Console.WriteLine($"AddProperty: err={DescribeError(apArgs[3])}");
+ }
+ catch (Exception ex) { Console.WriteLine($"AddProperty skipped: {ex.GetType().Name}"); }
+ }
+
+ MethodInfo addStreamed = accessType.GetMethods().First(m =>
+ m.Name == "AddStreamedValue" && m.GetParameters().Length == 2
+ && m.GetParameters()[0].ParameterType == eventType);
+ object?[] asArgs = { evt, Activator.CreateInstance(errorType) };
+ bool sent = (bool)addStreamed.Invoke(access, asArgs)!;
+ Console.WriteLine($"AddStreamedValue({evtTypeName}): {sent} err={DescribeError(asArgs[1])}");
+
+ // Let the native delivery queue flush the event onto the wire (AddStreamValues).
+ System.Threading.Thread.Sleep(flushSeconds * 1000);
+ Console.WriteLine(sent ? "CAPTURE-SEND-EVENT: AddStreamedValue accepted (buffer captured on flush)" : "CAPTURE-SEND-EVENT: AddStreamedValue rejected");
+ return sent ? 0 : 3;
+ }
+ finally
+ {
+ try
+ {
+ // CloseConnection flushes any remaining queued values before teardown.
+ MethodInfo? close = accessType.GetMethod("CloseConnection", new[] { errorType.MakeByRefType() });
+ if (close != null) close.Invoke(access, new object?[] { Activator.CreateInstance(errorType) });
+ }
+ catch { /* best-effort */ }
+ }
+ }
+
///
/// Read-only gRPC connect probe: opens a 2023 R2 Historian (mode=Historian) connection via the
/// native client and reports the resulting connection status. Proves the mixed-mode client can