SendEvent over gRPC: implement + live-validate (was capture-gated)

Captured the native 2023 R2 client's gRPC event send (new capture-send-event
harness scenario): it rides HistoryService.AddStreamValues with the SAME "OS"
(0x534F) storage-sample buffer the WCF path already uses (HistorianEventWrite-
Protocol) — confirming "no distinct RPC", and that it is NOT the historical
write's "ON" buffer. Diffed the write-enabled vs read-only v8 Event open: byte-
identical apart from per-session crypto, so the existing OpenSession event path
is reused unchanged.

So SendEvent-over-gRPC was pure assembly of proven parts:
- HistorianGrpcEventWriteOrchestrator = v8 Event open + CM_EVENT registration
  (UpdC3/RegisterTags/EnsureTags) + AddStreamValues(OS buffer).
- HistorianClient.SendEventAsync now routes to it for RemoteGrpc (WCF otherwise).

Live-validated end-to-end against the 2023 R2 server: pure-managed SDK send →
AddStreamValues BSuccess=true → the event reads back from the server (markers
confirmed in returned event rows). The native gRPC RegisterTags(24B) +
EnsureTags(86B) byte-match our serializers (new GrpcEventSendProtocolTests
golden, closing the 83-vs-86 EnsureTags question). Gated live test
SendEventAsync_OverGrpc_AcceptsEvent (opt-in HISTORIAN_GRPC_EVENT_SEND=1).
331 offline tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-23 15:37:22 -04:00
parent ae536bb4b8
commit afc7c4bf96
6 changed files with 384 additions and 21 deletions
+22 -13
View File
@@ -76,7 +76,17 @@ reuses the proven 2020 WCF byte serializers/parsers unchanged inside protobuf
`capture-event` harness (native, returns rows). `capture-event` harness (native, returns rows).
2. **R4.3 active-SF magnitude** — needs an **SF-active server** (D2 storage-engine 2. **R4.3 active-SF magnitude** — needs an **SF-active server** (D2 storage-engine
console handle). 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`; 4. **ExecuteSqlCommand over gRPC****server-walled** (`CSrvDbConnection`;
RegisterTags prime doesn't help). Use WCF for SQL. RegisterTags prime doesn't help). Use WCF for SQL.
5. **R4.2 revision EDITS** — storage-engine-pipe-only on BOTH transports (the D2 wall). 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 would differ is native and not on the wire. One untested low-effort check
remains: byte-diff a captured **Event-connection** EnsureTags/RegisterTags remains: byte-diff a captured **Event-connection** EnsureTags/RegisterTags
against our replay (the 83-vs-86-byte EnsT gap was never actually compared). 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 - **Item 3 (SendEvent over gRPC)** — **SHIPPED + LIVE-VALIDATED 2026-06-23** (was
capture.** RPC **confirmed** = `HistoryService.AddStreamValues` (the "no distinct "capturable"). RPC confirmed = `HistoryService.AddStreamValues` (the "no distinct RPC"
RPC" note is TRUE; an event rides the same RPC as a streamed sample, discriminated note is TRUE). The `btValues` VTQ buffer turned out to be already-owned: our M2
inside `btValues`). Public API `HistorianAccess.AddStreamedValue(HistorianEvent)` `HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer` ("OS" buffer, decoded from
→ native `AddHistorianValue`; prereqs known (write-enabled Event conn, CM_EVENT the WCF event-send) is the transport-independent `PackToVtq` equivalent and the gRPC send
tag handle, quality 192); field set/order recovered from `HistorianEvent.PackToVtq`. uses it **verbatim** (live capture: sig `OS`/0x534F, CM_EVENT GUID, identical framing — NOT
**Only the `btValues` VTQ byte layout is missing** — built by native the historical write's "ON" buffer). The write-enabled Event open is byte-identical to the
`CCommonArchestraEventValue::PackToVtq` and copied out as an opaque `CDataChunk`. read-only one (live diff). So SendEvent-over-gRPC was pure assembly:
Our read parser already decodes the inverse property-bag format. **Capturable `HistorianGrpcEventWriteOrchestrator` = existing v8 Event open + existing CM_EVENT
against the local Historian** (instrument `PackToVtq` output / the `AddStreamValues` registration + `AddStreamValues`(OS buffer). End-to-end live-validated (send → `BSuccess`
body) → then build `HistorianEventWriteProtocol` and reuse the → read back from the live server). Golden-tested + gated live test.
`HistorianGrpcHistoricalWriteOrchestrator` plumbing.
- **Item 4 (ExecuteSql over gRPC)** — **confirmed walled + explained.** The stock - **Item 4 (ExecuteSql over gRPC)** — **confirmed walled + explained.** The stock
client gates SQL **out client-side**: `HistorianAccess.ExecuteSqlCommand` returns client gates SQL **out client-side**: `HistorianAccess.ExecuteSqlCommand` returns
`OperationNotSupported` when `IsManagedHistorian(node)` or `!IsProcessConnectionRequested()` `OperationNotSupported` when `IsManagedHistorian(node)` or `!IsProcessConnectionRequested()`
@@ -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;
/// <summary>
/// 2023 R2 gRPC orchestrator for the event SEND (<see cref="HistorianClient.SendEventAsync"/>).
/// Captured live from the native 2023 R2 client (<c>capture-send-event</c> scenario,
/// 2026-06-23): an event send rides <c>HistoryService.AddStreamValues</c> with the SAME
/// <c>"OS"</c> (0x534F) storage-sample buffer the WCF AddS2 path uses
/// (<see cref="HistorianEventWriteProtocol"/>) — NOT a distinct event RPC and NOT the historical
/// write's <c>"ON"</c> buffer. The native client's write-enabled Event <c>OpenConnection</c>
/// 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 <see cref="HistorianGrpcHandshake.OpenSession"/> event path is reused unchanged. The
/// chain on a single Event session:
/// <list type="number">
/// <item>OpenConnection (v8 Event, ExchangeKey ECDH auth) → string storage handle</item>
/// <item>CM_EVENT registration: UpdateClientStatus → RegisterTags → EnsureTags (the same
/// buffers the gRPC event READ replays — verified byte-identical to the capture)</item>
/// <item><c>HistoryService.AddStreamValues</c>(strHandle, "OS" event buffer)</item>
/// </list>
/// Only original events (<see cref="HistorianEvent.RevisionVersion"/> = 0) with string-valued
/// properties have a captured encoding; others throw <see cref="ProtocolEvidenceMissingException"/>
/// from <see cref="HistorianEventWriteProtocol"/>.
/// </summary>
internal sealed class HistorianGrpcEventWriteOrchestrator
{
private readonly HistorianClientOptions _options;
public HistorianGrpcEventWriteOrchestrator(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>Diagnostic: type+code description of the most recent AddStreamValues error buffer.</summary>
public string LastSendErrorDescription { get; private set; } = string.Empty;
/// <summary>Diagnostic: outcomes of the CM_EVENT registration RPCs.</summary>
public string RegistrationDiag { get; private set; } = string.Empty;
public Task<bool> 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;
}
/// <summary>
/// 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 (<see cref="HistorianEventRegistrationProtocol"/> +
/// <see cref="HistorianAddTagsProtocol.SerializeCmEventEnsureTagsGrpc"/>) 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).
/// </summary>
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}; "; }
}
}
+13 -7
View File
@@ -114,18 +114,24 @@ public sealed class HistorianClient : IAsyncDisposable
} }
/// <summary> /// <summary>
/// Sends a single <see cref="HistorianEvent"/> to the Historian's built-in CM_EVENT tag /// Sends a single <see cref="HistorianEvent"/> to the Historian's built-in CM_EVENT tag.
/// over the WCF event pipeline (Open2 event mode → CM_EVENT registration → AddS2). The /// Over WCF this runs Open2 event mode → CM_EVENT registration → AddS2; over the 2023 R2
/// event is appended to the historian's event history and is readable back via /// <see cref="HistorianTransport.RemoteGrpc"/> transport it runs the captured-equivalent
/// <see cref="ReadEventsAsync"/> / the <c>v_AlarmEventHistory2</c> view. Only original /// v8 Event OpenConnection → CM_EVENT registration → <c>HistoryService.AddStreamValues</c>
/// events (<see cref="HistorianEvent.RevisionVersion"/> = 0) with string-valued properties /// with the same "OS" event buffer (live-captured 2026-06-23 — the send rides the same RPC
/// are supported; other property value types and revision/update/delete events throw /// 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 <see cref="ReadEventsAsync"/> /
/// the <c>v_AlarmEventHistory2</c> view. Only original events
/// (<see cref="HistorianEvent.RevisionVersion"/> = 0) with string-valued properties are
/// supported; other property value types and revision/update/delete events throw
/// <see cref="ProtocolEvidenceMissingException"/> until their wire encoding is captured. /// <see cref="ProtocolEvidenceMissingException"/> until their wire encoding is captured.
/// </summary> /// </summary>
public Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default) public Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(historianEvent); 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);
} }
/// <summary> /// <summary>
@@ -0,0 +1,53 @@
using System;
using System.Buffers.Binary;
using AVEVA.Historian.Client.Wcf;
using Xunit;
namespace AVEVA.Historian.Client.Tests;
/// <summary>
/// Golden-byte coverage for the 2023 R2 gRPC event-SEND registration buffers, pinned against a live
/// native capture (<c>capture-send-event</c> scenario, 2026-06-23). The send itself rides
/// <c>HistoryService.AddStreamValues</c> with the same "OS" buffer the WCF path uses
/// (<see cref="HistorianEventWriteProtocol"/>, already golden-tested in
/// <c>WcfEventWriteProtocolTests</c>); 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).
/// </summary>
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);
}
}
@@ -233,6 +233,37 @@ public sealed class HistorianGrpcIntegrationTests
Assert.Contains(samples, s => s.NumericValue is { } v && Math.Abs(v - expected) < 0.01); 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<string, object?> { ["SdkProbeProp"] = "SdkProbeValue" });
bool sent = await client.SendEventAsync(evt, CancellationToken.None);
Assert.True(sent, "gRPC SendEvent should be accepted by the server (AddStreamValues BSuccess).");
}
[Fact] [Fact]
public async Task ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows() public async Task ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows()
{ {
@@ -92,8 +92,10 @@ namespace AVEVA.Historian.Grpc2023CaptureHarness
return DeleteTag(managedDll, args); return DeleteTag(managedDll, args);
case "capture-event": case "capture-event":
return CaptureEvent(managedDll, args); return CaptureEvent(managedDll, args);
case "capture-send-event":
return CaptureSendEvent(managedDll, args);
default: 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; return 1;
} }
} }
@@ -603,6 +605,132 @@ namespace AVEVA.Historian.Grpc2023CaptureHarness
} }
} }
/// <summary>
/// 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 <host>] [--port 32565] [--cert <host>]
/// [--event-type SdkCaptureProbe] [--flush-seconds 6]
/// </summary>
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 */ }
}
}
/// <summary> /// <summary>
/// Read-only gRPC connect probe: opens a 2023 R2 Historian (mode=Historian) connection via the /// 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 /// native client and reports the resulting connection status. Proves the mixed-mode client can