R1.5 GetTagExtendedPropertiesAsync (GetTepByNm) + R1.6 closed (no op)
Ship tag extended-property reads over the 2020 WCF aa/Retr/GetTepByNm op: HistorianClient.GetTagExtendedPropertiesAsync(tag) -> name/value pairs. String-handle op reached with the Open2 storage-session GUID formatted uppercase (same format that unlocked GETRP/GETHI/ExeC). Routed via the name-based native path (GetTagExtendedPropertiesByName, server-fetch flag), not the index-based TagQuery path. Evidence-backed findings from the capture: - GetTepByNm (and GetTgByNm) succeed with the uppercase handle -- further validates the resolved string-handle wall. - QTB (StartTagQuery) does NOT punch through: captured uppercase, it still fails server-side (CMdServer::StartActiveTagnamesQuery over the aahMetadataServer pipe) -- a metadata-server blocker, not handle format. - R1.6 (localized properties) has NO distinct op (only error-message/UI-text localization in the managed client); collapses into R1.5. Closed, not throwing. Wire format (golden-pinned, synthetic bytes -- no dev tag names committed): - 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. Adds: HistorianTagExtendedProperty model, HistorianTagExtendedPropertyProtocol (codec), HistorianWcfTagExtendedPropertyClient (orchestration), dialect + public API; golden WcfTagExtendedPropertyProtocolTests (4) + gated live test (HISTORIAN_TEP_TAG). Tooling: Capture-TagExtendedProperties.ps1, decode-tag-properties-capture.py, harness tag-extended-properties scenario. Docs: wcf-tag-extended-properties.md; roadmap R1.5 DONE / R1.6 collapsed; wall doc + memory updated with the QTB-server-side nuance. 228 tests green. 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:
@@ -98,17 +98,21 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
|
||||
> (and R1.1) take a **`string` GUID handle**; the earlier "code 1/51 blocked" verdict came from
|
||||
> passing the Open2 storage GUID in .NET's default **lowercase**. Sent **uppercase**
|
||||
> (`storageSessionId.ToString("D").ToUpperInvariant()`) the same handle works: **GETRP** (R1.2,
|
||||
> shipped), **GETHI** (R1.4) and **ExeC** (R1.1) are all live-verified reachable. R1.5/R1.6
|
||||
> (GetTepByNm family) + QTB/QTG are very likely reachable the same way (not yet individually
|
||||
> re-probed). Full analysis: `docs/reverse-engineering/wcf-string-handle-wall.md` (RESOLVED banner).
|
||||
> shipped), **GETHI** (R1.4) and **ExeC** (R1.1) are all live-verified reachable, and **R1.5
|
||||
> `GetTepByNm`** is now **shipped + live-verified** (`GetTagExtendedPropertiesAsync`). **R1.6 has no
|
||||
> distinct op** (collapses into R1.5). Note: `QTB` (StartTagQuery) does **not** punch through — it
|
||||
> fails *server-side* (`CMdServer::StartActiveTagnamesQuery` over the `aahMetadataServer` pipe),
|
||||
> independent of handle format, so the index-based property/query paths stay blocked here. Full
|
||||
> analysis: `docs/reverse-engineering/wcf-string-handle-wall.md` (RESOLVED banner) and
|
||||
> `docs/reverse-engineering/wcf-tag-extended-properties.md`.
|
||||
> R1.8/R1.9 (StartQuery summary/state modes) are `uint`-handle and were already reachable.
|
||||
|
||||
### 1b. Bounded (decode one `bytes` payload; S–M 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.5 | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` | 🟡 **Likely reachable** via uppercase string handle (GetTepByNm family) — not yet individually re-probed. TEP result buffer. | uppercase string handle |
|
||||
| R1.6 | Localized-property **read** | `Retrieval.GetTagLocalizedPropertiesFromName` | 🟡 **Likely reachable** (same family) — not yet re-probed. | 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`. | — |
|
||||
| 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) | — |
|
||||
|
||||
@@ -17,8 +17,18 @@
|
||||
> `scripts/Capture-RuntimeParam.ps1`, `scripts/Capture-ExecSql.ps1`. The handle for ExeC/GetR is the
|
||||
> **same** Open2 storage-session GUID (confirmed = `outBuff[5..21]`). The original analysis below is
|
||||
> retained for history; treat its "blocked" conclusions as **superseded** — the only missing piece
|
||||
> was the uppercase format. R1.5/R1.6 (GetTepByNm family) and QTB/QTG are very likely reachable the
|
||||
> same way but have not yet been individually re-probed.
|
||||
> was the uppercase format.
|
||||
>
|
||||
> **Update 2026-06-20 — R1.5 `GetTepByNm` shipped; QTB nuance.** `GetTagExtendedPropertiesFromName`
|
||||
> (`GetTepByNm`) is now **shipped + live-verified** with the uppercase handle
|
||||
> (`GetTagExtendedPropertiesAsync`; see `wcf-tag-extended-properties.md`). It confirms the
|
||||
> string-handle Retrieval family is reachable (and `GetTgByNm`/GetTagInfosFromName was observed
|
||||
> succeeding alongside it). **But not every string-handle op is just a format fix:** `QTB`
|
||||
> (`StartTagQuery`) was captured being sent with a correctly-**uppercase** handle and still failed
|
||||
> with `error 1` *server-side* (`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over
|
||||
> `\\.\pipe\aahMetadataServer\console`). So QTB/QTG (the active-tagnames query family) are blocked by
|
||||
> the metadata server, not the handle format — distinct from the handle-format wall. **R1.6
|
||||
> (localized properties) has no distinct op** and collapses into R1.5.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Tag extended properties over 2020 WCF — GetTepByNm (HCAL R1.5)
|
||||
|
||||
**Status: ✅ DONE + live-verified (2026-06-20).** `HistorianClient.GetTagExtendedPropertiesAsync(tag)`
|
||||
reads a tag's extended (user-defined) properties over the 2020 WCF op
|
||||
`aa/Retr/GetTagExtendedPropertiesFromName` (`GetTepByNm`). Live-verified end-to-end from the
|
||||
pure-managed .NET 10 client against the local 2020 Historian.
|
||||
|
||||
## The op
|
||||
|
||||
`GetTepByNm` is on the Retrieval service (`IRetrievalServiceContract4`):
|
||||
|
||||
```
|
||||
bool GetTagExtendedPropertiesFromName(
|
||||
string handle, // Open2 storage-session GUID, UPPERCASE dash-no-braces
|
||||
byte[] tagNames, // [MessageParameter pRequestBuff-style]
|
||||
ref uint sequence, // paging cursor (0 on first call)
|
||||
out byte[] tagExtendedProperties, // result buffer
|
||||
out byte[] errorBuffer)
|
||||
```
|
||||
|
||||
It is a **string-handle** op — reachable from the managed client because the handle is the Open2
|
||||
storage-session GUID formatted `storageSessionId.ToString("D").ToUpperInvariant()` (the same handle
|
||||
format that unlocked GETRP/GETHI/ExeC; see `wcf-string-handle-wall.md`). The Retrieval service
|
||||
version handshake (`Retr.GetV`) is primed first, as the native client does.
|
||||
|
||||
## Why the name-based path (not the TagQuery path)
|
||||
|
||||
There are two managed entry points:
|
||||
|
||||
- **Index-based** `TagQuery.GetTagExtendedPropertyInfo(start, count, …)` — requires a prior
|
||||
`StartTagQuery` (`QTB`). On this 2020 box **QTB fails server-side** (`error 1` from
|
||||
`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over `\\.\pipe\aahMetadataServer\console`),
|
||||
so this path is dead here regardless of handle format.
|
||||
- **Name-based** `HistorianAccess.GetTagExtendedPropertiesByName(tagName, fetchFromServer, …)` —
|
||||
issues `GetTepByNm` directly with the tag name in `tagNames`, no QTB needed. Its second arg
|
||||
forces a **server fetch** when true; when false the C++ client reads a local cache and returns
|
||||
`error 41 (Requested item not found)` without any WCF round-trip. The SDK reproduces the
|
||||
name-based path.
|
||||
|
||||
## Wire format (captured)
|
||||
|
||||
`scripts/Capture-TagExtendedProperties.ps1` (NativeTraceHarness `tag-extended-properties` scenario +
|
||||
instrument-wcf-{write,read}message) → decode with `scripts/decode-tag-properties-capture.py`.
|
||||
Golden-pinned in `WcfTagExtendedPropertyProtocolTests`.
|
||||
|
||||
### Request — `tagNames` buffer
|
||||
|
||||
```
|
||||
uint32 count
|
||||
per name: uint32 charCount + UTF-16LE chars
|
||||
```
|
||||
|
||||
(For one tag: `01 00 00 00` + `LL 00 00 00` + UTF-16 name.)
|
||||
|
||||
### Response — `tagExtendedProperties` buffer
|
||||
|
||||
```
|
||||
uint32 tagCount
|
||||
per tag:
|
||||
byte groupMarker (observed 0x01)
|
||||
0x09 + uint16 byteLen + ASCII tagName (compact-ASCII string)
|
||||
uint32 propertyCount
|
||||
per property:
|
||||
byte propMarker (observed 0x02 — likely the value type)
|
||||
0x09 + uint16 byteLen + ASCII propertyName
|
||||
0x43 + uint16 payloadLen + uint16 charCount + UTF-16LE value (VT_BSTR CRetVariant)
|
||||
byte trailingMarker (observed 0x01)
|
||||
```
|
||||
|
||||
`payloadLen` counts the `charCount` field (2 bytes) + the UTF-16 value bytes. Only the string value
|
||||
variant (`0x43`) is evidence-backed; other variant types throw `ProtocolEvidenceMissingException`
|
||||
(same discipline as GETRP). The single-byte `0x01`/`0x02`/`0x01` markers are pinned as observed
|
||||
constants from a single capture; their full semantics are not independently disambiguated.
|
||||
|
||||
Example (sanitized — real capture used a dev tag/value):
|
||||
|
||||
```
|
||||
tag "Reactor.Temp1" → property "Location" = "Plant/AreaA"
|
||||
```
|
||||
|
||||
### Paging
|
||||
|
||||
`GetTepByNm` is sequence-paged like `GetNextQueryResultBuffer`: call with `sequence = 0`, parse the
|
||||
buffer, then re-call with the returned `sequence`. A small result returns everything on the first
|
||||
call; the next call returns an empty/`nil` buffer (with a benign `CClientUtil::FillBufferFromVector`
|
||||
terminator) — that is the stop signal. The SDK loops until the buffer carries no rows.
|
||||
|
||||
## R1.6 (localized properties) — no distinct op on 2020
|
||||
|
||||
There is **no** `GetTagLocalizedPropertiesFromName` / `GetTlpByNm` op or
|
||||
`GetTagLocalizedPropertiesByName` method in `current/aahClientManaged.dll` — the only "localized"
|
||||
surfaces are `ClientApp.GetLocalizedText` and `SMessageTextMap.GetLocalizedMessage` (error-message /
|
||||
UI-text localization), not tag properties. So R1.6 **collapses into R1.5**: extended properties
|
||||
(`GetTepByNm`) are the user-defined tag-property read surface on 2020. R1.6 is closed as
|
||||
"no separate op," not left throwing.
|
||||
|
||||
## Shipped surface
|
||||
|
||||
- `HistorianClient.GetTagExtendedPropertiesAsync(tag)` → `IReadOnlyList<HistorianTagExtendedProperty>`
|
||||
(`Name`/`Value` pairs; empty when the tag has none).
|
||||
- `HistorianTagExtendedPropertyProtocol` (serializer/parser), `HistorianWcfTagExtendedPropertyClient`
|
||||
(orchestration), golden `WcfTagExtendedPropertyProtocolTests`, gated live
|
||||
`GetTagExtendedPropertiesAsync_AgainstLocalHistorian_ReturnsProperties` (set `HISTORIAN_TEP_TAG`
|
||||
to a tag with extended properties).
|
||||
@@ -0,0 +1,104 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's GetTagExtendedPropertiesFromName (GetTepByNm) wire traffic
|
||||
(HCAL roadmap R1.5) so the WCF op name, the string-handle format, the tagNames request buffer,
|
||||
and the extended-property response buffer can be decoded instead of guessed.
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the .NET-Framework NativeTraceHarness's tag scenario with --retrieve-extended-properties,
|
||||
which flips TagQueryArgs.RetrieveTagExtendedPropertyInfo and then calls
|
||||
TagQuery.GetTagExtendedPropertyInfo(start, count, out TagExtendedPropertyGroupList, out err) —
|
||||
the managed method that issues the GetTepByNm op. An IL-rewritten copy of aahClientManaged.dll
|
||||
logs every MDAS body (ClientMessageEncoder.WriteMessage + ReadMessage), the same pipeline that
|
||||
produced every other proven request/response shape.
|
||||
|
||||
Decode with scripts/decode-tag-properties-capture.py: locate the WCF.WriteMessage.Body whose op
|
||||
is aa/Retr/GetTepByNm -> that is the request (string handle + tagNames buffer + sequence). The
|
||||
paired WCF.ReadMessage.Body is the extended-property response buffer.
|
||||
|
||||
.NOTES
|
||||
Read-only metadata call; no data is written. Artifacts are diagnostic and gitignored.
|
||||
Sanitize before copying anything into docs/ -- never commit raw capture NDJSON, credentials,
|
||||
hostnames, or customer tag names. SysTimeSec is a built-in system tag (safe to name).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
# A tag (or wildcard) to query extended properties for. SysTimeSec is a built-in system tag
|
||||
# present on every Historian; override with a real tag that carries extended properties for a
|
||||
# richer response decode.
|
||||
[string]$Tag = "SysTimeSec",
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-tag-extended-properties"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
$capturePath = Join-Path $captureDir "tag-extended-properties-capture-latest.ndjson"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
# Chain via a distinct intermediate file (reading+writing the same path drops the second hook on
|
||||
# the mixed-mode native image). Final dll carries both hooks: WCF.WriteMessage.Body + WCF.ReadMessage.Body.
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing tag-extended-properties ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "tag-extended-properties",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--tag", $Tag,
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
)
|
||||
|
||||
$harnessJson = $null
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
|
||||
} catch {
|
||||
Write-Host " (tag-extended-properties raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
Write-Host "Harness output (TagExtendedProperties Success / Groups):" -ForegroundColor Cyan
|
||||
$harnessJson | Select-Object -Last 24
|
||||
Write-Host "`nDecode with: python scripts\decode-tag-properties-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Decode the GetTagExtendedPropertiesFromName (GetTepByNm) WCF request/response (HCAL R1.5).
|
||||
|
||||
Reads the chained WriteMessage+ReadMessage capture produced by
|
||||
scripts/Capture-TagExtendedProperties.ps1, locates the aa/Retr/GetTepByNm exchange, and
|
||||
dumps the tagNames request buffer + tagExtendedProperties response buffer so the op name,
|
||||
the uppercase string handle, the tagNames layout, and the extended-property response layout
|
||||
can be read off.
|
||||
|
||||
Request tagNames buffer:
|
||||
uint32 count + per name: uint32 charCount + UTF-16LE chars.
|
||||
|
||||
Response tagExtendedProperties buffer:
|
||||
uint32 tagCount
|
||||
per tag: byte marker(0x01) + compact-ASCII tagName(0x09 + uint16 len + ascii)
|
||||
uint32 propCount
|
||||
per prop: byte marker(0x02) + compact-ASCII propName(0x09 + uint16 len + ascii)
|
||||
value: 0x43 (VT_BSTR) + uint16 payloadLen + uint16 charCount + UTF-16LE
|
||||
trailing byte(0x01)
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/ (tag names / values are dev data).
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-tag-extended-properties"
|
||||
DEFAULT_CAP = CAPDIR / "tep-localized-capture.ndjson"
|
||||
|
||||
ACTION = re.compile(rb"aa/[A-Za-z0-9]+/[A-Za-z0-9_]+")
|
||||
|
||||
|
||||
def hexdump(label, buf):
|
||||
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" {off:04X} {hp:<48} |{ap}|")
|
||||
print()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cap = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_CAP
|
||||
if not cap.exists():
|
||||
print(f"Missing capture: {cap}\nRun scripts/Capture-TagExtendedProperties.ps1 -Localized first.")
|
||||
return 1
|
||||
|
||||
records = [json.loads(l) for l in cap.open(encoding="utf-8-sig") if l.strip()]
|
||||
print(f"== {len(records)} MDAS bodies captured ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
acts = sorted({m.decode() for m in ACTION.findall(body)})
|
||||
flag = " <== GetTepByNm" if any("Tep" in a for a in acts) else ""
|
||||
print(f" [{idx:02d}] {rec.get('Phase'):24s} len={len(body):5d} {acts}{flag}")
|
||||
|
||||
print("\n== GetTepByNm request(s) [WriteMessage] ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if rec.get("Phase") == "WCF.WriteMessage.Body" and b"GetTepByNm" in body:
|
||||
hexdump(f"[{idx}] request", body)
|
||||
|
||||
print("\n== GetTepByNm response(s) [ReadMessage] ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if rec.get("Phase") == "WCF.ReadMessage.Body" and b"GetTepByNmResponse" in body:
|
||||
hexdump(f"[{idx}] response", body)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -166,6 +166,19 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
return _protocol.GetRuntimeParameterAsync(name, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the extended (user-defined) properties attached to a tag via the 2020 WCF
|
||||
/// <c>GetTepByNm</c> op. Returns the property name/value pairs (empty when the tag has none).
|
||||
/// String-valued properties only (the evidence-backed surface); other value variants throw
|
||||
/// <see cref="ProtocolEvidenceMissingException"/>. See
|
||||
/// <c>HistorianTagExtendedPropertyProtocol</c>.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return _protocol.GetTagExtendedPropertiesAsync(tag, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates the named tag in the Historian Runtime database via
|
||||
/// <c>EnsureTags2</c>. Currently only <see cref="HistorianDataType.Float"/> is
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A single extended (user-defined) property attached to a Historian tag — a name/value pair
|
||||
/// returned by <c>GetTagExtendedPropertiesAsync</c>. Extended properties are stored separately
|
||||
/// from the core tag metadata (server table <c>_TagExtendedProperty</c>) and are exposed over the
|
||||
/// 2020 WCF <c>aa/Retr/GetTepByNm</c> op.
|
||||
/// </summary>
|
||||
/// <param name="Name">The property name (e.g., <c>Location</c>).</param>
|
||||
/// <param name="Value">The property value as a string (the wire format is a VT_BSTR variant).</param>
|
||||
public sealed record HistorianTagExtendedProperty(string Name, string Value);
|
||||
@@ -74,6 +74,13 @@ internal sealed class Historian2020ProtocolDialect
|
||||
return Wcf.HistorianWcfStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Models.HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return Wcf.HistorianWcfTagExtendedPropertyClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> Missing<T>(
|
||||
string operation,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Protocol;
|
||||
|
||||
namespace AVEVA.Historian.Client.Wcf;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the <c>GetTepByNm</c> (GetTagExtendedPropertiesFromName) request buffer and parses
|
||||
/// its response buffer for AVEVA Historian 2020 over WCF/MDAS.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire format captured from the native client (<c>scripts/Capture-TagExtendedProperties.ps1</c>
|
||||
/// + instrument-wcf-{write,read}message; golden-pinned in
|
||||
/// <c>WcfTagExtendedPropertyProtocolTests</c>). The op is
|
||||
/// <c>aa/Retr/GetTepByNm(string handle, byte[] tagNames, ref uint sequence) -> (bool,
|
||||
/// byte[] tagExtendedProperties, byte[] errorBuffer)</c> — a string-handle op reachable from the
|
||||
/// pure-managed client using the Open2 storage-session GUID as an <b>uppercase</b> handle (the
|
||||
/// same format that unlocked GETRP/GETHI/ExeC; see <c>wcf-string-handle-wall.md</c>).
|
||||
///
|
||||
/// <para><b>tagNames</b> request buffer: <c>uint32 count</c> then per name <c>uint32 charCount</c>
|
||||
/// + UTF-16LE chars.</para>
|
||||
///
|
||||
/// <para><b>tagExtendedProperties</b> response buffer: <c>uint32 tagCount</c> then per tag a
|
||||
/// 1-byte group marker (observed <c>0x01</c>) + compact-ASCII tag name (<c>0x09</c> + uint16 byte
|
||||
/// length + ASCII), <c>uint32 propertyCount</c>, then per property a 1-byte marker (observed
|
||||
/// <c>0x02</c>) + compact-ASCII property name + a CRetVariant value (<c>0x43</c> VT_BSTR + uint16
|
||||
/// payload length + uint16 charCount + UTF-16LE), and a 1-byte trailing marker (observed
|
||||
/// <c>0x01</c>). Only the string value variant (<c>0x43</c>) is evidence-backed; other variant
|
||||
/// types throw <see cref="ProtocolEvidenceMissingException"/>.</para>
|
||||
///
|
||||
/// <para>The op is sequence-paged: call with <c>sequence = 0</c>, parse the buffer, then re-call
|
||||
/// with the returned sequence until the response carries no rows. Only string-valued properties
|
||||
/// from a single capture are exercised; the single-byte group/property markers are pinned as
|
||||
/// observed constants and their full semantics are not independently disambiguated.</para>
|
||||
/// </remarks>
|
||||
internal static class HistorianTagExtendedPropertyProtocol
|
||||
{
|
||||
private const byte CompactStringMarker = 0x09;
|
||||
private const byte VariantTypeBStr = 0x43;
|
||||
|
||||
/// <summary>Serializes the <c>tagNames</c> request buffer for a single tag.</summary>
|
||||
public static byte[] SerializeRequest(string tagName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(tagName);
|
||||
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||||
|
||||
writer.Write(1u); // name count = 1
|
||||
writer.Write((uint)tagName.Length); // char count
|
||||
writer.Write(Encoding.Unicode.GetBytes(tagName));
|
||||
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>tagExtendedProperties</c> response buffer into a flat list of
|
||||
/// (tagName, propertyName, value) rows. Returns an empty list when the buffer carries no rows
|
||||
/// (the terminal page of the sequence loop), which is how callers know to stop paging.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<HistorianTagExtendedPropertyRow> ParseResponse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < 4)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
int cursor = 0;
|
||||
uint tagCount = ReadUInt32(buffer, ref cursor);
|
||||
if (tagCount == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<HistorianTagExtendedPropertyRow> rows = [];
|
||||
for (uint t = 0; t < tagCount; t++)
|
||||
{
|
||||
// 1-byte group marker (observed 0x01).
|
||||
SkipByte(buffer, ref cursor);
|
||||
string tagName = ReadCompactAsciiString(buffer, ref cursor);
|
||||
|
||||
uint propertyCount = ReadUInt32(buffer, ref cursor);
|
||||
for (uint p = 0; p < propertyCount; p++)
|
||||
{
|
||||
// 1-byte property marker (observed 0x02 — likely the property value type).
|
||||
SkipByte(buffer, ref cursor);
|
||||
string propertyName = ReadCompactAsciiString(buffer, ref cursor);
|
||||
string value = ReadVariantStringValue(buffer, ref cursor);
|
||||
rows.Add(new HistorianTagExtendedPropertyRow(tagName, propertyName, value));
|
||||
}
|
||||
|
||||
// 1-byte trailing marker (observed 0x01) after the property block. Read it only if a
|
||||
// byte remains so a tightly-packed terminal buffer doesn't over-read.
|
||||
if (cursor < buffer.Length)
|
||||
{
|
||||
SkipByte(buffer, ref cursor);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string ReadCompactAsciiString(ReadOnlySpan<byte> buffer, ref int cursor)
|
||||
{
|
||||
EnsureAvailable(buffer, cursor, 3);
|
||||
byte marker = buffer[cursor++];
|
||||
if (marker != CompactStringMarker)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"GetTepByNm response expected compact string marker 0x09, found 0x{marker:X2}.");
|
||||
}
|
||||
|
||||
ushort byteLength = ReadUInt16(buffer, ref cursor);
|
||||
EnsureAvailable(buffer, cursor, byteLength);
|
||||
string value = Encoding.ASCII.GetString(buffer.Slice(cursor, byteLength));
|
||||
cursor += byteLength;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string ReadVariantStringValue(ReadOnlySpan<byte> buffer, ref int cursor)
|
||||
{
|
||||
EnsureAvailable(buffer, cursor, 5);
|
||||
byte variantType = buffer[cursor++];
|
||||
if (variantType != VariantTypeBStr)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"GetTepByNm response value variant type 0x{variantType:X2} is not the evidence-backed string variant (0x43).");
|
||||
}
|
||||
|
||||
// uint16 payload length (bytes that follow the charCount field), then uint16 charCount.
|
||||
_ = ReadUInt16(buffer, ref cursor);
|
||||
ushort charCount = ReadUInt16(buffer, ref cursor);
|
||||
int byteCount = charCount * 2;
|
||||
EnsureAvailable(buffer, cursor, byteCount);
|
||||
string value = Encoding.Unicode.GetString(buffer.Slice(cursor, byteCount));
|
||||
cursor += byteCount;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void SkipByte(ReadOnlySpan<byte> buffer, ref int cursor)
|
||||
{
|
||||
EnsureAvailable(buffer, cursor, 1);
|
||||
cursor++;
|
||||
}
|
||||
|
||||
private static ushort ReadUInt16(ReadOnlySpan<byte> buffer, ref int cursor)
|
||||
{
|
||||
EnsureAvailable(buffer, cursor, 2);
|
||||
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(cursor, 2));
|
||||
cursor += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(ReadOnlySpan<byte> buffer, ref int cursor)
|
||||
{
|
||||
EnsureAvailable(buffer, cursor, 4);
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(cursor, 4));
|
||||
cursor += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void EnsureAvailable(ReadOnlySpan<byte> buffer, int cursor, int byteCount)
|
||||
{
|
||||
if (cursor < 0 || byteCount < 0 || cursor > buffer.Length - byteCount)
|
||||
{
|
||||
throw new InvalidDataException("GetTepByNm response ended unexpectedly.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One (tag, propertyName, value) row parsed from a GetTepByNm response buffer.</summary>
|
||||
internal sealed record HistorianTagExtendedPropertyRow(string TagName, string PropertyName, string Value);
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf.Contracts;
|
||||
|
||||
namespace AVEVA.Historian.Client.Wcf;
|
||||
|
||||
/// <summary>
|
||||
/// Reads tag extended properties (HCAL R1.5) over the 2020 WCF <c>aa/Retr/GetTepByNm</c> op. This
|
||||
/// is a string-handle op reached with the Open2 storage-session GUID formatted UPPERCASE — the same
|
||||
/// handle format that unlocked GETRP/GETHI/ExeC (see <c>wcf-string-handle-wall.md</c>).
|
||||
/// </summary>
|
||||
internal static class HistorianWcfTagExtendedPropertyClient
|
||||
{
|
||||
// GetTepByNm is sequence-paged. A single tag with a handful of properties returns everything in
|
||||
// the first buffer and an empty buffer on the next call; the cap is a runaway guard.
|
||||
private const int MaxPages = 64;
|
||||
|
||||
/// <summary>Diagnostic: the GetTepByNm return code / error from the last call (set only on
|
||||
/// server rejection).</summary>
|
||||
public static string? LastError { get; private set; }
|
||||
|
||||
public static Task<IReadOnlyList<HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(
|
||||
HistorianClientOptions options,
|
||||
string tag,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return Task.Run(() => GetTagExtendedProperties(options, tag), cancellationToken);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<HistorianTagExtendedProperty> GetTagExtendedProperties(
|
||||
HistorianClientOptions options,
|
||||
string tag)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(options);
|
||||
|
||||
LastError = null;
|
||||
List<HistorianTagExtendedProperty> properties = [];
|
||||
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||
options, histBinding, histEndpoint, contextKey, CancellationToken.None,
|
||||
additionalSetup: (_, context) =>
|
||||
QueryExtendedProperties(retrBinding, retrEndpoint, options, context.StorageSessionId, tag, properties));
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static void QueryExtendedProperties(
|
||||
Binding retrBinding,
|
||||
EndpointAddress retrEndpoint,
|
||||
HistorianClientOptions options,
|
||||
Guid storageSessionId,
|
||||
string tag,
|
||||
List<HistorianTagExtendedProperty> sink)
|
||||
{
|
||||
// GetTepByNm takes the storage-session GUID as a string handle, formatted exactly as the
|
||||
// native client sends it: uppercase, dash-separated, no braces.
|
||||
string handle = storageSessionId.ToString("D").ToUpperInvariant();
|
||||
byte[] tagNames = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag);
|
||||
|
||||
ChannelFactory<IRetrievalServiceContract4> factory = new(retrBinding, retrEndpoint);
|
||||
HistorianWcfClientCredentialsHelper.Configure(factory, options);
|
||||
IRetrievalServiceContract4 channel = factory.CreateChannel();
|
||||
ICommunicationObject co = (ICommunicationObject)channel;
|
||||
try
|
||||
{
|
||||
// Prime the Retrieval service version handshake (Retr.GetV), as the native client does
|
||||
// before any string-handle Retrieval op.
|
||||
channel.GetInterfaceVersion(out _);
|
||||
|
||||
uint sequence = 0;
|
||||
for (int page = 0; page < MaxPages; page++)
|
||||
{
|
||||
bool ok = channel.GetTagExtendedPropertiesFromName(
|
||||
handle, tagNames, ref sequence, out byte[] responseBuffer, out byte[] errorBuffer);
|
||||
if (!ok)
|
||||
{
|
||||
// A non-success on the first page is a real failure; on a later page it is the
|
||||
// benign end-of-paging terminator (server returns nil + a FillBufferFromVector
|
||||
// marker once the rows are exhausted).
|
||||
if (page == 0)
|
||||
{
|
||||
LastError = $"GetTepByNm returned false (responseLen={responseBuffer?.Length ?? 0}, errorLen={errorBuffer?.Length ?? 0}).";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
IReadOnlyList<HistorianTagExtendedPropertyRow> rows =
|
||||
HistorianTagExtendedPropertyProtocol.ParseResponse(responseBuffer ?? []);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (HistorianTagExtendedPropertyRow row in rows)
|
||||
{
|
||||
sink.Add(new HistorianTagExtendedProperty(row.PropertyName, row.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } catch { try { co.Abort(); } catch { } }
|
||||
try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { try { factory.Abort(); } catch { } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,41 @@ public sealed class HistorianClientIntegrationTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTagExtendedPropertiesAsync_AgainstLocalHistorian_ReturnsProperties()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
// A tag that carries at least one extended property. Gated on its own env var so the test
|
||||
// skips cleanly when no such tag is configured (no tag name is hardcoded).
|
||||
string? tepTag = Environment.GetEnvironmentVariable("HISTORIAN_TEP_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host)
|
||||
|| !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)
|
||||
|| !OperatingSystem.IsWindows()
|
||||
|| string.IsNullOrWhiteSpace(tepTag))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
// GetTepByNm rides the storage-session GUID as an uppercase string handle. The configured
|
||||
// tag has at least one string-valued extended property (e.g. Location).
|
||||
IReadOnlyList<AVEVA.Historian.Client.Models.HistorianTagExtendedProperty> properties =
|
||||
await client.GetTagExtendedPropertiesAsync(tepTag, CancellationToken.None);
|
||||
|
||||
Assert.NotEmpty(properties);
|
||||
Assert.All(properties, p =>
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.Name));
|
||||
Assert.NotNull(p.Value);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionStatusAsync_AgainstLocalHistorian_ReportsConnectedToServer()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using AVEVA.Historian.Client.Protocol;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
|
||||
namespace AVEVA.Historian.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-byte tests for the GetTepByNm (GetTagExtendedPropertiesFromName) codec. The byte layout
|
||||
/// is pinned to the live capture documented on <see cref="HistorianTagExtendedPropertyProtocol"/>
|
||||
/// (scripts/Capture-TagExtendedProperties.ps1); synthetic tag/property values are used here so no
|
||||
/// dev tag names land in the repo, but the framing is byte-for-byte the captured structure.
|
||||
/// </summary>
|
||||
public sealed class WcfTagExtendedPropertyProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializeRequest_SingleTag_MatchesCapturedLayout()
|
||||
{
|
||||
// tagNames = uint32 count(1) + uint32 charCount + UTF-16LE chars.
|
||||
const string tag = "Reactor.Temp1";
|
||||
byte[] actual = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag);
|
||||
|
||||
using MemoryStream expected = new();
|
||||
using (BinaryWriter w = new(expected, Encoding.Unicode, leaveOpen: true))
|
||||
{
|
||||
w.Write(1u);
|
||||
w.Write((uint)tag.Length);
|
||||
w.Write(Encoding.Unicode.GetBytes(tag));
|
||||
}
|
||||
|
||||
Assert.Equal(expected.ToArray(), actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_SingleStringProperty_ExtractsNameAndValue()
|
||||
{
|
||||
byte[] buffer = BuildResponse("Reactor.Temp1", "Location", "Plant/AreaA");
|
||||
|
||||
IReadOnlyList<HistorianTagExtendedPropertyRow> rows =
|
||||
HistorianTagExtendedPropertyProtocol.ParseResponse(buffer);
|
||||
|
||||
HistorianTagExtendedPropertyRow row = Assert.Single(rows);
|
||||
Assert.Equal("Reactor.Temp1", row.TagName);
|
||||
Assert.Equal("Location", row.PropertyName);
|
||||
Assert.Equal("Plant/AreaA", row.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_ZeroTagCount_ReturnsEmpty_ForPagingTermination()
|
||||
{
|
||||
// The terminal page of the sequence loop carries tagCount = 0.
|
||||
byte[] terminal = [0x00, 0x00, 0x00, 0x00];
|
||||
Assert.Empty(HistorianTagExtendedPropertyProtocol.ParseResponse(terminal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_NonStringVariant_Throws()
|
||||
{
|
||||
// Replace the 0x43 VT_BSTR marker with an unmapped variant type (0x03).
|
||||
byte[] buffer = BuildResponse("Reactor.Temp1", "Location", "Plant/AreaA");
|
||||
int variantOffset = Array.IndexOf(buffer, (byte)0x43);
|
||||
Assert.True(variantOffset > 0);
|
||||
buffer[variantOffset] = 0x03;
|
||||
|
||||
Assert.Throws<ProtocolEvidenceMissingException>(
|
||||
() => HistorianTagExtendedPropertyProtocol.ParseResponse(buffer));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a GetTepByNm response buffer byte-for-byte per the captured layout: uint32 tagCount,
|
||||
/// then per tag [marker 0x01][compact-ASCII name][uint32 propCount][per prop marker 0x02 +
|
||||
/// compact-ASCII name + 0x43 VT_BSTR value][trailing 0x01].
|
||||
/// </summary>
|
||||
private static byte[] BuildResponse(string tag, string propName, string propValue)
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using BinaryWriter w = new(ms, Encoding.ASCII, leaveOpen: true);
|
||||
|
||||
WriteUInt32(w, 1u); // tagCount
|
||||
w.Write((byte)0x01); // group marker
|
||||
WriteCompactAscii(w, tag);
|
||||
WriteUInt32(w, 1u); // propertyCount
|
||||
w.Write((byte)0x02); // property marker
|
||||
WriteCompactAscii(w, propName);
|
||||
WriteVariantString(w, propValue);
|
||||
w.Write((byte)0x01); // trailing marker
|
||||
|
||||
w.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteUInt32(BinaryWriter w, uint value)
|
||||
{
|
||||
Span<byte> b = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b, value);
|
||||
w.Write(b);
|
||||
}
|
||||
|
||||
private static void WriteUInt16(BinaryWriter w, ushort value)
|
||||
{
|
||||
Span<byte> b = stackalloc byte[2];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(b, value);
|
||||
w.Write(b);
|
||||
}
|
||||
|
||||
private static void WriteCompactAscii(BinaryWriter w, string value)
|
||||
{
|
||||
w.Write((byte)0x09);
|
||||
WriteUInt16(w, (ushort)value.Length);
|
||||
w.Write(Encoding.ASCII.GetBytes(value));
|
||||
}
|
||||
|
||||
private static void WriteVariantString(BinaryWriter w, string value)
|
||||
{
|
||||
w.Write((byte)0x43); // VT_BSTR
|
||||
WriteUInt16(w, (ushort)(2 + value.Length * 2)); // payload length = charCount field + UTF-16 bytes
|
||||
WriteUInt16(w, (ushort)value.Length); // char count
|
||||
w.Write(Encoding.Unicode.GetBytes(value));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,12 @@ internal static class Program
|
||||
string? aggregationTypeName = GetArg(args, "--aggregation-type");
|
||||
uint maxStates = uint.TryParse(GetArg(args, "--max-states"), out uint parsedMaxStates) ? parsedMaxStates : 0;
|
||||
string? historyFilter = GetArg(args, "--filter");
|
||||
// R1.5 capture: flip the TagQueryArgs flag that makes the native client retrieve extended
|
||||
// properties in the index-based TagQuery path. (The dedicated tag-extended-properties
|
||||
// scenario, which drives the name-based GetTagExtendedPropertiesByName, is the reliable
|
||||
// GetTepByNm capture path; the TagQuery path is gated behind QTB, which fails server-side
|
||||
// here.) Off by default so the normal tag-query scenario is unchanged.
|
||||
bool retrieveExtendedProperties = HasFlag(args, "--retrieve-extended-properties");
|
||||
DateTime endUtc = TryParseUtc(GetArg(args, "--end-utc")) ?? DateTime.UtcNow;
|
||||
DateTime startUtc = TryParseUtc(GetArg(args, "--start-utc")) ?? endUtc.AddMinutes(-lookbackMinutes);
|
||||
|
||||
@@ -246,6 +252,77 @@ internal static class Program
|
||||
}));
|
||||
return 0;
|
||||
}
|
||||
else if (openSuccess && status.ConnectedToServer && IsTagExtendedPropertiesScenario(scenario))
|
||||
{
|
||||
// R1.5 capture: drive HistorianAccess.GetTagExtendedPropertiesByName(string tagName,
|
||||
// bool fetchFromServer, out TagExtendedPropertyGroup, out error) directly. This is the
|
||||
// NAME-based entry point that issues the GetTepByNm WCF op WITHOUT a prior
|
||||
// StartTagQuery (QTB) — the index-based TagQuery.GetTagExtendedPropertyInfo path is
|
||||
// blocked here because QTB fails server-side (CMdServer StartActiveTagnamesQuery).
|
||||
// The second GetTagExtendedPropertiesByName arg forces a server fetch (issues GetTepByNm)
|
||||
// when true; when false the C++ client reads its local cache and returns err 41 if the
|
||||
// tag's properties were never fetched. Default true so the scenario captures GetTepByNm;
|
||||
// pass --tep-cache-only to exercise the cache-read (no WCF op) path.
|
||||
bool fetchFromServer = !HasFlag(args, "--tep-cache-only");
|
||||
|
||||
// Prime the tag identity table first (ProcessTagNameIdentity inside
|
||||
// GetTagExtendedPropertiesByName fails with err 41 if the tag was never resolved on
|
||||
// this connection). GetTagInfoByName(tagName, cache, out HistorianTag, out err) is the
|
||||
// proven uint-handle metadata path that registers the tag.
|
||||
string? primeResult = null;
|
||||
MethodInfo? getTagInfoByName = accessType.GetMethods()
|
||||
.FirstOrDefault(m => m.Name == "GetTagInfoByName" && m.GetParameters().Length == 4);
|
||||
if (getTagInfoByName is not null)
|
||||
{
|
||||
ParameterInfo[] tibParams = getTagInfoByName.GetParameters();
|
||||
Type tagOutType = tibParams[2].ParameterType.GetElementType()!;
|
||||
object tibError = Activator.CreateInstance(errorType)!;
|
||||
object?[] tibArgs = new object?[] { tagName, true, null, tibError };
|
||||
try
|
||||
{
|
||||
bool tibOk = (bool)getTagInfoByName.Invoke(access, tibArgs)!;
|
||||
primeResult = $"GetTagInfoByName={tibOk} err={GetPropertyText(tibArgs[3], "ErrorDescription")}";
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
primeResult = "GetTagInfoByName threw: " + FormatException(ex.InnerException ?? ex);
|
||||
}
|
||||
}
|
||||
|
||||
MethodInfo getTepByName = accessType.GetMethods()
|
||||
.First(m => m.Name == "GetTagExtendedPropertiesByName" && m.GetParameters().Length == 4);
|
||||
ParameterInfo[] tepParams = getTepByName.GetParameters();
|
||||
Type groupType = tepParams[2].ParameterType.GetElementType()!; // TagExtendedPropertyGroup& -> TagExtendedPropertyGroup
|
||||
|
||||
object tepError = Activator.CreateInstance(errorType)!;
|
||||
object?[] tepArgs = new object?[] { tagName, fetchFromServer, null, tepError };
|
||||
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-get-tag-extended-properties");
|
||||
bool tepOk = false;
|
||||
string? tepException = null;
|
||||
try
|
||||
{
|
||||
tepOk = (bool)getTepByName.Invoke(access, tepArgs)!;
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
tepException = FormatException(ex.InnerException ?? ex);
|
||||
}
|
||||
|
||||
Console.WriteLine(Serialize(new
|
||||
{
|
||||
Scenario = scenario,
|
||||
TagName = tagName,
|
||||
FetchFromServer = fetchFromServer,
|
||||
Prime = primeResult,
|
||||
GroupType = groupType.FullName,
|
||||
GetTagExtendedPropertiesByNameReturned = tepOk,
|
||||
Exception = tepException,
|
||||
Group = ToSerializableValue(tepArgs[2]),
|
||||
GroupSnapshot = tepArgs[2] is null ? null : SnapshotObject(tepArgs[2]!),
|
||||
Error = SnapshotObject(tepArgs[3]!),
|
||||
}));
|
||||
return 0;
|
||||
}
|
||||
else if (openSuccess && status.ConnectedToServer && IsEventSendScenario(scenario))
|
||||
{
|
||||
// R2.1 capture: drive AddStreamedValue(HistorianEvent) and let instrument-wcf-*
|
||||
@@ -916,7 +993,7 @@ internal static class Program
|
||||
object queryArgs = Activator.CreateInstance(tagQueryArgsType)!;
|
||||
SetProperty(queryArgs, "TagFilter", tagName);
|
||||
SetProperty(queryArgs, "CacheTagInfo", true);
|
||||
SetProperty(queryArgs, "RetrieveTagExtendedPropertyInfo", false);
|
||||
SetProperty(queryArgs, "RetrieveTagExtendedPropertyInfo", retrieveExtendedProperties);
|
||||
snapshots["TagQueryArgsBeforeStart"] = SnapshotObject(queryArgs);
|
||||
|
||||
startError = Activator.CreateInstance(errorType)!;
|
||||
@@ -980,6 +1057,42 @@ internal static class Program
|
||||
Tags = SummarizeTagList(tagInfoArgs[2])
|
||||
});
|
||||
}
|
||||
|
||||
// R1.5 capture: explicitly pull extended properties so the native client issues
|
||||
// the GetTepByNm WCF op (only fires when --retrieve-extended-properties is set,
|
||||
// which flips RetrieveTagExtendedPropertyInfo on the query args above).
|
||||
if (retrieveExtendedProperties)
|
||||
{
|
||||
MethodInfo? getTepMethod = queryType.GetMethods().FirstOrDefault(method =>
|
||||
method.Name == "GetTagExtendedPropertyInfo" && method.GetParameters().Length == 4);
|
||||
if (getTepMethod is not null)
|
||||
{
|
||||
object tepError = Activator.CreateInstance(errorType)!;
|
||||
object?[] tepArgs = [0u, requestedRows, null, tepError];
|
||||
bool tepSuccess = false;
|
||||
try
|
||||
{
|
||||
tepSuccess = (bool)getTepMethod.Invoke(query, tepArgs)!;
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
rows.Add(new { Kind = "TagExtendedPropertyException", Detail = FormatException(ex.InnerException ?? ex) });
|
||||
}
|
||||
tepError = tepArgs[3]!;
|
||||
rows.Add(new
|
||||
{
|
||||
Kind = "TagExtendedProperties",
|
||||
Success = tepSuccess,
|
||||
ErrorDescription = GetPropertyText(tepError, "ErrorDescription"),
|
||||
ErrorCode = GetPropertyText(tepError, "ErrorCode"),
|
||||
Groups = ToSerializableValue(tepArgs[2])
|
||||
});
|
||||
if (tepArgs[2] is not null)
|
||||
{
|
||||
snapshots["TagExtendedPropertyGroups"] = SnapshotObject(tepArgs[2]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
|
||||
@@ -1513,6 +1626,18 @@ internal static class Program
|
||||
|| scenario.Equals("sql", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tag extended-properties scenario (R1.5 capture): opens a normal authenticated process
|
||||
/// connection and calls the NAME-based <c>GetTagExtendedPropertiesByName</c> so the GetTepByNm
|
||||
/// WCF op + tagNames request / extended-property response buffers can be captured. This
|
||||
/// bypasses the QTB (StartTagQuery) path, which fails server-side here.
|
||||
/// </summary>
|
||||
private static bool IsTagExtendedPropertiesScenario(string scenario)
|
||||
{
|
||||
return scenario.Equals("tag-extended-properties", StringComparison.OrdinalIgnoreCase)
|
||||
|| scenario.Equals("tag-tep", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsEventConnectionScenario(string scenario)
|
||||
{
|
||||
return IsEventScenario(scenario) || IsEventSendScenario(scenario);
|
||||
|
||||
Reference in New Issue
Block a user