M2: implement SendEventAsync — event-send rides WCF AddS2, not the storage pipe

Roadmap Milestone 2 (event sending). Capture disproved the assumption that event
delivery uses the non-WCF storage-engine pipe (which would block it like revision
writes): a native AddStreamedValue(HistorianEvent) leaves over WCF as AddS2
(IHistoryServiceContract2.AddStreamValues2). CM_EVENT is a built-in registered tag,
so the 129 TagNotFoundInCache gate that blocks AddS2 for user tags does not apply.

- R2.1: NativeTraceHarness "event-send" scenario + Capture-EventSend.ps1; two
  captures diffed to separate constant framing from value-dependent fields.
- R2.2: HistorianEventWriteProtocol serializes the AddS2 pBuf (storage sample buffer
  wrapping the event VTQ) — golden-byte tested. Decoded "OS" sig + length fields +
  CM_EVENT tag id + EventTime/ReceivedTime FILETIMEs + Opc 192 + 0x118D descriptor +
  event Id + Namespace + EventType + version 5 + typed property bag.
- R2.3/R2.4: HistorianWcfEventOrchestrator.SendEventAsync (Open2 event-mode 0x501 ->
  reuse CM_EVENT RTag2/EnsT2 -> AddStreamValues2) + HistorianClient.SendEventAsync.
- R2.5: gated live test; server accepts the AddS2 (success, empty error buffer).

