Merge re/m3-grpc-nonstreamed-write: M3 non-streamed write reachable over gRPC (Begin/End live; sequence mapped)
The D2 storage-engine-pipe wall is WCF-transport-specific. On 2023 R2 gRPC, TransactionService.AddNonStreamValuesBegin/End round-trips live (write-enabled session, Open2 storage GUID as strHandle). Live decode + static mining mapped the full sequence: the remaining insert needs StorageService.OpenStorageConnection (+ RegisterTags) then a btInput decode — a focused follow-up. Revision EDITS (R4.2) stay pipe-only even on gRPC. 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:
+30
-14
@@ -166,8 +166,8 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
|
||||
| ~~R1.5~~ | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` (`GetTepByNm`) | ✅ **DONE (2026-06-20), live-verified.** `GetTagExtendedPropertiesAsync(tag)` → name/value pairs. String-handle op via the uppercase storage GUID; name-based path (`GetTagExtendedPropertiesByName`, not the QTB-gated TagQuery path). Request `tagNames` = `uint count` + per-name(`uint charCount`+UTF-16); response = `uint tagCount` + per-tag(marker + compact-ASCII name + `uint propCount` + per-prop(marker + compact-ASCII name + `0x43` VT_BSTR value) + trailer). Sequence-paged. Shipped: `HistorianTagExtendedPropertyProtocol`, golden `WcfTagExtendedPropertyProtocolTests`, gated live test. See `docs/reverse-engineering/wcf-tag-extended-properties.md`. | uppercase string handle |
|
||||
| ~~R1.6~~ | Localized-property **read** | (no op) | ⛔ **No distinct op on 2020 — collapses into R1.5.** There is no `GetTagLocalizedPropertiesFromName`/`GetTlpByNm` or `GetTagLocalizedPropertiesByName` in `current/aahClientManaged.dll`; the only "localized" surfaces are error-message/UI-text localization. Extended properties (R1.5) are the user-defined tag-property read surface. Closed, not throwing. | — |
|
||||
| ~~R1.7~~ | Event **filters** | filter bytes in `Retrieval.StartEventQuery` | ✅ **DONE (2026-06-20), live-honored.** `ReadEventsAsync(start, end, HistorianEventFilter)`. The filter rides `StartEventQuery`'s `pRequestBuff` (captured via `EventQuery.AddEventFilter` + instrument-wcf-writemessage; Equal vs Contains diffed to isolate the op). Filter block: `ushort 0 + uint filterCount + uint condCount + uint nameLen + name(UTF-16) + uint 1 + ushort op + uint 1 + value(0x09-len-0x00 compact-ASCII) + byte 0`. **REAL, not inert** (a non-matching predicate returns 0 events; matching returns the subset). Single string-valued predicate only; multi-filter (OR) / multi-condition (AND via `AddEventFilterCondition`) framing not yet fully captured. See `HistorianEventFilter`, golden `WcfEventQueryProtocolTests`. | — |
|
||||
| R1.8 | Analog-summary query | `Retrieval.StartQuery` (summary mode) | summary row layout — **`uint`-handle, reachable. Scoped + decode targets located** (`CAnalogSummaryValue.UnpackFromValueBuffer`, fields Min/Max/First/Last/ValueCount/Integral/…). Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md) | — |
|
||||
| R1.9 | State-summary query | `Retrieval.StartQuery` (state mode) | state-summary row layout — **`uint`-handle, reachable. Scoped** (`CStateSummaryStruct`: MinContained/MaxContained/TotalContained/PartialStart/PartialEnd/StateEntryCount). Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md) | — |
|
||||
| ~~R1.8~~ | Analog-summary query | `Retrieval.StartQuery` (summary mode) | ✅ **RESOLVED (2026-06-21) — no new code; == existing `ReadAggregateAsync`.** Request + response both captured (`scripts/Capture-SummaryRequest.ps1 -WithResponse`): the `GetNextQueryResultBuffer2` response is the **ordinary version-9 row buffer** the raw/aggregate parser already handles (decoded 7 rows = SQL ground truth exactly). There is **no rich `CAnalogSummaryValue` struct on the wire** — each row carries a *single* value selected by `RetrievalMode`/QueryType (Integral→8, TimeWeightedAverage→5, …), not an all-aggregates-in-one row; `ValueSelector`/`AggregationType`/`MaxStates` are **inert** on the WCF retrieval path (they configure the SQL provider, not this query). The all-aggregates-at-once shape is the SQL/OLEDB provider's, or the gRPC front door — not 2020 WCF binary. Plan + capture evidence: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md). | — |
|
||||
| ~~R1.9~~ | State-summary query | `Retrieval.StartQuery` (state mode) | ✅ **RESOLVED (2026-06-21) — same finding as R1.8.** State-summary is the **same `StartQuery2` request** (only `MaxStates`/defaults differ on the wire); the response carries no distinct `CStateSummaryStruct` on the 2020 WCF binary path. Covered by the existing aggregate read; no new `src/` code warranted. Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md). | — |
|
||||
|
||||
### 1c. Bounded config writes (S–M each)
|
||||
| ID | Capability | gRPC op | Payload | Notes |
|
||||
@@ -221,9 +221,21 @@ byte-correct `AddS2` (✅). Appears-and-reads-back is environment-gated on event
|
||||
|
||||
*Goal: insert original historical VTQs (backfill), the path that is NOT the gated cache push.*
|
||||
|
||||
> ✅ **gRPC UNLOCK (2026-06-21, LIVE-VERIFIED): the transaction lifecycle is REACHABLE over the
|
||||
> 2023 R2 gRPC front door.** The `grpc-revision-probe` opened a **write-enabled** (`0x401`) gRPC
|
||||
> session and drove `TransactionService.AddNonStreamValuesBegin(storage-GUID **uppercase**)` →
|
||||
> real `strTransactionId` → `AddNonStreamValuesEnd(bCommit=false)` (discarded, no data written).
|
||||
> Where 2020 WCF returns `UnknownClient (51)`, the gRPC `TransactionService` is itself the gateway
|
||||
> to the storage engine, so the Open2 session GUID is accepted directly — **no legacy pipe**. This
|
||||
> answers the M3-over-gRPC question below: **yes**, the non-streamed *original* write transaction is
|
||||
> reachable from the pure-managed SDK. **Not yet shipped:** the `AddNonStreamValues` `btInput` VTQ
|
||||
> buffer must be captured before any value-commit (never guess wire bytes); revision *edits* (R4.2)
|
||||
> remain pipe-only even on gRPC. Full detail + decompile basis:
|
||||
> [`revision-write-path.md`](revision-write-path.md) §"2023 R2 gRPC — the wall is gone".
|
||||
>
|
||||
> ⛔ **BLOCKED on 2020 WCF — re-confirmed by the D2 probe (2026-05-05), see
|
||||
> [`revision-write-path.md`](revision-write-path.md).** The premise above ("the path that is NOT
|
||||
> the gated cache push") was **disproved**: R3.1's op
|
||||
> the gated cache push") was **disproved** *on WCF*: R3.1's op
|
||||
> (`Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End`) is the **same**
|
||||
> `ITransactionServiceContract2.AddNonStreamValuesBegin2` D2 probed, and over WCF it returns
|
||||
> `04 33 00 00 00` = `UnknownClient (51)` for every handle format **and** the full priming chain
|
||||
@@ -242,12 +254,14 @@ byte-correct `AddS2` (✅). Appears-and-reads-back is environment-gated on event
|
||||
|
||||
| ID | Work | gRPC op | Status |
|
||||
|---|---|---|---|
|
||||
| R3.1 | Decode non-streamed VTQ packet | `Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End` | ⛔ WCF blocked (storage-engine pipe — D2). gRPC: untested |
|
||||
| R3.2 | `AddHistoricalValuesAsync` | batched begin→values→end | ⛔ gated on R3.1 |
|
||||
| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from `AddS2` cache wall) | ⛔ proven to share the same gate, not distinct |
|
||||
| R3.1 | Decode non-streamed VTQ packet | `Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End` | 🟡 **gRPC Begin/End LIVE-VERIFIED 2026-06-21; full sequence MAPPED** (WCF still blocked — D2). Live decode showed `AddNonStreamValues` reaches server `StoreNonStreamValues` → `\\.\pipe\aahStorageEngine\console` and fails for lack of a console session. Remaining (follow-up): `StorageService.OpenStorageConnection` handshake + `RegisterTags`, THEN the `btInput` decode. See [`revision-write-path.md`](revision-write-path.md) §R3.1. |
|
||||
| R3.2 | `AddHistoricalValuesAsync` | batched begin→values→end | 🟡 unblocked architecturally; needs R3.1's two live decode loops (OpenStorageConnection handshake + `btInput` serializer) then a real `bCommit=true` write/read-back |
|
||||
| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from `AddS2` cache wall) | ✅ **distinct on gRPC** — Begin succeeded against a real write-enabled session (the WCF/native cache gate does not apply here) |
|
||||
|
||||
**Acceptance:** historical points inserted and read back. **WCF path closed (D2);** would require
|
||||
the gRPC write path (live 2023 R2 server + capture) to reopen.
|
||||
**Acceptance:** historical points inserted and read back. **WCF path closed (D2).** gRPC path:
|
||||
**transaction lifecycle proven (Begin/End live) + full sequence mapped**; the remaining insert is a
|
||||
focused follow-up — reproduce `StorageService.OpenStorageConnection` (+ `RegisterTags`), then decode
|
||||
the `btInput` VTQ buffer, each a live-production probe loop.
|
||||
|
||||
---
|
||||
|
||||
@@ -301,11 +315,13 @@ event-send). M3/M4 as demand dictates.
|
||||
|
||||
> **Status 2026-06-21:** sprints 1 + 2 are **complete** (M0 gRPC parity, the reachable M1 surface,
|
||||
> and M2 event-send all shipped + live-verified; remaining M1 items are evidence-bounded-out). The
|
||||
> reachable surface on the **available 2020 WCF infrastructure is exhausted** — every remaining
|
||||
> roadmap item is now either (a) blocked by the storage-engine-pipe architecture (**M3-WCF**, R4.2),
|
||||
> (b) **gRPC/2023R2-only** and needs the live 2023 R2 server for a native capture (R1.3 timezone,
|
||||
> R1.4 EventStorageMode, M3/revisions over gRPC), or (c) a HARD deferred subsystem (M4). No further
|
||||
> work lands without one of: a live-2023R2 capture session, or a customer-demand trigger.
|
||||
> reachable surface on the **available 2020 WCF infrastructure is exhausted**. **M3 update
|
||||
> (2026-06-21):** with the live 2023 R2 server, the **M3 non-streamed write transaction is now
|
||||
> proven reachable over gRPC** — `TransactionService.AddNonStreamValuesBegin/End` round-trips live
|
||||
> (the D2 storage-engine-pipe wall is WCF-only). The remaining M3 work is bounded and concrete:
|
||||
> capture the `AddNonStreamValues` `btInput` VTQ buffer → golden-tested serializer → real
|
||||
> commit+read-back → public `AddHistoricalValuesAsync`. The other levers are unchanged: R4.2 revision
|
||||
> *edits* stay pipe-only even on gRPC, and M4 (SF / redundancy) is a HARD deferred subsystem.
|
||||
|
||||
## One-glance status
|
||||
|
||||
@@ -314,5 +330,5 @@ event-send). M3/M4 as demand dictates.
|
||||
| M0 gRPC parity + capture tooling | foundation | M | unblocks everything, Windows-free | ✅ **done** |
|
||||
| M1 cheap surface | TRIVIAL/BOUNDED | M–L | most remaining read/config | ✅ **done** (reachable surface; rest bounded out) |
|
||||
| M2 event send | CAPTURE | S–M | headline write capability | ✅ **done** |
|
||||
| M3 historical writes | BOUNDED | M | backfill | ⛔ WCF blocked (D2); gRPC = on-demand + live 2023R2 |
|
||||
| M3 historical writes | BOUNDED | M | backfill | 🟡 **gRPC Begin/End live-verified + full sequence mapped (2026-06-21)**; WCF blocked (D2). Follow-up: OpenStorageConnection handshake + `btInput` decode → commit+read-back |
|
||||
| M4 SF / revisions / redundancy | HARD | L×N | parity completeness | defer (R4.2 = same pipe wall) |
|
||||
|
||||
@@ -1,6 +1,128 @@
|
||||
# Plan: Revision-Write Path (`AddRevisionValuesBegin/Value/End`)
|
||||
|
||||
Status: **ARCHITECTURALLY BLOCKED — verified 2026-05-05.** Same root
|
||||
Status: **WCF: ARCHITECTURALLY BLOCKED (verified 2026-05-05).** **gRPC (2023 R2): the
|
||||
non-streamed-original transaction is REACHABLE — Begin/End round-trip LIVE-VERIFIED 2026-06-21.**
|
||||
Same root cause on WCF as `AddS2`: the `TransactionService` relay needs a pre-existing
|
||||
storage-engine *pipe* session no WCF op can create. The 2023 R2 gRPC front door removes that wall
|
||||
(see the §"2023 R2 gRPC — the wall is gone" section immediately below); the legacy WCF analysis is
|
||||
preserved unchanged after it.
|
||||
|
||||
## 2023 R2 gRPC — the wall is gone (non-streamed original writes), LIVE-VERIFIED 2026-06-21
|
||||
|
||||
The whole D2 WCF blocker was: `ITransactionServiceContract2.AddNonStreamValuesBegin2` returns
|
||||
`04 33 00 00 00` = `UnknownClient (51)` because the server-side Trx relay requires a storage-engine
|
||||
pipe session (`STransactPipeClient2` → `aaStorageEngine.exe`) that no WCF op establishes. On the
|
||||
**2023 R2 gRPC** transport that relay is replaced by a first-class `TransactionService` gRPC
|
||||
service, and the gRPC server is itself the gateway to the storage engine — so the client passes the
|
||||
**HistoryService Open2 storage-session GUID** straight in as `strHandle` and the transaction opens.
|
||||
|
||||
**Live probe (`grpc-revision-probe` CLI command / `HistorianGrpcRevisionProbe`):** against the real
|
||||
2023 R2 server (History iface 12), over a **write-enabled** (`0x401`) gRPC session —
|
||||
|
||||
| step | result |
|
||||
|---|---|
|
||||
| `HistoryService.OpenConnection` (write-enabled `0x401`) | ✅ `OpenSucceeded`, client handle + storage GUID returned |
|
||||
| `TransactionService.GetTransactionInterfaceVersion` | ✅ error 0, **version 2** |
|
||||
| `TransactionService.AddNonStreamValuesBegin(strHandle = storage GUID **UPPERCASE**)` | ✅ **`BeginSucceeded`** — returns a real `strTransactionId` (e.g. `…-FE0A-4822-…`) on the **first** handle format tried |
|
||||
| `TransactionService.AddNonStreamValuesEnd(handle, txId, bCommit=**false**)` | ✅ `EndDiscardSucceeded` — transaction discarded, **no data written** |
|
||||
|
||||
So the answer to the roadmap's open M3-over-gRPC question ("does the 2023 R2 gRPC front door expose
|
||||
a non-streamed write that bypasses the legacy storage-engine pipe?") is **YES** — Begin/End is
|
||||
reachable from the pure-managed SDK with no pipe, no native wrapper. The probe is committed as the
|
||||
`grpc-revision-probe` CLI command + the gated test
|
||||
`HistorianGrpcIntegrationTests.NonStreamedWriteTransaction_OverGrpc_BeginsAndDiscards`; re-run any
|
||||
time to confirm the path is still open.
|
||||
|
||||
### Decompile basis (handle + op group)
|
||||
|
||||
`Archestra.Historian.GrpcClient.GrpcHistoryClient` drives the identical three-phase sequence
|
||||
(`AddNonStreamValuesBegin(strHandle) → strTransactionId`; `AddNonStreamValues(strHandle,
|
||||
strTransactionId, btInput)`; `AddNonStreamValuesEnd(strHandle, strTransactionId, bCommit)`), passing
|
||||
the Open2 session GUID as `strHandle`. `btInput` is the **same opaque native VTQ buffer** the 2020
|
||||
path uses. Proto: `src/AVEVA.Historian.Client/Grpc/Protos/TransactionService.proto`.
|
||||
|
||||
### What is proven vs. what remains (do NOT ship yet)
|
||||
|
||||
- ✅ **Proven:** the transaction lifecycle (Begin → End/rollback) is reachable over gRPC. The D2
|
||||
architectural wall is specific to the WCF transport.
|
||||
- ⛔ **Not yet captured:** the `AddNonStreamValues` **`btInput` VTQ buffer byte layout**. Per project
|
||||
discipline ("never guess wire bytes; capture first") no value-commit is implemented. The next step
|
||||
to actually *ship* M3 (`AddHistoricalValuesAsync`) is to capture the native gRPC `AddNonStreamValues`
|
||||
`btInput` (or decode the `GrpcHistoryClient` serializer), build a golden-tested serializer, then do a
|
||||
real `bCommit=true` write + SQL read-back against a sandbox tag created by `EnsureTagAsync`.
|
||||
- 🔒 **Scope:** this is **non-streamed ORIGINAL backfill** (`HistorianDataCategory.NonStreamedOriginal`
|
||||
→ `TransactionService.AddNonStreamValues*`). **Revision EDITS** (`AddRevisionValue(s)` /
|
||||
`RevisionInsert*`, the R4.2 path) are NOT on the gRPC contract even in 2023 R2 — the capability
|
||||
matrix confirms they still ride the storage-engine pipe. The gRPC unlock here is original backfill,
|
||||
not after-the-fact edits.
|
||||
|
||||
### R3.1 decode probe (2026-06-21): `AddNonStreamValues` reaches the server-side storage-engine console pipe
|
||||
|
||||
The `btInput` VTQ buffer is assembled in native C++ (`SendNonStreamedValues(batchID)` → a vtable
|
||||
call after values are pooled via native `AddNonStreamedValueAsync(&HISTORIAN_VALUE2)`) and is **not
|
||||
visible in any decompile** — only the 44-byte packed `HISTORIAN_VALUE2` struct is (TagKey@0,
|
||||
FILETIME@4, OpcQuality@20, Type@24=7 numeric, value@33, bVersioned@41, VersionStatus@42). So the
|
||||
framing was probed empirically against the live server with `grpc-nonstream-decode` (every
|
||||
transaction `bCommit=false` → rolled back, nothing written; tag key from `SysTimeSec`).
|
||||
|
||||
**Result — the failure is NOT a buffer-format problem:** six different framings (44–54 bytes:
|
||||
count-prefixed packed struct, struct-only, version+count, OS-wrapped) all returned the **identical**
|
||||
`AddNonStreamValues` error, while an empty buffer returned a *different* error (`04 01 00 00 00`,
|
||||
InvalidParameter). The shared error is a nested `SError` whose detail strings are decisive:
|
||||
|
||||
```
|
||||
aahClientAccessPoint::CHistStorageConnection::StoreNonStreamValues::StoreNonStreamValues
|
||||
\\.\pipe\aahStorageEngine\console,sid(<server storage-engine session GUID>)
|
||||
```
|
||||
|
||||
So non-empty buffers get **past parameter validation into `StoreNonStreamValues`**, which routes to
|
||||
the **`aahStorageEngine` console named pipe** server-side (the same storage engine as D2 — but the
|
||||
gRPC *server* now holds the pipe, not the client). Because the error is identical across every
|
||||
framing, the blocker is **not** the `btInput` layout — it is a **missing storage-engine console
|
||||
session / tag-registration precondition** for the connection.
|
||||
|
||||
**Required call sequence (mapped from the 2023 R2 decompile, corroborates the error above):** the
|
||||
missing precondition is **`StorageService.OpenStorageConnection`** — it creates exactly the
|
||||
`\\.\pipe\aahStorageEngine\console,sid(...)` console session named in the failure. The native
|
||||
non-streamed write path is:
|
||||
|
||||
```
|
||||
HistoryService.OpenConnection (✅ have it — the Open2 handshake)
|
||||
→ StorageService.OpenStorageConnection (⛔ MISSING — opens the console sid session; SEPARATE
|
||||
storage session, returns its own uint handle + new GUID)
|
||||
→ StorageService.RegisterTags (register the tag→storage mapping for the session)
|
||||
→ TransactionService.AddNonStreamValuesBegin (✅ works)
|
||||
→ TransactionService.AddNonStreamValues(btInput) (⛔ currently fails here — no console session yet)
|
||||
→ TransactionService.AddNonStreamValuesEnd(bCommit=true)
|
||||
→ StorageService.CloseStorageConnection / HistoryService.CloseConnection
|
||||
```
|
||||
|
||||
`OpenStorageConnection` (gRPC `StorageService`) takes 12 args — HostName, EnginePath
|
||||
(`\\.\pipe\aahStorageEngine\console`), FreeDiskSpace, ProcessName, ProcessId, UserName, Password(+len),
|
||||
ClientType, ClientVersion, ConnectionMode, ConnectionTimeout, StorageSessionId(in/out) — and returns a
|
||||
**new** storage `Handle` (uint) + a **new** StorageSessionId GUID (distinct from the Open2 GUID).
|
||||
|
||||
**Two hard parts remain, each a separate live-production decode loop (no static shortcut):**
|
||||
1. **Reproduce the `OpenStorageConnection` handshake** — several of the 12 args are only inferable from
|
||||
the decompile (ProcessId, ClientType/Version, ConnectionMode, the password-bytes framing), so the
|
||||
exact values must be confirmed against the live server.
|
||||
2. **Decode the `AddNonStreamValues` `btInput`** — built in C++ (`SendNonStreamedValues` vtable call),
|
||||
**absent from every decompile**; only the 44-byte packed `HISTORIAN_VALUE2` struct is known. Must be
|
||||
decoded empirically once the console session exists (the batch-1 identical-error result could not
|
||||
distinguish framings precisely *because* there was no session — with a session, framings should
|
||||
diverge and the correct one becomes findable).
|
||||
|
||||
Raw decode artifact: `artifacts/reverse-engineering/grpc-nonstream-decode/batch1-decode.txt`
|
||||
(gitignored). Probe command: `grpc-nonstream-decode`; driver:
|
||||
`HistorianGrpcRevisionProbe.ProbeNonStreamedBuffersAsync` (candidate guess-bytes live in the RE tool,
|
||||
not `src/`). **Status: M3 transaction lifecycle proven; full insert blocked on the
|
||||
OpenStorageConnection handshake + btInput decode — a focused follow-up, each step a live probe.**
|
||||
|
||||
---
|
||||
|
||||
## Legacy WCF analysis (preserved — still accurate for the 2020 WCF transport)
|
||||
|
||||
Status (WCF only): **ARCHITECTURALLY BLOCKED — verified 2026-05-05.** Same root
|
||||
cause as `AddS2`: client-side cache rejects values for tags that
|
||||
weren't registered through a configured IO server / Application Server
|
||||
pipeline. Documented below; implementation deferred until / unless that
|
||||
|
||||
@@ -40,10 +40,17 @@ internal static class HistorianGrpcHandshake
|
||||
CancellationToken cancellationToken)
|
||||
=> OpenSession(connection, options, cancellationToken).ClientHandle;
|
||||
|
||||
/// <param name="connectionMode">
|
||||
/// The native Open2 connection mode. Defaults to read-only (<c>0x402</c>); pass
|
||||
/// <see cref="HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode"/>
|
||||
/// (<c>0x401</c>) for write-enabled sessions (e.g. the non-streamed/revision Transaction path,
|
||||
/// which the read-only mode silently rejects with err 132 OperationNotEnabled).
|
||||
/// </param>
|
||||
public static Session OpenSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
uint connectionMode = HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode)
|
||||
{
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||
|
||||
@@ -73,7 +80,7 @@ internal static class HistorianGrpcHandshake
|
||||
cancellationToken);
|
||||
|
||||
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
|
||||
options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
|
||||
options.Host, contextKey, connectionMode);
|
||||
|
||||
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
||||
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
using Google.Protobuf;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Live probe for the M3 (historical / non-streamed original-value write) path over the 2023 R2
|
||||
/// gRPC front door. On 2020 WCF this op group is architecturally blocked: the
|
||||
/// <c>ITransactionServiceContract2.AddNonStreamValuesBegin2</c> relay returns
|
||||
/// <c>UnknownClient (51)</c> because it requires a pre-existing storage-engine pipe session
|
||||
/// (<c>STransactPipeClient2</c> → <c>aaStorageEngine.exe</c>) that no WCF op can establish — see
|
||||
/// <c>docs/plans/revision-write-path.md</c> (the D2 finding).
|
||||
///
|
||||
/// The 2023 R2 decompile shows the native gRPC client driving the SAME op group over
|
||||
/// <c>TransactionService.AddNonStreamValuesBegin/AddNonStreamValues/AddNonStreamValuesEnd</c> and
|
||||
/// passing the HistoryService Open2 session GUID directly as <c>strHandle</c> — i.e. the gRPC
|
||||
/// server is the gateway to the storage engine, so the client never touches the legacy pipe. This
|
||||
/// probe tests whether the SDK's pure-managed handshake can reproduce that: it opens a
|
||||
/// write-enabled session and calls <c>AddNonStreamValuesBegin</c>, surfacing whatever the server
|
||||
/// returns. It writes NO data — if Begin succeeds it immediately calls <c>AddNonStreamValuesEnd</c>
|
||||
/// with <c>bCommit=false</c> to discard the transaction.
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcRevisionProbe
|
||||
{
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianGrpcRevisionProbe(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public Task<HistorianGrpcRevisionProbeResult> ProbeBeginAsync(CancellationToken cancellationToken)
|
||||
=> Task.Run(() => ProbeBegin(cancellationToken), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Empirical-decode driver for the <c>AddNonStreamValues</c> <c>btInput</c> buffer (R3.1). For
|
||||
/// each candidate buffer it opens a fresh transaction, sends the buffer, records the server's
|
||||
/// accept/reject, and ALWAYS ends with <c>bCommit=false</c> (rollback) so nothing persists.
|
||||
/// The candidate buffers are supplied by the caller (the RE tool) — this method does not invent
|
||||
/// wire bytes, it just reports what the live server says about each. Safe against a real tag key
|
||||
/// because every transaction is discarded.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<HistorianGrpcNonStreamedCandidateResult>> ProbeNonStreamedBuffersAsync(
|
||||
IReadOnlyList<(string Label, byte[] Buffer)> candidates,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.Run<IReadOnlyList<HistorianGrpcNonStreamedCandidateResult>>(
|
||||
() => ProbeNonStreamedBuffers(candidates, cancellationToken), cancellationToken);
|
||||
|
||||
private List<HistorianGrpcNonStreamedCandidateResult> ProbeNonStreamedBuffers(
|
||||
IReadOnlyList<(string Label, byte[] Buffer)> candidates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<HistorianGrpcNonStreamedCandidateResult>();
|
||||
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
|
||||
connection, _options, cancellationToken,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode);
|
||||
|
||||
var transactionClient = new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel);
|
||||
string handle = session.StringHandle;
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
|
||||
// Prime the Transaction service session table.
|
||||
try
|
||||
{
|
||||
transactionClient.GetTransactionInterfaceVersion(
|
||||
new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
}
|
||||
catch { /* version prime is best-effort */ }
|
||||
|
||||
foreach ((string label, byte[] buffer) in candidates)
|
||||
{
|
||||
var candidate = new HistorianGrpcNonStreamedCandidateResult { Label = label, BufferLength = buffer.Length };
|
||||
string? transactionId = null;
|
||||
try
|
||||
{
|
||||
GrpcTransaction.AddNonStreamValuesBeginResponse begin = transactionClient.AddNonStreamValuesBegin(
|
||||
new GrpcTransaction.AddNonStreamValuesBeginRequest { StrHandle = handle },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
if (!(begin.Status?.BSuccess ?? false) || string.IsNullOrEmpty(begin.StrTransactionId))
|
||||
{
|
||||
candidate.BeginFailed = true;
|
||||
byte[] be = begin.Status?.BtError?.ToByteArray() ?? [];
|
||||
candidate.AddErrorHex = be.Length == 0 ? null : Convert.ToHexString(be);
|
||||
results.Add(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
transactionId = begin.StrTransactionId;
|
||||
|
||||
GrpcTransaction.AddNonStreamValuesResponse add = transactionClient.AddNonStreamValues(
|
||||
new GrpcTransaction.AddNonStreamValuesRequest
|
||||
{
|
||||
StrHandle = handle,
|
||||
StrTransactionId = transactionId,
|
||||
BtInput = ByteString.CopyFrom(buffer),
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
candidate.AddSucceeded = add.Status?.BSuccess ?? false;
|
||||
byte[] ae = add.Status?.BtError?.ToByteArray() ?? [];
|
||||
candidate.AddErrorHex = ae.Length == 0 ? null : Convert.ToHexString(ae);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
candidate.Exception = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always roll back — bCommit=false writes nothing.
|
||||
if (!string.IsNullOrEmpty(transactionId))
|
||||
{
|
||||
try
|
||||
{
|
||||
transactionClient.AddNonStreamValuesEnd(
|
||||
new GrpcTransaction.AddNonStreamValuesEndRequest
|
||||
{
|
||||
StrHandle = handle,
|
||||
StrTransactionId = transactionId,
|
||||
BCommit = false,
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
}
|
||||
catch { /* rollback best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(candidate);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private HistorianGrpcRevisionProbeResult ProbeBegin(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new HistorianGrpcRevisionProbeResult();
|
||||
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
|
||||
connection,
|
||||
_options,
|
||||
cancellationToken,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode);
|
||||
|
||||
result.OpenSucceeded = true;
|
||||
result.ClientHandle = session.ClientHandle;
|
||||
result.StorageSessionId = session.StorageSessionId;
|
||||
|
||||
var transactionClient = new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
|
||||
// Register the client with the Transaction service's session table (matches the
|
||||
// cross-service GetV priming the WCF write path uses).
|
||||
try
|
||||
{
|
||||
GrpcTransaction.GetTransactionInterfaceVersionResponse version = transactionClient.GetTransactionInterfaceVersion(
|
||||
new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
result.TrxInterfaceVersionError = version.Error;
|
||||
result.TrxInterfaceVersion = version.Version;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.TrxInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
// The decompiled native client passes the Open2 storage-session GUID (string) as strHandle.
|
||||
// Try that first (uppercase "D" form, as the other string-handle ops require), then a couple
|
||||
// of fallbacks mirroring the WCF probe, so a wrong-format rejection is distinguishable from a
|
||||
// genuine server-side block.
|
||||
foreach ((string label, string handle) in new[]
|
||||
{
|
||||
("storageSessionId-upper", session.StringHandle),
|
||||
("storageSessionId-lower", session.StorageSessionId.ToString("D")),
|
||||
("clientHandle-as-string", session.ClientHandle.ToString()),
|
||||
})
|
||||
{
|
||||
var attempt = new HistorianGrpcRevisionBeginAttempt { HandleLabel = label, HandleSent = handle };
|
||||
try
|
||||
{
|
||||
GrpcTransaction.AddNonStreamValuesBeginResponse begin = transactionClient.AddNonStreamValuesBegin(
|
||||
new GrpcTransaction.AddNonStreamValuesBeginRequest { StrHandle = handle },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
attempt.Succeeded = begin.Status?.BSuccess ?? false;
|
||||
attempt.TransactionId = begin.StrTransactionId;
|
||||
byte[] error = begin.Status?.BtError?.ToByteArray() ?? [];
|
||||
attempt.ErrorHex = error.Length == 0 ? null : Convert.ToHexString(error);
|
||||
result.BeginAttempts.Add(attempt);
|
||||
|
||||
if (attempt.Succeeded && !string.IsNullOrEmpty(attempt.TransactionId))
|
||||
{
|
||||
result.BeginSucceeded = true;
|
||||
result.BeginTransactionId = attempt.TransactionId;
|
||||
|
||||
// Discard immediately — bCommit=false writes nothing. This keeps the probe
|
||||
// read-only against the live (production) server.
|
||||
try
|
||||
{
|
||||
GrpcTransaction.AddNonStreamValuesEndResponse end = transactionClient.AddNonStreamValuesEnd(
|
||||
new GrpcTransaction.AddNonStreamValuesEndRequest
|
||||
{
|
||||
StrHandle = handle,
|
||||
StrTransactionId = attempt.TransactionId,
|
||||
BCommit = false,
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
result.EndDiscardSucceeded = end.Status?.BSuccess ?? false;
|
||||
byte[] endError = end.Status?.BtError?.ToByteArray() ?? [];
|
||||
result.EndDiscardErrorHex = endError.Length == 0 ? null : Convert.ToHexString(endError);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.EndDiscardException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attempt.Exception = $"{ex.GetType().Name}: {ex.Message}";
|
||||
result.BeginAttempts.Add(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcRevisionProbeResult
|
||||
{
|
||||
public bool OpenSucceeded { get; set; }
|
||||
public uint ClientHandle { get; set; }
|
||||
public Guid StorageSessionId { get; set; }
|
||||
public uint? TrxInterfaceVersionError { get; set; }
|
||||
public uint? TrxInterfaceVersion { get; set; }
|
||||
public string? TrxInterfaceVersionException { get; set; }
|
||||
public bool BeginSucceeded { get; set; }
|
||||
public string? BeginTransactionId { get; set; }
|
||||
public bool EndDiscardSucceeded { get; set; }
|
||||
public string? EndDiscardErrorHex { get; set; }
|
||||
public string? EndDiscardException { get; set; }
|
||||
public List<HistorianGrpcRevisionBeginAttempt> BeginAttempts { get; } = new();
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcRevisionBeginAttempt
|
||||
{
|
||||
public string HandleLabel { get; set; } = "";
|
||||
public string HandleSent { get; set; } = "";
|
||||
public bool Succeeded { get; set; }
|
||||
public string? TransactionId { get; set; }
|
||||
public string? ErrorHex { get; set; }
|
||||
public string? Exception { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcNonStreamedCandidateResult
|
||||
{
|
||||
public string Label { get; set; } = "";
|
||||
public int BufferLength { get; set; }
|
||||
public bool BeginFailed { get; set; }
|
||||
public bool AddSucceeded { get; set; }
|
||||
public string? AddErrorHex { get; set; }
|
||||
public string? Exception { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using AVEVA.Historian.Client.Grpc;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.Tests;
|
||||
@@ -123,6 +124,30 @@ public sealed class HistorianGrpcIntegrationTests
|
||||
Assert.All(names, n => Assert.StartsWith("Sys", n, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonStreamedWriteTransaction_OverGrpc_BeginsAndDiscards()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// M3 reachability probe: on 2020 WCF this op group is walled (TransactionService relay
|
||||
// returns UnknownClient(51) — the storage-engine-pipe requirement, see
|
||||
// docs/plans/revision-write-path.md). On the 2023 R2 gRPC front door the native client
|
||||
// passes the Open2 storage-session GUID straight to TransactionService and it works.
|
||||
// This asserts the wall is gone: a write-enabled session opens and AddNonStreamValuesBegin
|
||||
// returns a transaction id, which we immediately End with bCommit=false (writes nothing).
|
||||
var probe = new HistorianGrpcRevisionProbe(BuildOptions(host));
|
||||
HistorianGrpcRevisionProbeResult result = await probe.ProbeBeginAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.OpenSucceeded);
|
||||
Assert.True(result.BeginSucceeded, "AddNonStreamValuesBegin should return a transaction id over gRPC.");
|
||||
Assert.False(string.IsNullOrEmpty(result.BeginTransactionId));
|
||||
Assert.True(result.EndDiscardSucceeded, "AddNonStreamValuesEnd(bCommit:false) should discard cleanly.");
|
||||
}
|
||||
|
||||
private static HistorianClientOptions BuildOptions(string host)
|
||||
{
|
||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||
|
||||
@@ -13,6 +13,8 @@ using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AVEVA.Historian.Client;
|
||||
using AVEVA.Historian.Client.Grpc;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using AVEVA.Historian.Client.Wcf.Contracts;
|
||||
using AVEVA.Historian.ReverseEngineering.Capture;
|
||||
@@ -72,6 +74,8 @@ try
|
||||
"wcf-register-event-tag" => RegisterEventTagAndStartQuery(args),
|
||||
"wcf-add-event-tag" => AddEventTagAndStartQuery(args),
|
||||
"capture-tag-info" => CaptureTagInfo(args),
|
||||
"grpc-revision-probe" => ProbeGrpcRevision(args),
|
||||
"grpc-nonstream-decode" => ProbeGrpcNonStreamedDecode(args),
|
||||
_ => UnknownCommand(args[0])
|
||||
};
|
||||
}
|
||||
@@ -3209,6 +3213,168 @@ static int WriteMarker(string[] args)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int ProbeGrpcRevision(string[] args)
|
||||
{
|
||||
// Usage: grpc-revision-probe <host> [port] [--tls] [--dnsid <name>] [--insecure-cert]
|
||||
// Reads HISTORIAN_USER / HISTORIAN_PASSWORD from the environment for explicit creds;
|
||||
// falls back to IntegratedSecurity when unset.
|
||||
string host = args.Length > 1 ? args[1] : "localhost";
|
||||
int port = args.Length > 2 && int.TryParse(args[2], out int parsedPort)
|
||||
? parsedPort
|
||||
: HistorianClientOptions.DefaultGrpcPort;
|
||||
bool tls = HasOption(args, "--tls");
|
||||
string? dnsId = GetOption(args, "--dnsid");
|
||||
|
||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||
bool explicitCreds = !string.IsNullOrEmpty(user);
|
||||
|
||||
var options = new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Transport = HistorianTransport.RemoteGrpc,
|
||||
GrpcUseTls = tls,
|
||||
AllowUntrustedServerCertificate = tls,
|
||||
ServerDnsIdentity = dnsId,
|
||||
IntegratedSecurity = !explicitCreds,
|
||||
UserName = user ?? string.Empty,
|
||||
Password = password ?? string.Empty,
|
||||
};
|
||||
|
||||
var probe = new HistorianGrpcRevisionProbe(options);
|
||||
HistorianGrpcRevisionProbeResult result = probe.ProbeBeginAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, CreateJsonOptions()));
|
||||
return result.BeginSucceeded ? 0 : 2;
|
||||
}
|
||||
|
||||
static int ProbeGrpcNonStreamedDecode(string[] args)
|
||||
{
|
||||
// Usage: grpc-nonstream-decode <host> [port] [--tls] [--dnsid <name>] [--tag <name>]
|
||||
// Empirically decodes the AddNonStreamValues btInput framing: looks up a real tag key, then
|
||||
// sends evidence-based candidate buffers over a live write-enabled gRPC transaction and reports
|
||||
// the server's accept/reject for each. Every transaction is rolled back (bCommit=false) — no
|
||||
// data is written. Candidates are derived from the decompiled 44-byte packed HISTORIAN_VALUE2.
|
||||
string host = args.Length > 1 ? args[1] : "localhost";
|
||||
int port = args.Length > 2 && int.TryParse(args[2], out int parsedPort)
|
||||
? parsedPort
|
||||
: HistorianClientOptions.DefaultGrpcPort;
|
||||
bool tls = HasOption(args, "--tls");
|
||||
string? dnsId = GetOption(args, "--dnsid");
|
||||
string tagName = GetOption(args, "--tag") ?? "SysTimeSec";
|
||||
|
||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||
bool explicitCreds = !string.IsNullOrEmpty(user);
|
||||
|
||||
var options = new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Transport = HistorianTransport.RemoteGrpc,
|
||||
GrpcUseTls = tls,
|
||||
AllowUntrustedServerCertificate = tls,
|
||||
ServerDnsIdentity = dnsId,
|
||||
IntegratedSecurity = !explicitCreds,
|
||||
UserName = user ?? string.Empty,
|
||||
Password = password ?? string.Empty,
|
||||
};
|
||||
|
||||
var client = new HistorianClient(options);
|
||||
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
|
||||
client.GetTagMetadataAsync(tagName, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (metadata is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Tag '{tagName}' not found on the server; cannot resolve a tag key.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (metadata.Key is not uint tagKey)
|
||||
{
|
||||
Console.Error.WriteLine($"Tag '{tagName}' metadata has no tag key.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// A historical timestamp ~2 hours in the past (non-streamed = backfill of past data).
|
||||
long fileTime = DateTime.UtcNow.AddHours(-2).ToFileTimeUtc();
|
||||
const short opcQualityGood = 192;
|
||||
double sampleValue = 123.0;
|
||||
|
||||
byte[] BuildHistorianValue2(byte[] value8)
|
||||
{
|
||||
byte[] v = new byte[44];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(v.AsSpan(0, 4), tagKey);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(v.AsSpan(4, 8), fileTime);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(v.AsSpan(20, 2), opcQualityGood);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(v.AsSpan(24, 4), 7); // Type = numeric
|
||||
// @28 u32 MaxLength = 0 (numeric); @32 ApplyScaling = 0
|
||||
value8.AsSpan(0, 8).CopyTo(v.AsSpan(33, 8)); // @33 value (8 bytes, unaligned)
|
||||
// @41 bVersioned = 0; @42 VersionStatus = 0
|
||||
return v;
|
||||
}
|
||||
|
||||
byte[] valueAsDoubleBits = BitConverter.GetBytes(sampleValue); // 8 bytes, double
|
||||
byte[] valueAsFloatLow = new byte[8];
|
||||
BitConverter.GetBytes((float)sampleValue).CopyTo(valueAsFloatLow, 0); // float in low 4
|
||||
|
||||
byte[] structDouble = BuildHistorianValue2(valueAsDoubleBits);
|
||||
byte[] structFloat = BuildHistorianValue2(valueAsFloatLow);
|
||||
|
||||
byte[] WithCountU32(byte[] body, uint count)
|
||||
{
|
||||
byte[] b = new byte[4 + body.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0, 4), count);
|
||||
body.CopyTo(b.AsSpan(4));
|
||||
return b;
|
||||
}
|
||||
|
||||
byte[] WithVersionAndCount(byte[] body, ushort version, uint count)
|
||||
{
|
||||
byte[] b = new byte[2 + 4 + body.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(b.AsSpan(0, 2), version);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(2, 4), count);
|
||||
body.CopyTo(b.AsSpan(6));
|
||||
return b;
|
||||
}
|
||||
|
||||
// "OS"-style storage-sample header (as AddS2 uses), wrapping the packed struct as the blob.
|
||||
byte[] OsWrap(byte[] body)
|
||||
{
|
||||
byte[] b = new byte[10 + body.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(b.AsSpan(0, 2), 0x534F); // "OS"
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(b.AsSpan(2, 2), 1); // sampleCount
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4, 4), (uint)(body.Length + 1));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(b.AsSpan(8, 2), (ushort)body.Length);
|
||||
body.CopyTo(b.AsSpan(10));
|
||||
return b;
|
||||
}
|
||||
|
||||
var candidates = new List<(string Label, byte[] Buffer)>
|
||||
{
|
||||
("count(u32)+struct[double@33]", WithCountU32(structDouble, 1)),
|
||||
("count(u32)+struct[float@33]", WithCountU32(structFloat, 1)),
|
||||
("struct-only[double@33]", structDouble),
|
||||
("ver(u16=0)+count(u32)+struct[double]", WithVersionAndCount(structDouble, 0, 1)),
|
||||
("ver(u16=2)+count(u32)+struct[double]", WithVersionAndCount(structDouble, 2, 1)),
|
||||
("OS-wrap(struct[double])", OsWrap(structDouble)),
|
||||
("empty", Array.Empty<byte>()),
|
||||
};
|
||||
|
||||
var probe = new HistorianGrpcRevisionProbe(options);
|
||||
IReadOnlyList<HistorianGrpcNonStreamedCandidateResult> results =
|
||||
probe.ProbeNonStreamedBuffersAsync(candidates, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
Tag = tagName,
|
||||
TagKey = tagKey,
|
||||
FileTimeUtc = DateTime.FromFileTimeUtc(fileTime).ToString("o"),
|
||||
Candidates = results,
|
||||
}, CreateJsonOptions()));
|
||||
|
||||
return results.Any(static r => r.AddSucceeded) ? 0 : 2;
|
||||
}
|
||||
|
||||
static int ProbeWcf(string[] args)
|
||||
{
|
||||
string host = args.Length > 1 ? args[1] : "localhost";
|
||||
|
||||
Reference in New Issue
Block a user