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:
@@ -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.*
|
*Goal: `SendEventAsync(HistorianEvent)`. Path fully mapped in histevents.md; one capture away.*
|
||||||
|
|
||||||
| ID | Work | Detail |
|
> ✅ **DONE (2026-06-20) — `HistorianClient.SendEventAsync(HistorianEvent)` shipped and
|
||||||
|---|---|---|
|
> live-accepted over 2020 WCF.** The headline assumption — that event delivery would ride the
|
||||||
| R2.1 | Capture the event value blob | Instrument `CCommonArchestraEventValue::PackToVtq` (or dump the VTQ value bytes) on a live `AddStreamedValue(HistorianEvent)`; save sanitized fixture |
|
> non-WCF storage-engine pipe (and so be blocked like revision writes) — was **disproved by
|
||||||
| 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/…`) |
|
> capture**: a native `AddStreamedValue(HistorianEvent)` leaves over WCF as **`AddS2`
|
||||||
| R2.3 | Event write orchestrator | Open **Event** connection (write mode) → register CM_EVENT (already have) → `Storage.AddStreamValues` with the event VTQ |
|
> (`IHistoryServiceContract2.AddStreamValues2`)**. CM_EVENT is a built-in registered tag, so the
|
||||||
| R2.4 | Public API | `HistorianClient.SendEventAsync(HistorianEvent)` (+ `HistorianEvent` model: Type, EventTime, property bag) |
|
> `129 TagNotFoundInCache` gate that blocks `AddS2` for user tags does **not** apply to events.
|
||||||
| R2.5 | Round-trip test | Send an event → read it back via `StartEventQuery` / `v_AlarmEventHistory2`; golden-byte on R2.2 |
|
> 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
|
| ID | Work | Status |
|
||||||
matching Type + properties. **Now practical** — Historian is installed locally.
|
|---|---|---|
|
||||||
|
| 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## Open threads
|
||||||
|
|
||||||
- 🔶 Event value blob: field set/order known (§2a); **exact native framing** still needs one
|
- ⚠️ Event **persistence**: accepted `AddS2` events do not land in `v_AlarmEventHistory2` on the
|
||||||
`CCommonArchestraEventValue::PackToVtq` output capture + golden-byte test (mirror the read-side
|
local dev box (native client identical) — the event storage/ingestion pipeline isn't active
|
||||||
`HistorianEventRowProtocol` reverse-engineering). Now feasible locally — the live historian is
|
here. Needs a Historian with active event storage to verify send→store→read-back end-to-end.
|
||||||
installed, so the same instrument-and-capture approach used for reads applies.
|
- 🔶 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).
|
- ❓ `EnqueueEventDataPacket.SerializedBytes` packet framing (header + N event VTQs batched).
|
||||||
- ✅ Database-mode store: server writer is `aahEventStorage.exe` loading the managed
|
- ✅ Database-mode store: server writer is `aahEventStorage.exe` loading the managed
|
||||||
`ArchestrAEvents.EventStorage.Contract` connection assembly; SQL retrieval surface is the
|
`ArchestrAEvents.EventStorage.Contract` connection assembly; SQL retrieval surface is the
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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);
|
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)
|
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
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)
|
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Guid contextKey = Guid.NewGuid();
|
Guid contextKey = Guid.NewGuid();
|
||||||
|
|||||||
@@ -744,4 +744,68 @@ public sealed class HistorianClientIntegrationTests
|
|||||||
Assert.True(metadata.MaxRaw is > 0 and <= 1e15);
|
Assert.True(metadata.MaxRaw is > 0 and <= 1e15);
|
||||||
Assert.False(string.IsNullOrWhiteSpace(metadata.EngineeringUnit));
|
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)!;
|
object connectionArgs = Activator.CreateInstance(connectionArgsType)!;
|
||||||
SetProperty(connectionArgs, "ServerName", serverName);
|
SetProperty(connectionArgs, "ServerName", serverName);
|
||||||
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
|
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
|
||||||
SetProperty(connectionArgs, "ReadOnly", !IsWriteScenario(scenario));
|
SetProperty(connectionArgs, "ReadOnly", !(IsWriteScenario(scenario) || IsEventSendScenario(scenario)));
|
||||||
SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity);
|
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)
|
if (directConnection)
|
||||||
{
|
{
|
||||||
SetProperty(connectionArgs, "DirectConnection", true);
|
SetProperty(connectionArgs, "DirectConnection", true);
|
||||||
@@ -137,7 +137,80 @@ internal static class Program
|
|||||||
string? moveTerminalDescription = null;
|
string? moveTerminalDescription = null;
|
||||||
List<object> rows = [];
|
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>())!;
|
object query = accessType.GetMethod("CreateEventQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
|
||||||
Type queryType = query.GetType();
|
Type queryType = query.GetType();
|
||||||
@@ -1262,6 +1335,40 @@ internal static class Program
|
|||||||
|| scenario.Equals("events", StringComparison.OrdinalIgnoreCase);
|
|| 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)
|
private static bool IsTagScenario(string scenario)
|
||||||
{
|
{
|
||||||
return scenario.Equals("tag", StringComparison.OrdinalIgnoreCase)
|
return scenario.Equals("tag", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|||||||
Reference in New Issue
Block a user