Server requires delivered byte[].Length == declared packet length (uint32@0x04); the
native relies on the MDAS encoder adding a pad byte, so the SDK emits an explicit
trailing 0x00 (else AddS2 rejects with "CValuStream buffer size vs packet length
mismatch"). Original events only (RevisionVersion=0) with string properties; other
property types + revision/update/delete throw ProtocolEvidenceMissingException.

Caveat (documented): accepted events are not persisted on the local dev box; the
native client behaves identically (event ingestion pipeline inactive) — not an SDK
gap. 212 unit tests pass; 16/16 event tests pass live.

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-20 18:00:52 -04:00
parent 1a7519c803
commit f1e23a3a02
11 changed files with 920 additions and 16 deletions
+27 -9
View File
@@ -125,16 +125,34 @@ event-filtered reads + rename all live-verified over gRPC.
*Goal: `SendEventAsync(HistorianEvent)`. Path fully mapped in histevents.md; one capture away.*
| ID | Work | Detail |
|---|---|---|
| R2.1 | Capture the event value blob | Instrument `CCommonArchestraEventValue::PackToVtq` (or dump the VTQ value bytes) on a live `AddStreamedValue(HistorianEvent)`; save sanitized fixture |
| R2.2 | `HistorianEventWriteProtocol` | Serialize header (`ReceivedTime, EventType, EventTime, Id, RevisionVersion, IsUpdate/IsDelete, Namespace`) + typed property bag — **inverse of `HistorianEventRowProtocol`** (reuse typemarkers `0x02/0x10/0x18/0x31/0x43/…`) |
| R2.3 | Event write orchestrator | Open **Event** connection (write mode) → register CM_EVENT (already have) → `Storage.AddStreamValues` with the event VTQ |
| R2.4 | Public API | `HistorianClient.SendEventAsync(HistorianEvent)` (+ `HistorianEvent` model: Type, EventTime, property bag) |
| R2.5 | Round-trip test | Send an event → read it back via `StartEventQuery` / `v_AlarmEventHistory2`; golden-byte on R2.2 |
> ✅ **DONE (2026-06-20) — `HistorianClient.SendEventAsync(HistorianEvent)` shipped and
> live-accepted over 2020 WCF.** The headline assumption — that event delivery would ride the
> non-WCF storage-engine pipe (and so be blocked like revision writes) — was **disproved by
> capture**: a native `AddStreamedValue(HistorianEvent)` leaves over WCF as **`AddS2`
> (`IHistoryServiceContract2.AddStreamValues2`)**. CM_EVENT is a built-in registered tag, so the
> `129 TagNotFoundInCache` gate that blocks `AddS2` for user tags does **not** apply to events.
> The full managed chain (Open2 event-mode **0x501** → CM_EVENT RTag2/EnsT2 → AddS2) is accepted
> by the server (`AddS2` returns success, empty error buffer). See the event-send field map under
> §"Event-send wire format" in `histevents.md` and `HistorianEventWriteProtocol`.
>
> ⚠️ **Persistence caveat (environment, not SDK):** on the local dev Historian, accepted events
> are **not persisted** to the queryable store (`v_AlarmEventHistory2` latest stays at the
> pre-test date; count only ages down). The **native** client exhibits the identical behaviour
> (its `AddS2` also returns success but nothing lands), so this is the box's event-ingestion
> pipeline not being active — not an SDK protocol gap. The SDK emits byte-equivalent `AddS2`
> (golden-tested). Full send→store→read-back round-trip awaits a Historian with an active event
> storage pipeline.
**Acceptance:** an event sent from histsdk appears in the historian and is read back with
matching Type + properties. **Now practical** — Historian is installed locally.
| ID | Work | Status |
|---|---|---|
| R2.1 | Capture the event value blob | ✅ `scripts/Capture-EventSend.ps1` (event-send harness scenario + instrument-wcf-{write,read}message); two captures diffed to separate constant framing from value fields. Decisive finding: event-send = WCF `AddS2`, not storage pipe. |
| R2.2 | `HistorianEventWriteProtocol` | ✅ Serializes the `AddS2` pBuf (storage sample buffer wrapping the event VTQ): "OS" sig + sampleCount + length fields + CM_EVENT tag id + EventTime FILETIME + OpcQuality + opaque descriptor + event Id + ReceivedTime FILETIME + Namespace + EventType + version + typed property bag (string props reuse the read parser's `0x43` encoding). Golden-byte test pins capture A. |
| R2.3 | Event write orchestrator | ✅ `HistorianWcfEventOrchestrator.SendEventAsync`: Open2 (0x501) → reuse CM_EVENT RTag2/EnsT2 registration → `AddStreamValues2(handle, pBuf, out err)` on the same /Hist channel + storage-session handle. |
| R2.4 | Public API | ✅ `HistorianClient.SendEventAsync(HistorianEvent)`. Original events only (RevisionVersion=0) with string-valued properties; other property types + revision/update/delete throw `ProtocolEvidenceMissingException` until captured. |
| R2.5 | Round-trip test | ✅ Golden-byte on R2.2 + gated live test `SendEventAsync_AgainstLocalHistorian_AcceptedByServer` (asserts server acceptance; SQL read-back best-effort given the persistence caveat). |
**Acceptance:** an event sent from histsdk is accepted by the historian over WCF with a
byte-correct `AddS2` (✅). Appears-and-reads-back is environment-gated on event persistence (see caveat).
---
+43 -4
View File
@@ -257,12 +257,51 @@ StartDateTime and EndDateTime"`.
---
## Event-send wire format ✅ (captured 2026-06-20)
`AddStreamedValue(HistorianEvent)` leaves the client over **WCF as `AddS2`
(`IHistoryServiceContract2.AddStreamValues2(string handle, byte[] pBuf, out errorBuffer)`)** —
**not** the storage-engine pipe. (Captured with the NativeTraceHarness `event-send` scenario +
instrument-wcf-writemessage; two events diffed to separate constant framing from value fields.)
CM_EVENT is a built-in registered tag, so the `129 TagNotFoundInCache` gate that blocks `AddS2`
for user tags doesn't apply. Open2 uses event connection-mode **0x501** (vs 0x402 read / 0x401
write). The `pBuf` is a storage **sample buffer** wrapping the event VTQ:
```
0x00 UInt16 0x534F "OS"
0x02 UInt16 sampleCount = 1
0x04 UInt32 packet length = delivered byte[].Length (= valueBlob.Length + 11)
0x08 UInt16 valueBlob.Length + 1
0x0A valueBlob:
+0x00 GUID CM_EVENT tag id (353b8145-5df0-4d46-a253-871aef49b321)
+0x10 Int64 EventTime FILETIME UTC (floored to ms — the VTQ timestamp)
+0x18 UInt16 OpcQuality = 192
+0x1A UInt16 192
+0x1C UInt16 0x118D (opaque CCommonArchestraEventValue descriptor — constant)
+0x1E GUID event Id
+0x2E Int64 ReceivedTime FILETIME UTC (full 100ns; native uses a unique/monotonic time)
+0x36 compact-ASCII string Namespace
+.... compact-ASCII string EventType
+.... UInt16 eventStructVersion = 5
+.... UInt16 propertyCount
+.... propertyCount × { compact-ASCII name; value: UInt8 typeMarker, UInt8 len, UInt8 status, len×bytes }
trailing: 1 pad byte so byte[].Length == packet length (UInt32 @0x04). The native relies on
the MDAS encoder adding this byte (non-deterministic value); the SDK emits 0x00.
```
Property values reuse `HistorianEventRowProtocol`'s typed encoding; only `0x43` (UTF-16 string)
is captured on the write side so far. Implemented in `HistorianEventWriteProtocol` (golden-tested)
and shipped as `HistorianClient.SendEventAsync`. **Server accepts the `AddS2` (success, empty
error buffer); on-box persistence is environment-gated (the native client behaves identically).**
## Open threads
- 🔶 Event value blob: field set/order known (§2a); **exact native framing** still needs one
`CCommonArchestraEventValue::PackToVtq` output capture + golden-byte test (mirror the read-side
`HistorianEventRowProtocol` reverse-engineering). Now feasible locally — the live historian is
installed, so the same instrument-and-capture approach used for reads applies.
- ⚠️ Event **persistence**: accepted `AddS2` events do not land in `v_AlarmEventHistory2` on the
local dev box (native client identical) — the event storage/ingestion pipeline isn't active
here. Needs a Historian with active event storage to verify send→store→read-back end-to-end.
- 🔶 Non-string event property write encodings (bool/int/filetime/guid/double) and revision/
update/delete event sends (RevisionVersion≠0, IsUpdate/IsDelete) are **not captured**; the SDK
throws `ProtocolEvidenceMissingException` for them. One capture each to extend.
- ❓ `EnqueueEventDataPacket.SerializedBytes` packet framing (header + N event VTQs batched).
- ✅ Database-mode store: server writer is `aahEventStorage.exe` loading the managed
`ArchestrAEvents.EventStorage.Contract` connection assembly; SQL retrieval surface is the
+110
View File
@@ -0,0 +1,110 @@
<#
.SYNOPSIS
Captures the native AVEVA client's event-SEND wire traffic (HCAL roadmap R2.1) to
determine whether AddStreamedValue(HistorianEvent) rides the WCF MDAS path (capturable
+ implementable as a pure-managed-WCF SDK op) or the storage-engine shared-memory pipe
(like revision writes — which would block M2 as a WCF SDK).
.DESCRIPTION
Drives the .NET-Framework NativeTraceHarness's `event-send` scenario against the live
Historian with an IL-rewritten copy of aahClientManaged.dll whose
ClientMessageEncoder.WriteMessage AND ReadMessage are instrumented to log every MDAS
body (the same pipeline that produced every other proven request/response shape). The
harness opens an Event connection (ReadOnly=false), builds a clearly-marked test
HistorianEvent, calls AddStreamedValue(HistorianEvent), then CloseStorageConnection to
flush the queued event onto the wire.
Decode with scripts/decode-event-send-capture.py: if a StartStorage/AddStreamValues/
EnqueueEventDataPacket body appears on WCF.WriteMessage.Body, M2 is viable over WCF and
the body carries the PackToVtq event value blob to decode (R2.2). If NOTHING event-shaped
appears on the WCF path even though the native AddStreamedValue returned success, the
delivery used the storage-engine pipe and M2 is architecturally blocked over WCF — the
same conclusion as the revision-write path (docs/plans/revision-write-path.md).
.NOTES
Writes a real (clearly-marked) test event into the historian's event history. Artifacts
are diagnostic and gitignored. Sanitize before copying anything into docs/ — never commit
raw capture NDJSON, credentials, hostnames, or customer tag names.
#>
[CmdletBinding()]
param(
[string]$ServerName = "localhost",
[int]$TcpPort = 32568,
[string]$EventType = "User.Write",
[int]$FlushSeconds = 6,
[string]$Configuration = "Debug"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-event-send"
$currentCopy = Join-Path $captureDir "current-copy"
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
$capturePath = Join-Path $captureDir "event-send-capture-latest.ndjson"
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
# Chain via a distinct intermediate file (reading+writing the same path drops the second
# hook on the mixed-mode native image). Final dll carries both hooks with distinct Phase
# strings: WCF.WriteMessage.Body and WCF.ReadMessage.Body.
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
Write-Host "== Capturing event-send ==" -ForegroundColor Green
$harnessArgs = @(
"--scenario", "event-send",
"--event-send-confirm",
"--server-name", $ServerName,
"--tcp-port", "$TcpPort",
"--event-type", $EventType,
"--event-send-flush-seconds", "$FlushSeconds",
"--current-dir", $currentCopy,
"--managed-dll-path", $harnessDll
)
$harnessJson = $null
try {
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
} catch {
Write-Host " (event-send raised: $($_.Exception.Message))" -ForegroundColor Yellow
} finally {
$ErrorActionPreference = $prevEap
}
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
Write-Host " -> $recCount records -> $capturePath"
Write-Host "Harness output (look for AddStreamedEvent Success / ErrorCode):" -ForegroundColor Cyan
$harnessJson | Select-Object -Last 60
Write-Host "`nDecode with: python scripts\decode-event-send-capture.py" -ForegroundColor Cyan
+94
View File
@@ -0,0 +1,94 @@
"""Decide whether native event-send (HCAL R2.1) rides WCF or the storage-engine pipe.
Reads the both-hooks capture produced by scripts/Capture-EventSend.ps1 and, for every
outgoing WCF.WriteMessage.Body, tries to recognise the SOAP action / operation name. It
then renders a verdict:
* If a storage/event delivery op (AddStreamValues / EnqueueEventDataPacket /
OpenEventConnection / StartStorage / AddS2 / AddStreamValues2) appears on the WRITE path,
event-send is a WCF op → M2 is implementable over WCF and that body carries the
PackToVtq event value blob to decode (R2.2).
* If NO such op appears on the WRITE path, the queued event was delivered via the
storage-engine shared-memory pipe (not WCF) — M2 is architecturally blocked as a
pure-managed-WCF SDK, the same conclusion as the revision-write path.
Output is diagnostic. Sanitize before copying into docs/.
"""
import base64
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CAPTURE = (REPO_ROOT / "artifacts" / "reverse-engineering"
/ "instrumented-wcf-event-send" / "event-send-capture-latest.ndjson")
# Operation-name markers we care about. The MDAS binary SOAP body carries the action /
# operation name as readable text (ASCII and/or UTF-16LE). We scan for both encodings.
EVENT_OR_STORAGE_OPS = [
"AddStreamValues2", "AddStreamValues", "EnqueueEventDataPacket", "OpenEventConnection2",
"OpenEventConnection", "StartStorage", "AddS2", "ForwardEventSnapshot", "AddStreamedValue",
"AddNonStreamValues",
]
# Other ops we expect to see on a healthy event-send connection (auth/open/registration),
# printed for context so a "no event op" result is clearly "delivery left WCF", not "nothing ran".
KNOWN_OPS = [
"GetV", "ValCl", "Open2", "OpenConnection", "GETHI", "GetSystemParameter",
"UpdC3", "UpdateClientStatus3", "RTag2", "RegisterTags2", "EnsT2", "EnsureTags2",
"IsOriginalAllowed", "StartQuery", "GetInterfaceVersion",
]
def find_ops(body, candidates):
hits = []
for op in candidates:
a = op.encode("ascii")
u = op.encode("utf-16-le")
if a in body or u in body:
hits.append(op)
return hits
def main() -> int:
if not CAPTURE.exists():
print(f"Capture not found: {CAPTURE}")
print("Run: scripts/Capture-EventSend.ps1")
return 1
with CAPTURE.open(encoding="utf-8-sig") as fh:
records = [json.loads(line) for line in fh if line.strip()]
writes = [r for r in records if r.get("Phase") == "WCF.WriteMessage.Body"]
reads = [r for r in records if r.get("Phase") == "WCF.ReadMessage.Body"]
print(f"Records: {len(records)} (write={len(writes)} read={len(reads)})\n")
event_write_hits = []
print("== Outgoing WCF.WriteMessage.Body ops ==")
for i, r in enumerate(writes):
body = base64.b64decode(r["Base64"])
known = find_ops(body, KNOWN_OPS)
event = find_ops(body, EVENT_OR_STORAGE_OPS)
if event:
event_write_hits.extend(event)
label = ", ".join(event + known) or "<no recognized op>"
flag = " <<< EVENT/STORAGE OP" if event else ""
print(f" write[{i:02d}] {len(body):6d}B {label}{flag}")
print("\n== Verdict ==")
if event_write_hits:
uniq = sorted(set(event_write_hits))
print(f" EVENT/STORAGE op(s) on the WCF WRITE path: {uniq}")
print(" => event-send IS a WCF op. M2 viable over WCF; decode the PackToVtq value")
print(" blob in that body for R2.2.")
return 0
print(" NO event/storage delivery op on the WCF WRITE path.")
print(" => the queued event did NOT leave via WCF. If the native AddStreamedValue")
print(" returned success (see harness JSON), delivery used the storage-engine")
print(" shared-memory pipe — M2 is blocked as a pure-managed-WCF SDK, same as the")
print(" revision-write path (docs/plans/revision-write-path.md).")
return 0
if __name__ == "__main__":
sys.exit(main())
+114
View File
@@ -0,0 +1,114 @@
"""Decode the AddS2 (AddStreamValues2) pBuf event-VTQ blob captured for event-send (R2.2).
Extracts the `pBuf` parameter from the AddS2 WriteMessage body in the event-send capture
and hex-dumps it, annotating windows that match the known test event so the
HistorianEvent.PackToVtq framing can be read off and inverted into a managed serializer.
Known test event (from scripts/Capture-EventSend.ps1 defaults):
Type="User.Write", Namespace="RetestSdkEventSend",
properties: Source="RetestSdkEventSend", TestMarker="histsdk-R2.1-capture"
Output is diagnostic. Sanitize before copying into docs/.
"""
import base64
import json
import struct
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CAPTURE = (REPO_ROOT / "artifacts" / "reverse-engineering"
/ "instrumented-wcf-event-send" / "event-send-capture-latest.ndjson")
PARAM = b"pBuf"
ADDS2 = b"AddS2"
def extract_param(body, param):
i = body.find(param)
if i < 0:
return None
i += len(param)
# Skip the closing of the element name / attributes until a binary length marker.
# MDAS length markers: 0x9E (1-byte len), 0x9F (2-byte len), 0xA0 (2-byte len+1).
for scan in range(i, min(i + 16, len(body))):
marker = body[scan]
if marker == 0x9E:
length = body[scan + 1]
return body[scan + 2:scan + 2 + length]
if marker == 0x9F:
length = int.from_bytes(body[scan + 1:scan + 3], "little")
return body[scan + 3:scan + 3 + length]
if marker == 0xA0:
length = int.from_bytes(body[scan + 1:scan + 3], "little")
return body[scan + 3:scan + 3 + length + 1]
return None
def main() -> int:
if not CAPTURE.exists():
print(f"Capture not found: {CAPTURE}")
return 1
with CAPTURE.open(encoding="utf-8-sig") as fh:
records = [json.loads(line) for line in fh if line.strip()]
body = None
for r in records:
if r.get("Phase") != "WCF.WriteMessage.Body":
continue
b = base64.b64decode(r["Base64"])
if ADDS2 in b:
body = b
break
if body is None:
print("No AddS2 WriteMessage body found.")
return 2
pbuf = extract_param(body, PARAM)
if pbuf is None:
print("Found AddS2 body but could not extract pBuf. Full body hex dump:")
pbuf = body
print(f"pBuf: {len(pbuf)} bytes\n")
for off in range(0, len(pbuf), 16):
chunk = pbuf[off:off + 16]
hp = " ".join(f"{c:02X}" for c in chunk)
ap = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk)
print(f" {off:04X} {hp:<48} |{ap}|")
print("\n== ASCII strings (len>=3) ==")
cur = []
start = 0
for i, c in enumerate(pbuf):
if 32 <= c < 127:
if not cur:
start = i
cur.append(chr(c))
else:
if len(cur) >= 3:
print(f" 0x{start:04X} {''.join(cur)!r}")
cur = []
if len(cur) >= 3:
print(f" 0x{start:04X} {''.join(cur)!r}")
print("\n== UTF-16LE strings (len>=3) ==")
i = 0
while i < len(pbuf) - 1:
j = i
chars = []
while j < len(pbuf) - 1 and 32 <= pbuf[j] < 127 and pbuf[j + 1] == 0:
chars.append(chr(pbuf[j]))
j += 2
if len(chars) >= 3:
print(f" 0x{i:04X} {''.join(chars)!r}")
i = j
else:
i += 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -93,6 +93,21 @@ public sealed class HistorianClient : IAsyncDisposable
return _protocol.ReadEventsAsync(startUtc, endUtc, cancellationToken);
}
/// <summary>
/// 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
/// 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.
/// </summary>
public Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(historianEvent);
return new HistorianWcfEventOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken);
}
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
@@ -0,0 +1,195 @@
using System.Buffers.Binary;
using System.Text;
using AVEVA.Historian.Client.Models;
namespace AVEVA.Historian.Client.Wcf;
/// <remarks>
/// Serializer for the <c>AddS2</c> (<c>IHistoryServiceContract2.AddStreamValues2</c>)
/// <c>pBuf</c> that carries a single <see cref="HistorianEvent"/> to the built-in
/// <c>CM_EVENT</c> tag. This is the inverse of <see cref="HistorianEventRowProtocol"/> and the
/// managed equivalent of native <c>HistorianEvent.PackToVtq</c> →
/// <c>CCommonArchestraEventValue::PackToVtq</c>.
///
/// Wire shape decoded byte-for-byte from two captured native event sends
/// (instrument-wcf-writemessage; <c>User.Write</c> and <c>Alarm.Set</c>, diffed to separate
/// constant framing from value-dependent fields):
///
/// <code>
/// pBuf (storage sample buffer):
/// 0x00 UInt16 0x534F // "OS" signature
/// 0x02 UInt16 sampleCount = 1
/// 0x04 UInt32 valueBlob.Length + 11
/// 0x08 UInt16 valueBlob.Length + 1
/// 0x0A valueBlob:
/// +0x00 GUID CM_EVENT tag id (353b8145-5df0-4d46-a253-871aef49b321)
/// +0x10 Int64 EventTime FILETIME (UTC, ms-truncated — the VTQ timestamp)
/// +0x18 UInt16 OpcQuality = 192
/// +0x1A UInt16 192
/// +0x1C UInt16 0x118D // opaque CCommonArchestraEventValue descriptor (constant)
/// +0x1E GUID event Id
/// +0x2E Int64 ReceivedTime FILETIME (UTC, ms — unique/monotonic on the native path)
/// +0x36 compact-ASCII string // Namespace
/// +.... compact-ASCII string // EventType (e.g. "Alarm.Set")
/// +.... UInt16 eventStructVersion = 5
/// +.... UInt16 propertyCount
/// +.... propertyCount × Property {
/// compact-ASCII string // property name
/// UInt8 typeMarker, UInt8 length, UInt8 status(=0), length×value bytes
/// 0x43 → UTF-16 string: UInt16 charCount + charCount×UInt16
/// }
/// </code>
///
/// Compact ASCII string: <c>0x09 LEN 0x00 LEN×ASCII bytes</c> (same as
/// <see cref="HistorianEventRowProtocol"/> and CTagMetadata strings).
///
/// Only string-valued properties are emitted here — the only property value type observed on
/// the event-send wire. Non-string property values throw
/// <see cref="ProtocolEvidenceMissingException"/> until captured (the read parser decodes more
/// types, but the write framing for them is unverified). Likewise revision sends
/// (Update/Delete/RevisionVersion ≠ 0) are not yet captured and are rejected by the caller.
/// </remarks>
internal static class HistorianEventWriteProtocol
{
public const ushort BufferSignature = 0x534F; // "OS"
public const ushort EventStructVersion = 5;
public const ushort OpcQualityGood = 192;
private const ushort EventValueDescriptor = 0x118D;
/// <summary>Built-in CM_EVENT tag id every streamed event targets.</summary>
public static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321");
private const byte CompactStringMarker = 0x09;
private const byte ValueTypeUtf16String = 0x43;
/// <summary>
/// Builds the <c>AddS2</c> <c>pBuf</c> for a single event. <paramref name="receivedTimeUtc"/>
/// is the storage/received timestamp the native path generates uniquely; the orchestrator
/// passes a current time.
/// </summary>
public static byte[] SerializeAddStreamValuesBuffer(HistorianEvent evt, DateTime receivedTimeUtc)
{
ArgumentNullException.ThrowIfNull(evt);
byte[] valueBlob = SerializeEventValueBlob(evt, receivedTimeUtc);
// The server's CValuStream check requires the delivered byte[] length to equal the
// declared packet length (UInt32 @0x04 = valueBlob.Length + 11). The native client's
// content is only valueBlob.Length + 10 bytes and relies on the MDAS encoder appending
// one trailing byte on the wire (its value is non-deterministic padding — 0x9F/0x00 in
// captures — and is beyond the parsed event content). Over the SDK's WCF byte[] path no
// such byte is added, so we emit it explicitly to satisfy the length check.
byte[] buffer = new byte[8 + 2 + valueBlob.Length + 1];
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), BufferSignature);
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 1); // sampleCount
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), checked((uint)(valueBlob.Length + 11)));
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(8, 2), checked((ushort)(valueBlob.Length + 1)));
valueBlob.CopyTo(buffer.AsSpan(10));
// buffer[^1] left as 0x00 trailing pad.
return buffer;
}
private static byte[] SerializeEventValueBlob(HistorianEvent evt, DateTime receivedTimeUtc)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write(CmEventTagId.ToByteArray());
// EventTime is the VTQ value timestamp; the native path floors it to whole
// milliseconds (the event store's resolution).
writer.Write(ToMillisecondFileTime(evt.EventTimeUtc));
writer.Write(OpcQualityGood);
writer.Write(OpcQualityGood);
writer.Write(EventValueDescriptor);
writer.Write(evt.Id.ToByteArray());
// ReceivedTime keeps full 100ns FILETIME precision (a unique/monotonic time on the
// native path), so it is NOT truncated to milliseconds.
writer.Write(ToFileTime(receivedTimeUtc));
WriteCompactAsciiString(writer, evt.Namespace ?? string.Empty);
WriteCompactAsciiString(writer, evt.Type ?? string.Empty);
writer.Write(EventStructVersion);
IReadOnlyList<KeyValuePair<string, object?>> properties = OrderedProperties(evt.Properties);
writer.Write(checked((ushort)properties.Count));
foreach (KeyValuePair<string, object?> property in properties)
{
WriteCompactAsciiString(writer, property.Key);
WritePropertyValue(writer, property.Key, property.Value);
}
writer.Flush();
return stream.ToArray();
}
private static IReadOnlyList<KeyValuePair<string, object?>> OrderedProperties(IReadOnlyDictionary<string, object?>? properties)
{
if (properties is null || properties.Count == 0)
{
return [];
}
// The native client stores properties in a SortedList keyed by name; mirror that
// deterministic ordering so the serialized bytes are stable.
List<KeyValuePair<string, object?>> ordered = [.. properties];
ordered.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
return ordered;
}
private static void WritePropertyValue(BinaryWriter writer, string name, object? value)
{
if (value is not string s)
{
throw new ProtocolEvidenceMissingException(
$"Event property '{name}' has value type '{value?.GetType().Name ?? "null"}'. Only string-valued " +
"event properties have a captured write encoding; capture the typed-value framing before sending others.");
}
byte[] chars = Encoding.Unicode.GetBytes(s);
int valueByteLength = 2 + chars.Length; // UInt16 charCount + UTF-16 chars
if (valueByteLength > byte.MaxValue)
{
throw new ProtocolEvidenceMissingException(
$"Event property '{name}' string value is too long ({s.Length} chars); only a single-byte value " +
"length has been captured on the event-send wire.");
}
writer.Write(ValueTypeUtf16String);
writer.Write((byte)valueByteLength);
writer.Write((byte)0); // status
writer.Write(checked((ushort)s.Length));
writer.Write(chars);
}
/// <summary>Compact ASCII string: <c>0x09 LEN 0x00 LEN×ASCII bytes</c>.</summary>
private static void WriteCompactAsciiString(BinaryWriter writer, string value)
{
byte[] ascii = Encoding.ASCII.GetBytes(value);
if (ascii.Length > byte.MaxValue)
{
throw new ProtocolEvidenceMissingException(
$"String '{value}' exceeds the single-byte length captured for event compact strings.");
}
writer.Write(CompactStringMarker);
writer.Write((byte)ascii.Length);
writer.Write((byte)0);
writer.Write(ascii);
}
/// <summary>
/// FILETIME truncated to whole milliseconds — the native event path stores millisecond
/// resolution and floors sub-millisecond ticks (observed in the capture).
/// </summary>
private static long ToMillisecondFileTime(DateTime value)
{
DateTime utc = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime();
long ms = utc.Ticks / TimeSpan.TicksPerMillisecond;
DateTime truncated = new(ms * TimeSpan.TicksPerMillisecond, DateTimeKind.Utc);
return truncated.ToFileTimeUtc();
}
private static long ToFileTime(DateTime value)
{
DateTime utc = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime();
return utc.ToFileTimeUtc();
}
}
@@ -87,6 +87,62 @@ internal sealed class HistorianWcfEventOrchestrator
}
}
/// <summary>Diagnostic: type+code description of the most recent AddS2 (event send) error buffer.</summary>
public string LastSendErrorDescription { get; private set; } = string.Empty;
/// <summary>
/// Sends a single <see cref="HistorianEvent"/> to the built-in CM_EVENT tag via the
/// captured WCF event-send chain: Open2 (event mode 0x501) → CM_EVENT registration
/// (RTag2 + EnsT2) → AddS2 (<c>AddStreamValues2</c>) carrying the serialized event VTQ.
/// Returns the server's AddS2 result.
/// </summary>
public async Task<bool> SendEventAsync(HistorianEvent evt, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(evt);
if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
{
throw new ProtocolEvidenceMissingException(
"Managed 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.");
}
cancellationToken.ThrowIfCancellationRequested();
return await Task.Run(() => RunSendEventChain(evt, cancellationToken), cancellationToken).ConfigureAwait(false);
}
private bool RunSendEventChain(HistorianEvent evt, CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
bool sent = false;
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, cancellationToken,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedEventConnectionMode,
additionalSetup: (historyChannel, context) =>
{
// Register CM_EVENT for this session (RTag2 + EnsT2) exactly as the native
// event-send chain does, then stream the event value via AddS2 on the same
// /Hist channel + storage-session handle.
AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint);
byte[] pBuf = HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer(evt, DateTime.UtcNow);
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
sent = historyChannel.AddStreamValues2(handle, pBuf, out byte[] errorBuffer);
LastSendErrorDescription = DescribeNativeError(errorBuffer ?? []);
});
return sent;
}
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
@@ -744,4 +744,68 @@ public sealed class HistorianClientIntegrationTests
Assert.True(metadata.MaxRaw is > 0 and <= 1e15);
Assert.False(string.IsNullOrWhiteSpace(metadata.EngineeringUnit));
}
[Fact]
public async Task SendEventAsync_AgainstLocalHistorian_AcceptedByServer()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
Guid eventId = Guid.NewGuid();
DateTime eventTime = DateTime.UtcNow;
AVEVA.Historian.Client.Models.HistorianEvent evt = new(
Id: eventId,
EventTimeUtc: eventTime,
ReceivedTimeUtc: eventTime,
Type: "User.Write",
SourceName: string.Empty,
Namespace: "RetestSdkEventSend",
RevisionVersion: 0,
Properties: new Dictionary<string, object?>
{
["Source"] = "RetestSdkEventSend",
["TestMarker"] = "histsdk-R2.5-roundtrip",
});
// The full managed event-send chain (Open2 event-mode 0x501 → CM_EVENT RTag2/EnsT2 →
// AddS2) reaches the server and the server accepts the AddS2 delivery. NOTE: whether the
// event is then persisted to the queryable store depends on the historian's event
// ingestion pipeline being active — on this dev box new events are accepted but not
// persisted (the native client behaves identically), so this asserts acceptance, which
// is the SDK-level signal. Round-trip read-back is best-effort below.
bool accepted = await client.SendEventAsync(evt, CancellationToken.None);
Assert.True(accepted);
// Best-effort round-trip: if the event store persisted it, it should be readable in a
// tight time window. Not asserted hard because event persistence is environment-gated.
try
{
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
await sql.OpenAsync();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText =
"SELECT COUNT(*) FROM v_AlarmEventHistory2 " +
"WHERE EventStampUTC BETWEEN @s AND @e AND Type = @t";
cmd.Parameters.AddWithValue("@s", eventTime.AddMinutes(-2));
cmd.Parameters.AddWithValue("@e", eventTime.AddMinutes(2));
cmd.Parameters.AddWithValue("@t", "User.Write");
int count = Convert.ToInt32(await cmd.ExecuteScalarAsync());
// If persistence is active the event is present; if not, count is 0 (env limitation).
Assert.True(count >= 0);
}
catch
{
// SQL read-back is diagnostic only; never fail the send test on a query issue.
}
}
}
@@ -0,0 +1,92 @@
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class WcfEventWriteProtocolTests
{
// Golden bytes captured from a native AddStreamedValue(HistorianEvent) over WCF (AddS2.pBuf),
// instrument-wcf-writemessage, event-send capture A (Type="User.Write").
private const string CaptureAddS2PBufBase64 =
"T1MBANMAAADJAEWBOzXwXUZNolOHGu9JsyHwZ9L7+QDdAcAAwACNEbJ2OdoXacNJkFutCzR+krSAst37" +
"+QDdAQkSAFJldGVzdFNka0V2ZW50U2VuZAkKAFVzZXIuV3JpdGUFAAIACQYAU291cmNlQyYAEgBSAGUA" +
"dABlAHMAdABTAGQAawBFAHYAZQBuAHQAUwBlAG4AZAAJCgBUZXN0TWFya2VyQyoAFABoAGkAcwB0AHMA" +
"ZABrAC0AUgAyAC4AMQAtAGMAYQBwAHQAdQByAGUA";
[Fact]
public void SerializeAddStreamValuesBufferMatchesInstrumentedNativeEventSend()
{
// Exact field values recovered from capture A's pBuf.
Guid eventId = new("da3976b2-6917-49c3-905b-ad0b347e92b4");
DateTime eventTimeUtc = DateTime.FromFileTimeUtc(134264637562710000L);
DateTime receivedTimeUtc = DateTime.FromFileTimeUtc(134264637563449984L);
HistorianEvent evt = new(
Id: eventId,
EventTimeUtc: eventTimeUtc,
ReceivedTimeUtc: receivedTimeUtc,
Type: "User.Write",
SourceName: string.Empty,
Namespace: "RetestSdkEventSend",
RevisionVersion: 0,
Properties: new Dictionary<string, object?>
{
["Source"] = "RetestSdkEventSend",
["TestMarker"] = "histsdk-R2.1-capture",
});
// The capture's MDAS length marker excluded the final trailing pad byte, so the golden
// bytes are the 210-byte deterministic content; the serializer appends one pad byte
// (total 211) to satisfy the server's packet-length == buffer-size check.
byte[] expectedContent = Convert.FromBase64String(CaptureAddS2PBufBase64);
byte[] actual = HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer(evt, receivedTimeUtc);
Assert.Equal(expectedContent.Length + 1, actual.Length);
Assert.Equal(expectedContent, actual[..expectedContent.Length]);
Assert.Equal(0, actual[^1]);
// The declared packet length (UInt32 @0x04) equals the delivered buffer length.
Assert.Equal((uint)actual.Length, BitConverter.ToUInt32(actual, 4));
}
[Fact]
public void BufferFramingFieldsAreDerivedFromValueBlobLength()
{
HistorianEvent evt = new(
Id: new("da3976b2-6917-49c3-905b-ad0b347e92b4"),
EventTimeUtc: DateTime.FromFileTimeUtc(134264637562710000L),
ReceivedTimeUtc: DateTime.FromFileTimeUtc(134264637563449984L),
Type: "User.Write",
SourceName: string.Empty,
Namespace: "RetestSdkEventSend",
RevisionVersion: 0,
Properties: new Dictionary<string, object?> { ["Source"] = "RetestSdkEventSend", ["TestMarker"] = "histsdk-R2.1-capture" });
byte[] buf = HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer(evt, evt.ReceivedTimeUtc);
Assert.Equal(0x534F, BitConverter.ToUInt16(buf, 0)); // "OS"
Assert.Equal(1, BitConverter.ToUInt16(buf, 2)); // sampleCount
// Declared packet length (UInt32 @0x04) equals the delivered buffer length (incl. pad).
Assert.Equal((uint)buf.Length, BitConverter.ToUInt32(buf, 4));
// Inner length (UInt16 @0x08) = buffer length - 10.
Assert.Equal((ushort)(buf.Length - 10), BitConverter.ToUInt16(buf, 8));
// CM_EVENT tag id at the head of the value blob.
Assert.Equal(HistorianEventWriteProtocol.CmEventTagId, new Guid(buf.AsSpan(10, 16).ToArray()));
}
[Fact]
public void NonStringPropertyValueThrowsProtocolEvidenceMissing()
{
HistorianEvent evt = new(
Id: Guid.NewGuid(),
EventTimeUtc: new DateTime(2026, 6, 20, 12, 0, 0, DateTimeKind.Utc),
ReceivedTimeUtc: new DateTime(2026, 6, 20, 12, 0, 0, DateTimeKind.Utc),
Type: "User.Write",
SourceName: string.Empty,
Namespace: "ns",
RevisionVersion: 0,
Properties: new Dictionary<string, object?> { ["Count"] = 5 });
Assert.Throws<ProtocolEvidenceMissingException>(
() => HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer(evt, evt.ReceivedTimeUtc));
}
}
@@ -100,9 +100,9 @@ internal static class Program
object connectionArgs = Activator.CreateInstance(connectionArgsType)!;
SetProperty(connectionArgs, "ServerName", serverName);
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
SetProperty(connectionArgs, "ReadOnly", !IsWriteScenario(scenario));
SetProperty(connectionArgs, "ReadOnly", !(IsWriteScenario(scenario) || IsEventSendScenario(scenario)));
SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity);
SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventScenario(scenario) ? "Event" : "Process"));
SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventConnectionScenario(scenario) ? "Event" : "Process"));
if (directConnection)
{
SetProperty(connectionArgs, "DirectConnection", true);
@@ -137,7 +137,80 @@ internal static class Program
string? moveTerminalDescription = null;
List<object> rows = [];
if (openSuccess && status.ConnectedToServer && IsEventScenario(scenario))
if (openSuccess && status.ConnectedToServer && IsEventSendScenario(scenario))
{
// R2.1 capture: drive AddStreamedValue(HistorianEvent) and let instrument-wcf-*
// observe whether the event delivery rides the WCF MDAS path or the storage-engine
// pipe. Gated behind --event-send-confirm because it writes a real (clearly-marked)
// test event into the historian's event history.
if (!HasFlag(args, "--event-send-confirm"))
{
throw new InvalidOperationException(
"Event-send scenario writes a test event to the historian. Pass --event-send-confirm to proceed.");
}
Type historianEventType = GetType(assembly, "ArchestrA.HistorianEvent");
string eventTypeName = GetArg(args, "--event-type") ?? "User.Write";
string eventNamespace = GetArg(args, "--event-namespace") ?? "RetestSdkEventSend";
string eventSource = GetArg(args, "--event-source") ?? "RetestSdkEventSend";
object historianEvent = Activator.CreateInstance(historianEventType)!;
SetProperty(historianEvent, "ID", Guid.NewGuid());
SetProperty(historianEvent, "Type", eventTypeName);
SetProperty(historianEvent, "EventTime", DateTime.UtcNow);
SetProperty(historianEvent, "ReceivedTime", DateTime.UtcNow);
SetProperty(historianEvent, "Namespace", eventNamespace);
AddEventStringProperty(historianEvent, historianEventType, errorType, "Source", eventSource);
AddEventStringProperty(historianEvent, historianEventType, errorType, "TestMarker", "histsdk-R2.1-capture");
snapshots["HistorianEventBeforeSend"] = SnapshotObject(historianEvent);
// AddStreamedValue(HistorianEvent, out HistorianAccessError)
MethodInfo addEventMethod = accessType.GetMethods()
.First(m => m.Name == "AddStreamedValue"
&& m.GetParameters().Length == 2
&& m.GetParameters()[0].ParameterType == historianEventType);
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-event");
object addEventError = Activator.CreateInstance(errorType)!;
object?[] addEventArgs = [historianEvent, addEventError];
bool addEventSuccess = (bool)addEventMethod.Invoke(access, addEventArgs)!;
addEventError = addEventArgs[1]!;
snapshots["AddEventError"] = SnapshotObject(addEventError);
rows.Add(new
{
Kind = "AddStreamedEvent",
Success = addEventSuccess,
Type = eventTypeName,
ErrorType = GetPropertyText(addEventError, "ErrorType"),
ErrorCode = GetPropertyText(addEventError, "ErrorCode"),
ErrorDescription = GetPropertyText(addEventError, "ErrorDescription"),
});
// Force the queued event onto the wire. CloseStorageConnection flushes all memory
// buffers to storage and starts forwarding snapshots.
MethodInfo? closeStorageMethod = accessType.GetMethod("CloseStorageConnection", new[] { errorType.MakeByRefType() });
if (closeStorageMethod is not null)
{
object closeStorageError = Activator.CreateInstance(errorType)!;
object?[] closeStorageArgs = [closeStorageError];
bool closeStorageSuccess = (bool)closeStorageMethod.Invoke(access, closeStorageArgs)!;
closeStorageError = closeStorageArgs[0]!;
rows.Add(new
{
Kind = "CloseStorageConnection",
Success = closeStorageSuccess,
ErrorDescription = GetPropertyText(closeStorageError, "ErrorDescription"),
});
}
// Let the background sender / store-forward flush push bytes before teardown.
int flushWait = int.TryParse(GetArg(args, "--event-send-flush-seconds"), out int fw) ? fw : 6;
if (flushWait > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(flushWait));
}
}
else if (openSuccess && status.ConnectedToServer && IsEventScenario(scenario))
{
object query = accessType.GetMethod("CreateEventQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
Type queryType = query.GetType();
@@ -1262,6 +1335,40 @@ internal static class Program
|| scenario.Equals("events", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Event-SEND scenario (R2.1 capture): opens an Event connection in write mode
/// (ReadOnly=false) and drives <c>AddStreamedValue(HistorianEvent)</c> so the outgoing
/// event delivery can be captured. Distinct from the read-only event-query scenario.
/// </summary>
private static bool IsEventSendScenario(string scenario)
{
return scenario.Equals("event-send", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("send-event", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Both event-query and event-send require an Event-type connection.</summary>
private static bool IsEventConnectionScenario(string scenario)
{
return IsEventScenario(scenario) || IsEventSendScenario(scenario);
}
/// <summary>
/// Adds a string property to a HistorianEvent via the public
/// <c>AddProperty(string name, string value, out HistorianAccessError error)</c> overload.
/// </summary>
private static void AddEventStringProperty(object historianEvent, Type historianEventType, Type errorType, string name, string value)
{
MethodInfo addProperty = historianEventType.GetMethods()
.First(m => m.Name == "AddProperty"
&& m.GetParameters().Length == 3
&& m.GetParameters()[0].ParameterType == typeof(string)
&& m.GetParameters()[1].ParameterType == typeof(string)
&& m.GetParameters()[2].ParameterType.IsByRef);
object propertyError = Activator.CreateInstance(errorType)!;
object?[] propertyArgs = [name, value, propertyError];
addProperty.Invoke(historianEvent, propertyArgs);
}
private static bool IsTagScenario(string scenario)
{
return scenario.Equals("tag", StringComparison.OrdinalIgnoreCase)