R1.11 DelTep capture + R1.3/R1.4/R1.12/R1.13 bounded out

DelTep (extended-property delete) — wire format captured + serializer
golden-proven, but live delete is server-blocked and NOT exposed publicly:
- Captured the DelTep inBuff via a cross-session trick (harness add-tep gains
  --tep-skip-add + read-for-sync before --tep-delete; Capture-DeleteTagExtended
  Properties.ps1 / decode-del-tep-capture.py). Layout = same group framing as
  AddTEx but property-name-only (no 0x43 value) + 0x00 group trailer.
- SerializeDeleteRequest + 4 golden tests pin the server-accepted buffer.
- A decisive experiment shows SDK-added properties ARE deletable (the native
  client read-syncs and deletes one), so SDK-add is complete; the SDK's own
  DelTep is rejected by CHistStorage::DeleteTagExtendedProperties even with
  byte-identical inBuff, matching mode/handle, GetTgByNm+GetTepByNm prime, open
  channel, and 60s retries. Root cause: the native multiplexes services over one
  connection (per-connection working set); the SDK's per-service WCF channels
  don't reproduce it. Kept as documented-but-blocked internal orchestrator path;
  no public HistorianClient delete API.

Bounded out with evidence (no code; docs + roadmap + probe):
- R1.12 localized-property write — no op on 2020 (mirror of R1.6); no
  *LocalizedPropert*/TagLocalized* symbol in any current/*.dll.
- R1.13 non-analog tag create — GATED; native AddTag rejects every non-analog
  type client-side (ValidationFailed, before any WCF op): SingleByteString,
  DoubleByteString, Int1 all fail, Float works. No Discrete type in the native
  enum, no TagType setter. No wire request to capture.
- R1.3 timezone + R1.4 EventStorageMode — re-confirmed 2023R2/gRPC-only from
  the Runtime DB schema (no timezone param, no EventStorageMode anywhere) and a
  parameter-op probe (GetSystemParameter + GETRP return null/throw for every
  candidate; only HistorianVersion works).

238 unit tests pass; full solution builds with 0 warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-21 11:26:21 -04:00
parent 08b950caee
commit c1b1b3d23b
14 changed files with 732 additions and 32 deletions
+5 -5
View File
@@ -92,7 +92,7 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
|---|---|---|---|
| R1.1 | `ExecuteSqlCommandAsync` | `Retrieval.ExecuteSqlCommand` (`ExeC`+`GetR`) | ✅ **REACHABLE (2026-06-20, live-probed).** The earlier "code 51 blocked" verdict was a handle-**format** bug — `ExeC` succeeds with the Open2 storage GUID sent **uppercase** (`ToString("D").ToUpperInvariant()`). Chain: `Retr.GetV` prime → `ExeC(handle, sqlString, option=0, ref queryHandle)``GetR(handle, queryHandle, ref sequence)` returns the result as a **BinaryFormatter-serialized .NET DataTable**. Proven by `StringHandleProbeDiagnosticTests` + `scripts/Capture-ExecSql.ps1`. **Public API not yet shipped** — needs a `GetR` continuation loop + a custom BinaryFormatter-stream parser (BinaryFormatter is removed from .NET 10, so a DataTable can't just be deserialized). |
| ~~R1.2~~ | ~~`GetRuntimeParameterAsync`~~ | `Status.GetRuntimeParameter` (`aa/Stat/GETRP`) | ✅ **DONE (2026-06-20), live-verified.** Captured (`scripts/Capture-RuntimeParam.ps1`): GETRP is a **`string`-handle** op (GETHI's shape), but reachable from the managed client using the Open2 storage-session GUID as an **uppercase** string handle (`ToString("D").ToUpperInvariant()`). Returns `HistorianVersion` = `20,0,000,000` live. pRequestBuff = `54 67 01 00` + uint nameCount + per-name(uint charCount + UTF-16); pResponseBuff = version + uint resultCount + CRetVariant(`0x43` VT_BSTR + uint16 len + uint16 charCount + UTF-16). Single string-valued param only (multi-name framing inferred, not captured). Shipped: `HistorianClient.GetRuntimeParameterAsync(name)`; golden `WcfRuntimeParameterProtocolTests`. **Note:** GETRP punching through the string-handle wall with the uppercase storage GUID is a strong lead that GETHI/ExeC may be a handle-*format* issue — see `wcf-string-handle-wall.md` §Update. |
| ~~R1.3~~ | ~~`GetServerTimeZoneAsync`~~ | `Status.GetSystemTimeZoneName` | **gRPC/2023R2-only.** Verified 2026-06-20: over **2020 WCF** this op is a stub (rc=0, empty value) in the `GetServerTime` family — not shippable here. Build+verify only against a live 2023 R2 server. See `docs/reverse-engineering/wcf-status-localhost.md`. |
| ~~R1.3~~ | ~~`GetServerTimeZoneAsync`~~ | `Status.GetSystemTimeZoneName` | **gRPC/2023R2-only — re-confirmed 2026-06-21 from 3 angles.** (1) native `GetSystemTimeZoneName` is a stub (rc=0, empty) in the `GetServerTime` family; (2) Runtime DB has no timezone `SystemParameter` — the zone exists only as per-block `HistoryBlock.TimeZoneOffset`/`wwTimeZone` (DST-specific, SQL-only) + a `TimeZone` lookup table, `StorageShard.TimeZoneId`=NULL; (3) `GetSystemParameter` + `GETRP` return null/throw for every timezone candidate (only `HistorianVersion` works). The sole 2020 route is a SQL read via `ExecuteSqlCommand` (R1.1) — DST-specific, different mechanism. Build the real op only against a live 2023 R2 server. See `docs/reverse-engineering/wcf-status-localhost.md`. |
> ✅ **String-handle "wall" RESOLVED (2026-06-20) — it was a handle-FORMAT bug.** R1.4/R1.5/R1.6
> (and R1.1) take a **`string` GUID handle**; the earlier "code 1/51 blocked" verdict came from
@@ -110,7 +110,7 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
### 1b. Bounded (decode one `bytes` payload; SM each)
| ID | Capability | gRPC op | Payload to decode | Depends |
|---|---|---|---|---|
| R1.4 | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | **REACHABLE (2026-06-20, live-probed)** via the uppercase storage GUID — `GETHI` returns data (`StringHandleProbeDiagnosticTests`). The version-keyed request returns `uint charCount + UTF-16`; the full info struct (incl. `EventStorageMode`@514) needs its own request capture. **Public API not yet shipped.** | uppercase string handle |
| ~~R1.4~~ | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | **BOUNDED OUT on 2020 (re-confirmed 2026-06-21).** GETHI is a **named-value** query reachable via the uppercase storage GUID, but **only `HistorianVersion` resolves** — the full 518-byte `HISTORIAN_INFO` struct (incl. `EventStorageMode`@514) is the 2023R2 HCAL-native/gRPC model. `EventStorageMode` has **no 2020 representation at all**: not a `SystemParameter` (only `EventStorageDuration`/`EventStorageLogPath`), not a DB column, and `GETRP`/`GetSystemParameter` return null/throw for it. The only 2020-reachable field (version) is already shipped via `GetSystemParameterAsync`/`GetRuntimeParameterAsync`, so a struct API would be hollow + misleading. Build the real op only against a live 2023 R2 server. See `docs/reverse-engineering/wcf-status-localhost.md`. | uppercase string handle |
| ~~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`. | — |
@@ -121,9 +121,9 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
| ID | Capability | gRPC op | Payload | Notes |
|---|---|---|---|---|
| R1.10 | `RenameTagsAsync` | History rename op | rename request buffer | `AllowRenameTags` already probed |
| ~~R1.11~~ | Extended-property **write** | `History.AddTagExtendedProperties` (AddTEx) | ✅ **Add DONE (2026-06-21), live-verified.** `AddTagExtendedPropertiesAsync`/`AddTagExtendedPropertyAsync` (write mode, uppercase handle). inBuff = exact inverse of the R1.5 read framing (`uint32 groupCount + 0x01 + compact-ASCII tag + uint32 propCount + per prop[0x02 + compact-ASCII name + 0x43 VT_BSTR value] + 0x01 trailer + 0x00 terminator`); the trailing `0x00` is required or the server throws. Golden `WcfTagExtendedPropertyWriteProtocolTests` + gated live write/read-back test. **Delete (DelTep) deferred** — native client-side sync gate (err 229) blocks capturing its inBuff. See `docs/reverse-engineering/wcf-add-tag-extended-properties.md`. |
| R1.12 | Localized-property **write** | `History.AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` | localized serialize | |
| R1.13 | Non-analog tag create (string/discrete) | `History.EnsureTags` | distinct CTagMetadata variant | ⚠ native AddTag rejected some types — confirm server path first; may be GATED |
| ~~R1.11~~ | Extended-property **write** | `History.AddTagExtendedProperties` (AddTEx) | ✅ **Add DONE (2026-06-21), live-verified.** `AddTagExtendedPropertiesAsync`/`AddTagExtendedPropertyAsync` (write mode, uppercase handle). inBuff = exact inverse of the R1.5 read framing (`uint32 groupCount + 0x01 + compact-ASCII tag + uint32 propCount + per prop[0x02 + compact-ASCII name + 0x43 VT_BSTR value] + 0x01 trailer + 0x00 terminator`); the trailing `0x00` is required or the server throws. Golden `WcfTagExtendedPropertyWriteProtocolTests` + gated live write/read-back test. **Delete (DelTep): wire format CAPTURED + serializer golden-proven (2026-06-21), but live delete is server-blocked and NOT shipped.** Captured via a two-session trick (add in Run A → fresh-session read-sync → delete in Run B, past the native err-229 client gate); inBuff = same group framing as Add but property-name-only and a `0x00` group trailer. A decisive experiment shows SDK-added properties ARE deletable (the native client deletes one), so SDK-add is complete; the SDK's own DelTep is rejected (`SErrorException` in `CHistStorage::DeleteTagExtendedProperties`) despite matching mode/handle/inBuff + GetTgByNm/GetTepByNm prime + open channel + 60s retries. Root cause: the native multiplexes services over ONE connection (per-connection working set), which the SDK's per-service WCF channels don't reproduce — needs transport-level multiplexing. See `docs/reverse-engineering/wcf-add-tag-extended-properties.md` §Delete. |
| ~~R1.12~~ | Localized-property **write** | (no op) | ⛔ **No distinct op on 2020 — closed (mirror of R1.6).** A symbol sweep of `current/*.dll` finds no `AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` / any `*LocalizedPropert*` / `TagLocalized*`; only UI/error-text localization (`GetLocalizedText`/`GetLocalizedMessage`/`LocalizedResourcesDir`). Localized properties are a 2023 R2/gRPC concept. Closed, not throwing. See `docs/reverse-engineering/wcf-tag-extended-properties.md` §R1.12. | 2026-06-21 |
| ~~R1.13~~ | Non-analog tag create (string/discrete) | `History.EnsureTags` | distinct CTagMetadata variant | **GATED — bounded out (2026-06-21, live-probed).** Native `AddTag` rejects every non-analog type **client-side** (`ErrorCode=ValidationFailed` / "Transaction validation failed", before any WCF op): SingleByteString, DoubleByteString, **and Int1** all fail; Float (control) succeeds. The native `HistorianDataType` enum has **no Discrete/Boolean** and no Int8/UInt8 (SDK-only extensions); `HistorianTag` has **no TagType setter** (type is data-type-derived). So no non-analog wire request is ever emitted → nothing to capture/implement. String/discrete create goes via a different subsystem (config editor / SQL), not this client's AddTag. `EnsureTagAsync` stays analog-only. See `docs/reverse-engineering/wcf-non-analog-tag-create.md`. |
**Acceptance:** read + browse + metadata + system/status + property R/W + summaries +
event-filtered reads + rename all live-verified over gRPC.
@@ -47,14 +47,73 @@ instrument capture mangles the final byte with MDAS chunk markers, so the golden
**clean** byte[] the SDK handed the channel (dumped via `AVEVA_HISTORIAN_TEP_DUMP`) — the exact buffer
the live server accepted.
## Delete (DelTep) — deferred
## Delete (DelTep) — wire format captured + serializer proven; live delete server-blocked
`DeleteTagExtendedProperties` (`DelTep`) is **not shipped yet**. The native
`DeleteTagExtendedPropertiesByName(tag, propertyNames, deleteFromServer, out err)` performs a
**client-side sync check** and returns error **229 ("Tag extended property not synchronized with
server")** when deleting a just-added property — so the `DelTep` request never reached the wire and its
inBuff could not be captured. Capturing it needs a property that is already server-synchronized (add it
in one session, then delete in a later one). Left for a follow-up rather than shipping a guessed buffer.
**Status (2026-06-21): the `DelTep` wire format is captured and decoded, the serializer is
golden-verified against a server-accepted buffer, and SDK-added properties are confirmed deletable —
but the SDK's own delete is rejected server-side and is therefore NOT exposed publicly.** This is a
much deeper result than the earlier "couldn't capture the inBuff" deferral.
### Capturing it: the cross-session trick
The native `DeleteTagExtendedPropertiesByName(tag, propertyNames, deleteFromServer, out err)` performs
a **client-side sync check** and returns error **229 ("Tag extended property not synchronized with
server")** when deleting a *just-added* property — so a same-session add→delete never reaches the wire.
`AddTEx` success does **not** mark the local cache entry as server-synchronized; only a *server fetch*
(`GetTepByNm`) does. So the capture (`scripts/Capture-DeleteTagExtendedProperties.ps1`) runs two
separate harness processes against one instrumented DLL:
- **Run A**: `add-tep` creates the sandbox tag and adds the property (now server-synced).
- **Run B**: a fresh process opens a new connection, fetches the property
(`GetTagExtendedPropertiesByName`, which seeds the local cache as synced), then deletes it — so
`DeleteTagExtendedPropertiesByName` passes the client gate and `DelTep` reaches the wire.
### The inBuff — same group framing as Add, names only
```
uint32 groupCount (= 1)
byte 0x01 (group marker)
0x09 + uint16 byteLen + ASCII tagName
uint32 propertyCount
repeated propertyCount times:
byte 0x02 (property marker) + 0x09 + uint16 byteLen + ASCII propertyName ← NO value variant
byte 0x00 (group trailer) ← 0x00 for delete, vs 0x01 for add
byte 0x00 (buffer terminator)
```
The native `deleteFromServer` argument is **not** in the buffer — it is the client-side flag that
decides whether the wire op fires at all (`true` ⇒ a `DelTep` call). `HistorianTagExtendedProperty
Protocol.SerializeDeleteRequest` produces this exactly; `WcfTagExtendedPropertyWriteProtocolTests`
pins the server-accepted bytes.
### Why the SDK delete is server-blocked
The SDK's `DelTep` is rejected by the server with `SErrorException` in
`aahClientAccessPoint::CHistStorage::DeleteTagExtendedProperties`, even though:
- the **inBuff is byte-identical** to the server-accepted native capture (golden-verified);
- the **Open2 connection mode matches** the native (`0x401`, confirmed from the capture at offset
0x4a4);
- the **handles match** (uppercase storage-session GUID for `DelTep`/`GetTepByNm`, uint client handle
for `GetTgByNm`);
- the SDK first **primes the session** with `GetTgByNm` (tag identity, returns 140 bytes of tag info)
and `GetTepByNm` (returns the property), keeping the Retrieval prime channel **open across** the
`DelTep` call;
- retried with backoff for **60 s** (ruling out a storage-tier sync delay).
A decisive experiment localizes the gap: an **SDK-added** property *is* deletable — the native client
read-syncs and deletes it (`Success:true`). So the SDK's **add is complete**; only the SDK's **delete
session** is the problem. The native client multiplexes Hist/Retr/Stat/Trx over **one connection**
under a single `HistorianAccess` session, so its `GetTepByNm` populates a **per-connection working set**
that the same-connection `DelTep` consults. The SDK uses **separate WCF channels per service** (the
proven read pattern), so the borrowed-GUID Retrieval prime doesn't satisfy that server-side check.
Reproducing it requires transport-level connection multiplexing — a substantial change beyond this op.
The investigated-but-blocked orchestration is kept (internal
`HistorianWcfTagWriteOrchestrator.DeleteTagExtendedPropertiesAsync`, the `PrimeThenDelete` helper) for
follow-up, but `HistorianClient` deliberately exposes **no** public delete to avoid a silently-failing
write API. Capture/decode tooling: `scripts/Capture-DeleteTagExtendedProperties.ps1` +
`scripts/decode-del-tep-capture.py`, harness `add-tep` scenario with `--tep-skip-add` / `--tep-delete`.
## Shipped surface
@@ -0,0 +1,67 @@
# Non-analog tag create over 2020 WCF — GATED (HCAL R1.13)
**Status: ⛔ bounded out (2026-06-21). No non-analog (string / discrete / wide-integer) tag-create
path is reachable on 2020 — the native managed client rejects every non-analog type *client-side*,
before any WCF op, so there is no wire format to capture and nothing to implement against.**
`EnsureTagAsync` stays analog-only (Float, Double, Int2, UInt2, Int4, UInt4); unsupported types throw
`ProtocolEvidenceMissingException` from `HistorianTagWriteProtocol.GetAnalogDataTypeCode`.
## What R1.13 asked for
Create string / discrete (non-analog) tags via `History.EnsureTags`, with a distinct `CTagMetadata`
variant. The roadmap flagged it "⚠ native AddTag rejected some types — confirm server path first;
may be GATED."
## Findings (live-probed against the local 2020 Historian)
1. **No discrete/boolean data type exists.** The native `ArchestrA.HistorianDataType` enum
(`current/aahClientManaged.dll`, dumped via `enum-dump`) has exactly 12 members: `Int1, Int2,
UInt2, Int4, UInt4, Float, Double, SingleByteString, DoubleByteString, Event, Structure`. There is
no `Discrete`/`Boolean`, and no `Int8`/`UInt8`/`UInt1`/`Guid`/`FileTime` (those are SDK-only
extensions in `Models/HistorianDataType`, recovered from the C++ `CDataType` predicate IL — they
are not settable on the managed `HistorianTag`).
2. **Tag type is data-type-derived, not separately settable.** `ArchestrA.HistorianTag`
(`--dump-type-members`) has **no** `TagType` property — only `TagDataType`. It does carry the
discrete/string-shaped fields (`MessageOn`/`MessageOff`, `RolloverValue`, dead-band/interpolation),
and the type exposes `ValidateAnalog*`, `ValidateDiscreteGeneralProperties`, and
`ValidateDiscreteAndStringStorageProperties` — but the analog-vs-discrete-vs-string decision is made
internally from the data type, with no way to request "discrete."
3. **Native AddTag rejects every non-analog type client-side.** Driving the native
`HistorianAccess.AddTag(HistorianTag, …)` (harness `write` scenario, `--write-data-type`) against
the live server:
| Data type | AddTag.Success | ErrorCode | ErrorType |
|---|---|---|---|
| SingleByteString | **false** | ValidationFailed | CustomError |
| DoubleByteString | **false** | ValidationFailed | CustomError |
| Int1 | **false** | ValidationFailed | CustomError |
| Int8 / UInt8 | n/a | *not in the native enum* | — |
| Float (control) | true | Success | — |
The error — `ErrorType=CustomError`, `ErrorCode=ValidationFailed`, `ErrorDescription="Transaction
validation failed"` — is raised by the client's own `Validate*` chain **before any WCF message is
sent** (the wrapper even auto-populates discrete defaults `MessageOn=ON`/`MessageOff=OFF`, then
fails validation). So the native client never emits a non-analog `EnsT2`/`AddTag` request.
## Why it's not deliverable here
Because the native client refuses non-analog types client-side, **no wire request exists to
reverse-engineer** — there is no captured `CTagMetadata` variant for string or discrete tags, and the
SDK does not guess wire bytes. The rejection is not string-specific: `Int1` (a non-string integer
outside the analog set `{Float, Double, Int2, UInt2, Int4, UInt4}`) fails identically, so the boundary
is "the analog set" rather than "strings only." Creating string/discrete tags on 2020 evidently goes
through a different subsystem (e.g. the configuration editor / SQL config path), not this client's
`AddTag`. R1.13 is closed as GATED, consistent with the mission note that these types "fail at native
AddTag — likely require a different path and are intentionally not supported."
## Probe commands (read-only / sandbox-guarded)
```
dotnet run --project tools\AVEVA.Historian.ReverseEngineering -- enum-dump current\aahClientManaged.dll HistorianDataType
dotnet run --project tools\AVEVA.Historian.ReverseEngineering -- dnlib-method current\aahClientManaged.dll HistorianTag.ValidateDataType
# native AddTag probe (sandbox tag must start with RetestSdkWrite; --write-skip-add-value avoids the blocked value path):
dotnet run --project tools\AVEVA.Historian.NativeTraceHarness -- --scenario write --server-name localhost --tcp-port 32568 \
--write-sandbox-tag RetestSdkWriteNADoubleByteString --write-data-type DoubleByteString --write-skip-add-value
```
@@ -71,3 +71,31 @@ Findings:
Shipped as `HistorianClient.GetRuntimeParameterAsync(name)`. See
`HistorianRuntimeParameterProtocol`, golden `WcfRuntimeParameterProtocolTests`, and the
handle-format lead in `wcf-string-handle-wall.md` §Update (retry GETHI/ExeC uppercased).
## R1.3 timezone + R1.4 EventStorageMode — re-confirmed bounded out (2026-06-21)
Both were already classified 2023R2/gRPC-only; re-verified from two *fresh* angles that corroborate it
more strongly than the original op-level probes:
- **Runtime DB schema** (`Runtime.dbo`, the server's own source of truth): the `SystemParameter` table
has **no** timezone parameter and **no `EventStorageMode`** (only `EventStorageDuration` /
`EventStorageLogPath`). The server timezone exists only as **per-block storage artifacts**
(`HistoryBlock.TimeZoneOffset` = e.g. 240 min, `wwTimeZone` = e.g. "Eastern Daylight Time") and a
`TimeZone` reference/lookup table; `StorageShard.TimeZoneId` is NULL. So the timezone is a
DST-specific, SQL-only, OS-derived value, not a clean server-config field exposed by any op.
- **Parameter-op probe** (`StringHandleProbeDiagnosticTests.TimezoneAndStorageMode_ParameterProbe`):
`GetSystemParameter` and `GetRuntimeParameter` (GETRP) were asked for every timezone candidate
(`TimeZone`/`ServerTimeZone`/`SystemTimeZone`/`TimeZoneName`/`SystemTimeZoneName`/`TimeStampRule`/
`ServerTime`) and every storage-mode candidate (`EventStorageMode`/`StorageMode`/`EventStorage`/
`EventStorageDuration`). **All returned null (GetSystemParameter) or threw
`ProtocolEvidenceMissingException` (GETRP — non-string/empty response)**; only the `HistorianVersion`
control returned a value (`20,0,000,000`). Note: `TimeStampRule`/`EventStorageDuration` *do* exist in
the `SystemParameter` table yet `GetSystemParameterAsync` returns null for them — the shipped op only
surfaces a whitelisted subset (a possible future widening, unrelated to R1.3/R1.4).
Conclusion: **R1.3 `GetServerTimeZoneAsync` and R1.4 `GetHistorianInfoAsync` (EventStorageMode) are not
deliverable as server ops on 2020.** The only 2020 route to the timezone is a SQL read of
`HistoryBlock`/`TimeZone` via `ExecuteSqlCommand` (R1.1) — a DST-specific value over a different
mechanism than the roadmap's `Status.GetSystemTimeZoneName`. `EventStorageMode` has no 2020
representation at all (it is a 2023 R2 event-storage-architecture field). Deliver both only against a
live 2023 R2 gRPC server.
@@ -94,6 +94,18 @@ UI-text localization), not tag properties. So R1.6 **collapses into R1.5**: exte
(`GetTepByNm`) are the user-defined tag-property read surface on 2020. R1.6 is closed as
"no separate op," not left throwing.
## R1.12 (localized-property write) — no distinct op on 2020 (mirror of R1.6)
Symmetric to R1.6 on the write side: there is **no** `AddTagLocalizedProperties` /
`DeleteTagLocalizedProperties` (or any `*LocalizedPropert*` / `TagLocalized*`) symbol in **any**
`current/*.dll`. A full symbol sweep of the shipped client DLLs surfaces only `GetLocalizedText`,
`GetLocalizedMessage`, and `LocalizedResourcesDir` — all UI/error-message-text localization, not tag
data. (The sweep does find the real write op `AddTagExtendedProperties` and the whole `AddTag*`
family, so the absence of a localized op is a true negative, not a grep miss.) R1.12 is therefore
**closed as "no separate op"** — the same conclusion as R1.6's read side. Extended-property write
(R1.11 `AddTEx`) is the user-defined tag-property write surface on 2020; localized properties are a
2023 R2 / gRPC-only concept. Not left throwing.
## Shipped surface
- `HistorianClient.GetTagExtendedPropertiesAsync(tag)``IReadOnlyList<HistorianTagExtendedProperty>`
@@ -0,0 +1,111 @@
<#
.SYNOPSIS
Captures the native AVEVA client's DeleteTagExtendedProperties (DelTep) wire traffic (HCAL R1.11
delete half) using the CROSS-SESSION trick so the delete passes the client-side sync gate.
.DESCRIPTION
DeleteTagExtendedPropertiesByName does a CLIENT-SIDE sync check and returns err 229 ("Tag extended
property not synchronized with server") for any property the local cache doesn't see as
server-synchronized — so a just-added property can't be deleted in the same session and its DelTep
inBuff never reaches the wire. This script captures it in two SEPARATE harness processes (= two
sessions) against one instrumented aahClientManaged.dll:
Run A: add-tep (create sandbox tag + AddTagExtendedProperties) -> property now server-synced
Run B: add-tep --tep-skip-create --tep-skip-add --tep-delete -> fresh connection: fetch the
property from the server (seeds the local cache as SYNCED), then
DeleteTagExtendedPropertiesByName, which now reaches the wire as DelTep.
The capture file is cleared BETWEEN the two runs so it contains only Run B (the GetTepByNm
read-for-sync + the DelTep delete). Decode with scripts/decode-del-tep-capture.py.
SAFETY: sandbox-guarded — the tag MUST start with 'RetestSdkWrite'. The run leaves the sandbox tag
in place (property removed); delete the tag afterwards with the supported aaDeleteTag proc.
.NOTES
Artifacts are diagnostic and gitignored. Sanitize before copying into docs/.
#>
[CmdletBinding()]
param(
[string]$ServerName = "localhost",
[int]$TcpPort = 32568,
[string]$TepTag = "RetestSdkWriteDelTepSdk",
[string]$PropName = "SdkDelProp",
[string]$PropValue = "SdkDelValue",
[string]$Configuration = "Debug"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
if (-not $TepTag.StartsWith("RetestSdkWrite")) { throw "-TepTag must start with 'RetestSdkWrite' (sandbox guard)." }
$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-del-tep"
$currentCopy = Join-Path $captureDir "current-copy"
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
$capturePath = Join-Path $captureDir "del-tep-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
$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"
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
function Invoke-Harness([string[]]$extraArgs, [string]$label) {
Write-Host "== $label ==" -ForegroundColor Green
$harnessArgs = @(
"--scenario", "add-tep",
"--server-name", $ServerName,
"--tcp-port", "$TcpPort",
"--tep-tag", $TepTag,
"--tep-name", $PropName,
"--tep-value", $PropValue,
"--current-dir", $currentCopy,
"--managed-dll-path", $harnessDll
) + $extraArgs
$json = $null
try {
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$json = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
} finally { $ErrorActionPreference = $prevEap }
$json | Select-Object -Last 20
}
# Run A: create the sandbox tag + add the property (server-synced afterwards).
Invoke-Harness @() "Run A: create + AddTagExtendedProperties ($TepTag : $PropName=$PropValue)"
# Clear the capture so the file contains only Run B (read-for-sync + DelTep).
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
# Run B: FRESH session — fetch (sync the local cache) then DeleteTagExtendedPropertiesByName.
Invoke-Harness @("--tep-skip-create", "--tep-skip-add", "--tep-delete") "Run B: fresh session -> read-for-sync -> DelTep"
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 (Run B only) ==" -ForegroundColor Cyan
Write-Host " -> $recCount records -> $capturePath"
Write-Host "`nDecode with: python scripts\decode-del-tep-capture.py" -ForegroundColor Cyan
+105
View File
@@ -0,0 +1,105 @@
"""Decode the DeleteTagExtendedProperties (DelTep) WCF inBuff (HCAL R1.11 delete half).
Reads the Run-B capture produced by scripts/Capture-DeleteTagExtendedProperties.ps1 and dumps the
DelTep WriteMessage body so the delete-request framing (tag name + property names + the
delete-from-server flag) can be read off and compared to the AddTEx serializer.
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
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-del-tep"
CAP = CAPDIR / "del-tep-capture-latest.ndjson"
TAG = "RetestSdkWriteDelTepSdk"
PROP = "SdkDelProp"
OP_DEL = b"DelTep"
OP_GET = b"GetTepByNm"
def hexdump(label, buf, base=0):
print(f"=== {label}: {len(buf)} bytes ===")
for off in range(0, len(buf), 16):
c = buf[off:off + 16]
hp = " ".join(f"{x:02X}" for x in c)
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
print(f" {base + off:04X} {hp:<48} |{ap}|")
print()
def ascii_strings(buf, minlen=3):
out, cur, start = [], [], 0
for i, x in enumerate(buf):
if 32 <= x < 127:
if not cur:
start = i
cur.append(chr(x))
else:
if len(cur) >= minlen:
out.append((start, "".join(cur)))
cur = []
if len(cur) >= minlen:
out.append((start, "".join(cur)))
return out
def u16_strings(buf, minlen=3):
out, i = [], 0
while i < len(buf) - 1:
j, chars = i, []
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
chars.append(chr(buf[j]))
j += 2
if len(chars) >= minlen:
out.append((i, "".join(chars)))
i = j
else:
i += 1
return out
def main() -> int:
if not CAP.exists():
print(f"Missing capture: {CAP}\nRun scripts/Capture-DeleteTagExtendedProperties.ps1 first.")
return 1
records = []
for line in CAP.open(encoding="utf-8-sig"):
if line.strip():
records.append(json.loads(line))
print(f"== {len(records)} MDAS bodies captured (Run B) ==")
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
flags = []
if OP_DEL in body:
flags.append("DelTep")
if OP_GET in body:
flags.append("GetTepByNm")
if TAG.encode("ascii") in body or TAG.encode("utf-16-le") in body:
flags.append("TAG")
if PROP.encode("ascii") in body or PROP.encode("utf-16-le") in body:
flags.append("PROP")
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
print("\n== DelTep request(s) ==")
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
if rec.get("Phase") == "WCF.WriteMessage.Body" and OP_DEL in body:
hexdump(f"[{idx}] DelTep WriteMessage", body)
print(" UTF-16 strings:")
for off, s in u16_strings(body):
print(f" 0x{off:04X} {s!r}")
print(" ASCII strings:")
for off, s in ascii_strings(body):
print(f" 0x{off:04X} {s!r}")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -201,6 +201,15 @@ public sealed class HistorianClient : IAsyncDisposable
return AddTagExtendedPropertiesAsync(tag, [new HistorianTagExtendedProperty(name, value ?? string.Empty)], cancellationToken);
}
// Extended-property DELETE (DelTep) is intentionally NOT exposed publicly. Its wire format is
// captured and the serializer (HistorianTagExtendedPropertyProtocol.SerializeDeleteRequest) is
// golden-verified against a server-accepted buffer, but the SDK cannot yet make the 2020 server
// accept the delete: the server's CHistStorage::DeleteTagExtendedProperties consults a
// per-connection working set that the native client populates by multiplexing GetTepByNm and
// DelTep over ONE connection, which the SDK's per-service WCF channels don't reproduce. See the
// documented-but-blocked path in HistorianWcfTagWriteOrchestrator and
// docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete.
/// <summary>
/// Creates or updates the named tag in the Historian Runtime database via
/// <c>EnsureTags2</c>. Currently only <see cref="HistorianDataType.Float"/> is
@@ -108,6 +108,59 @@ internal static class HistorianTagExtendedPropertyProtocol
return stream.ToArray();
}
/// <summary>
/// Serializes the <c>DelTep</c> (DeleteTagExtendedProperties) inBuff for a single tag's
/// properties identified by name. Same group framing as <see cref="SerializeAddRequest"/> but
/// each property carries its name only (no <c>0x43</c> value variant) and the group trailer byte
/// is <c>0x00</c> (Add uses <c>0x01</c>). Decoded from a native
/// <c>DeleteTagExtendedPropertiesByName</c> capture (see
/// <c>docs/reverse-engineering/wcf-add-tag-extended-properties.md</c> §Delete):
/// <code>
/// uint32 groupCount (= 1)
/// byte 0x01 (group marker)
/// 0x09 + uint16 byteLen + ASCII tagName
/// uint32 propertyCount
/// repeated: byte 0x02 (property marker) + 0x09 + uint16 byteLen + ASCII propName
/// byte 0x00 (group trailer)
/// byte 0x00 (buffer terminator)
/// </code>
/// The native <c>deleteFromServer</c> argument is not in the buffer — it is the client-side flag
/// that decides whether the wire op fires at all (the SDK always sends the server-delete form,
/// the only one that produces a <c>DelTep</c> call).
/// </summary>
public static byte[] SerializeDeleteRequest(string tagName, IReadOnlyList<string> propertyNames)
{
ArgumentException.ThrowIfNullOrEmpty(tagName);
ArgumentNullException.ThrowIfNull(propertyNames);
if (propertyNames.Count == 0)
{
throw new ArgumentException("At least one property name is required.", nameof(propertyNames));
}
using MemoryStream stream = new();
Span<byte> u32 = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(u32, 1u); // group count
stream.Write(u32);
stream.WriteByte(GroupMarker);
WriteCompactAscii(stream, tagName);
BinaryPrimitives.WriteUInt32LittleEndian(u32, checked((uint)propertyNames.Count));
stream.Write(u32);
foreach (string name in propertyNames)
{
ArgumentException.ThrowIfNullOrEmpty(name, nameof(propertyNames));
stream.WriteByte(PropertyMarker);
WriteCompactAscii(stream, name); // delete is by name only — no value variant
}
stream.WriteByte(0x00); // group trailer (captured: 0x00 for delete, vs 0x01 for add)
stream.WriteByte(0x00); // buffer terminator
return stream.ToArray();
}
private static void WriteCompactAscii(MemoryStream stream, string value)
{
byte[] ascii = Encoding.ASCII.GetBytes(value);
@@ -53,6 +53,29 @@ internal sealed class HistorianWcfTagWriteOrchestrator
return Task.Run(() => AddTagExtendedProperties(tagName, properties), cancellationToken);
}
/// <summary>
/// DelTep (DeleteTagExtendedProperties) — <b>investigated but server-blocked, not wired to the
/// public API.</b> The inBuff serializer is golden-verified against a server-accepted native
/// capture, and an SDK-added property is deletable (the native client read-syncs and deletes it).
/// But the SDK's own delete is rejected by <c>CHistStorage::DeleteTagExtendedProperties</c>
/// (SErrorException) even with matching Open2 mode/handle/inBuff and a GetTgByNm+GetTepByNm prime:
/// the server consults a per-connection working set the native client populates by multiplexing
/// GetTepByNm and DelTep over one connection, which the SDK's per-service WCF channels don't
/// reproduce. Kept for follow-up RE. See
/// docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete.
/// </summary>
internal Task<bool> DeleteTagExtendedPropertiesAsync(
string tagName, IReadOnlyList<string> propertyNames, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
ArgumentNullException.ThrowIfNull(propertyNames);
if (propertyNames.Count == 0)
{
throw new ArgumentException("At least one property name is required.", nameof(propertyNames));
}
return Task.Run(() => DeleteTagExtendedProperties(tagName, propertyNames), cancellationToken);
}
private bool EnsureTag(HistorianTagDefinition definition)
{
Guid contextKey = Guid.NewGuid();
@@ -126,8 +149,101 @@ internal sealed class HistorianWcfTagWriteOrchestrator
return result;
}
/// <summary>Env-gated dump of the clean AddTEx inBuff (base64) for golden-fixture capture,
/// mirroring the rename/SQL dump hooks — avoids hand-stitching MDAS chunk markers.</summary>
private bool DeleteTagExtendedProperties(string tagName, IReadOnlyList<string> propertyNames)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
EndpointAddress retrievalEndpoint = _options.Transport == HistorianTransport.LocalPipe
? HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval)
: HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
bool result = false;
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
additionalSetup: (historyChannel, context) =>
{
RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
byte[] inBuff = HistorianTagExtendedPropertyProtocol.SerializeDeleteRequest(tagName, propertyNames);
DumpTepIfRequested(inBuff);
// The server's CHistStorage::DeleteTagExtendedProperties resolves each property from
// the storage session's working set — so the tag identity (GetTgByNm) and its
// extended properties (GetTepByNm) must be fetched on THIS session first, exactly as
// the native client does (register → read → delete). The Retrieval prime channel is
// kept OPEN across the DelTep call (the registration is bound to the live channel
// session); without this prime the server throws SErrorException and DelTep returns
// false. See docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete.
result = PrimeThenDelete(auxBinding, retrievalEndpoint, handle, context.ClientHandle, tagName, () =>
{
bool ok = historyChannel.DeleteTagExtendedProperties(handle, inBuff, out byte[] errorBuffer);
WriteDiag("DelTep", $"Returned={ok} Tag={tagName} PropCount={propertyNames.Count} InLen={inBuff.Length} ErrLen={errorBuffer?.Length ?? -1} ErrHex={(errorBuffer is null ? "<null>" : Convert.ToHexString(errorBuffer))}");
return ok;
});
});
return result;
}
/// <summary>
/// Primes the Retrieval session before a <c>DelTep</c> delete exactly as the native client does:
/// register the tag identity (<c>GetTgByNm</c>) then fetch its extended properties
/// (<c>GetTepByNm</c>) so the server loads both into the storage session's working set, then runs
/// <paramref name="deleteAction"/> (the DelTep call) WHILE the Retrieval channel is still open.
/// Without this two-step prime — or if the channel is closed before DelTep — the server's
/// <c>CHistStorage::DeleteTagExtendedProperties</c> can't resolve the property and throws
/// <c>SErrorException</c> (DelTep returns false). The fetched rows are discarded — the calls are
/// for their server-side registration effect. See
/// docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete.
/// </summary>
private bool PrimeThenDelete(
Binding retrievalBinding, EndpointAddress retrievalEndpoint, string handle, uint clientHandle,
string tagName, Func<bool> deleteAction)
{
ChannelFactory<IRetrievalServiceContract4> factory = new(retrievalBinding, retrievalEndpoint);
HistorianWcfClientCredentialsHelper.Configure(factory, _options);
IRetrievalServiceContract4 channel = factory.CreateChannel();
try
{
TryRun(() => channel.GetInterfaceVersion(out _));
byte[] tagNames = HistorianTagExtendedPropertyProtocol.SerializeRequest(tagName);
// Register the tag identity on this session (GetTgByNm, uint clientHandle). tagNames uses
// the same count+charCount+UTF-16 framing as GetTepByNm's request buffer.
try
{
uint tgSequence = 0;
uint tgRet = channel.GetTagInfosFromName(clientHandle, (uint)tagNames.Length, tagNames, ref tgSequence, out uint tgInfosSize, out byte[] tgInfos);
WriteDiag("DelTepPrime", $"GetTgByNm ret={tgRet} clientHandle={clientHandle} seq={tgSequence} infosSize={tgInfosSize} infosLen={tgInfos?.Length ?? -1}");
}
catch (Exception ex) { WriteDiag("DelTepPrime", $"GetTgByNm threw: {ex.GetType().Name}: {ex.Message}"); }
uint sequence = 0;
for (int page = 0; page < 64; page++)
{
bool ok;
byte[] responseBuffer;
try { ok = channel.GetTagExtendedPropertiesFromName(handle, tagNames, ref sequence, out responseBuffer, out byte[] errBuf); }
catch (Exception ex) { WriteDiag("DelTepPrime", $"GetTepByNm threw page={page}: {ex.GetType().Name}: {ex.Message}"); break; }
int rows = HistorianTagExtendedPropertyProtocol.ParseResponse(responseBuffer ?? []).Count;
WriteDiag("DelTepPrime", $"GetTepByNm page={page} ok={ok} respLen={responseBuffer?.Length ?? -1} rows={rows}");
if (!ok) break;
if (rows == 0) break;
}
// DelTep runs here, while the Retrieval prime channel is still open.
return deleteAction();
}
finally
{
CloseSafely(channel, factory);
}
}
/// <summary>Env-gated dump of the clean AddTEx / DelTep inBuff (base64) for golden-fixture
/// capture, mirroring the rename/SQL dump hooks — avoids hand-stitching MDAS chunk markers.</summary>
private static void DumpTepIfRequested(byte[] inBuff)
{
string? path = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_TEP_DUMP");
@@ -565,6 +565,12 @@ public sealed class HistorianClientIntegrationTests
}
}
// Extended-property DELETE (DelTep) has no live integration test: the wire format is captured and
// the serializer is golden-verified (WcfTagExtendedPropertyWriteProtocolTests), but the SDK
// cannot yet make the server accept the delete (the native single-connection working-set
// requirement isn't reproduced by per-service WCF channels). See
// docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete.
// Round-trip every live-verified analog data type + the non-default-range case. The
// sandbox tag name is suffixed per case so the runs don't collide. Always cleans up.
[Theory]
@@ -153,4 +153,43 @@ public sealed class StringHandleProbeDiagnosticTests
}
});
}
/// <summary>
/// R1.3 / R1.4 reachability probe: ask GetSystemParameter (config) and GetRuntimeParameter (GETRP,
/// live runtime state) for timezone + event-storage-mode candidate keys. If the server timezone or
/// EventStorageMode surfaces through either named-parameter op, R1.3/R1.4 are deliverable on 2020;
/// if every candidate returns null, they are confirmed 2023R2/gRPC-only from the parameter angle.
/// Prints results — not an assertion test.
/// </summary>
[Fact]
public async Task TimezoneAndStorageMode_ParameterProbe_AgainstLocalHistorian()
{
if (!ShouldRun(out string host)) return;
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
// Timezone (R1.3) candidates + the one existing time-ish SystemParameter (TimeStampRule);
// EventStorageMode (R1.4) candidates + the existing EventStorage* params as controls.
string[] candidates =
[
"TimeZone", "ServerTimeZone", "SystemTimeZone", "TimeZoneName", "SystemTimeZoneName",
"TimeStampRule", "ServerTime",
"EventStorageMode", "StorageMode", "EventStorage", "EventStorageDuration",
"HistorianVersion", // known-good control
];
foreach (string key in candidates)
{
string? sys = null, run = null;
string sysErr = "", runErr = "";
try { sys = await client.GetSystemParameterAsync(key); } catch (Exception ex) { sysErr = ex.GetType().Name; }
try { run = await client.GetRuntimeParameterAsync(key); } catch (Exception ex) { runErr = ex.GetType().Name; }
_output.WriteLine($"{key,-22} SystemParameter={(sys is null ? "<null>" : $"\"{sys}\"")}{(sysErr.Length > 0 ? $" [{sysErr}]" : "")} RuntimeParameter={(run is null ? "<null>" : $"\"{run}\"")}{(runErr.Length > 0 ? $" [{runErr}]" : "")}");
}
}
}
@@ -69,4 +69,58 @@ public sealed class WcfTagExtendedPropertyWriteProtocolTests
Assert.Throws<ArgumentException>(() =>
HistorianTagExtendedPropertyProtocol.SerializeAddRequest("T", Array.Empty<HistorianTagExtendedProperty>()));
}
// Server-accepted DelTep inBuff for tag "RetestSdkWriteDelTepSdk", property "SdkDelProp",
// extracted byte-for-byte from the native DeleteTagExtendedPropertiesByName WriteMessage in the
// cross-session capture (scripts/Capture-DeleteTagExtendedProperties.ps1) — DelTep returned
// success, so it is server-validated.
private const string ServerAcceptedDeleteInBuffBase64 =
"AQAAAAEJFwBSZXRlc3RTZGtXcml0ZURlbFRlcFNkawEAAAACCQoAU2RrRGVsUHJvcAAA";
[Fact]
public void SerializeDeleteRequest_MatchesServerAcceptedBuffer()
{
byte[] expected = Convert.FromBase64String(ServerAcceptedDeleteInBuffBase64);
byte[] actual = HistorianTagExtendedPropertyProtocol.SerializeDeleteRequest(
"RetestSdkWriteDelTepSdk", ["SdkDelProp"]);
Assert.Equal(expected, actual);
}
[Fact]
public void SerializeDeleteRequest_SingleProperty_HasExpectedLayout()
{
byte[] buf = HistorianTagExtendedPropertyProtocol.SerializeDeleteRequest(
"ReactorTemp", ["Location"]);
int c = 0;
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(c, 4))); c += 4; // group count
Assert.Equal(0x01, buf[c++]); // group marker
Assert.Equal(0x09, buf[c++]); // compact string marker
Assert.Equal(11, BinaryPrimitives.ReadUInt16LittleEndian(buf.AsSpan(c, 2))); c += 2; // tag byte len
Assert.Equal("ReactorTemp", Encoding.ASCII.GetString(buf.AsSpan(c, 11))); c += 11;
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(c, 4))); c += 4; // property count
Assert.Equal(0x02, buf[c++]); // property marker
Assert.Equal(0x09, buf[c++]);
Assert.Equal(8, BinaryPrimitives.ReadUInt16LittleEndian(buf.AsSpan(c, 2))); c += 2;
Assert.Equal("Location", Encoding.ASCII.GetString(buf.AsSpan(c, 8))); c += 8;
// Delete carries NO value variant (Add would have 0x43 ... here).
Assert.Equal(0x00, buf[c++]); // group trailer (0x00 for delete, vs 0x01 for add)
Assert.Equal(0x00, buf[c++]); // buffer terminator
Assert.Equal(buf.Length, c);
}
[Fact]
public void SerializeDeleteRequest_MultipleNames_EncodesCount()
{
byte[] buf = HistorianTagExtendedPropertyProtocol.SerializeDeleteRequest("T", ["A", "B"]);
// group count @0, then 0x01 marker, 0x09 + u16(1) + "T" => property count at offset 4+1+1+2+1 = 9
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(9, 4)));
}
[Fact]
public void SerializeDeleteRequest_NoProperties_Throws()
{
Assert.Throws<ArgumentException>(() =>
HistorianTagExtendedPropertyProtocol.SerializeDeleteRequest("T", Array.Empty<string>()));
}
}
@@ -390,27 +390,67 @@ internal static class Program
groupType.GetMethod("Add", new[] { propType })!.Invoke(group, [prop]);
listType.GetMethod("Add", new[] { groupType })!.Invoke(list, [group]);
MethodInfo addTepMethod = accessType.GetMethods()
.First(m => m.Name == "AddTagExtendedProperties" && m.GetParameters().Length == 2);
object addTepError = Activator.CreateInstance(errorType)!;
object?[] addTepArgs = [list, addTepError];
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-tep");
bool addTepOk = false;
string? addTepEx = null;
try { addTepOk = (bool)addTepMethod.Invoke(access, addTepArgs)!; }
catch (TargetInvocationException ex) { addTepEx = FormatException(ex.InnerException ?? ex); }
tepRows.Add(new
// 2b) Add the property (AddTEx) unless --tep-skip-add. Skipped in the second
// (delete) session so the local cache isn't re-seeded with an UNSYNCED entry —
// DeleteTagExtendedPropertiesByName returns err 229 against unsynced properties.
if (!HasFlag(args, "--tep-skip-add"))
{
Kind = "AddTagExtendedProperties",
Success = addTepOk,
Exception = addTepEx,
ErrorDescription = GetPropertyText(addTepArgs[1]!, "ErrorDescription"),
ErrorCode = GetPropertyText(addTepArgs[1]!, "ErrorCode"),
});
MethodInfo addTepMethod = accessType.GetMethods()
.First(m => m.Name == "AddTagExtendedProperties" && m.GetParameters().Length == 2);
object addTepError = Activator.CreateInstance(errorType)!;
object?[] addTepArgs = [list, addTepError];
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-tep");
bool addTepOk = false;
string? addTepEx = null;
try { addTepOk = (bool)addTepMethod.Invoke(access, addTepArgs)!; }
catch (TargetInvocationException ex) { addTepEx = FormatException(ex.InnerException ?? ex); }
tepRows.Add(new
{
Kind = "AddTagExtendedProperties",
Success = addTepOk,
Exception = addTepEx,
ErrorDescription = GetPropertyText(addTepArgs[1]!, "ErrorDescription"),
ErrorCode = GetPropertyText(addTepArgs[1]!, "ErrorCode"),
});
}
// 3) Optional delete (DelTep) to capture its inBuff too.
// 3) Optional delete (DelTep) to capture its inBuff too. The native
// DeleteTagExtendedPropertiesByName performs a CLIENT-SIDE sync check and rejects
// (err 229) any property the local cache doesn't see as server-synchronized — so a
// just-added property can't be deleted in the same session. To capture DelTep:
// Run A: add-tep --tep-tag RetestSdkWriteDelTepSdk --tep-name P --tep-value V
// Run B: add-tep --tep-tag RetestSdkWriteDelTepSdk --tep-name P \
// --tep-skip-create --tep-skip-add --tep-delete
// Run B opens a FRESH connection, then we fetch the property from the server
// (GetTagExtendedPropertiesByName, fetchFromServer=true) to seed the local cache as
// SYNCED before deleting — which lets DeleteTagExtendedPropertiesByName reach the wire.
if (HasFlag(args, "--tep-delete"))
{
// Seed the synced local cache: prime tag identity + force a server fetch.
MethodInfo? primeForDelete = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "GetTagInfoByName" && m.GetParameters().Length == 4);
if (primeForDelete is not null)
{
object tibErr = Activator.CreateInstance(errorType)!;
object?[] tibArgs = [tepTag, true, null, tibErr];
try { primeForDelete.Invoke(access, tibArgs); } catch { }
}
MethodInfo? readForSync = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "GetTagExtendedPropertiesByName" && m.GetParameters().Length == 4);
string? syncRead = null;
if (readForSync is not null)
{
object syncErr = Activator.CreateInstance(errorType)!;
object?[] syncArgs = [tepTag, true, null, syncErr];
try
{
bool syncOk = (bool)readForSync.Invoke(access, syncArgs)!;
syncRead = $"GetTagExtendedPropertiesByName(fetch)={syncOk} err={GetPropertyText(syncArgs[3]!, "ErrorDescription")}";
}
catch (TargetInvocationException ex) { syncRead = "read-for-sync threw: " + FormatException(ex.InnerException ?? ex); }
}
tepRows.Add(new { Kind = "ReadForSync", Detail = syncRead });
MethodInfo? delTepMethod = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "DeleteTagExtendedPropertiesByName" && m.GetParameters().Length == 4);
if (delTepMethod is not null)
@@ -420,10 +460,11 @@ internal static class Program
namesColType.GetMethod("Add", new[] { typeof(string) })!.Invoke(names, [propName]);
object delErr = Activator.CreateInstance(errorType)!;
object?[] delArgs = [tepTag, names, true, delErr];
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-del-tep");
bool delOk = false; string? delEx = null;
try { delOk = (bool)delTepMethod.Invoke(access, delArgs)!; }
catch (TargetInvocationException ex) { delEx = FormatException(ex.InnerException ?? ex); }
tepRows.Add(new { Kind = "DeleteTagExtendedPropertiesByName", Success = delOk, Exception = delEx, ErrorDescription = GetPropertyText(delArgs[3]!, "ErrorDescription") });
tepRows.Add(new { Kind = "DeleteTagExtendedPropertiesByName", Success = delOk, Exception = delEx, ErrorDescription = GetPropertyText(delArgs[3]!, "ErrorDescription"), ErrorCode = GetPropertyText(delArgs[3]!, "ErrorCode") });
}
}