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:
Joseph Doherty
2026-06-20 22:52:07 -04:00
parent 4da5287d01
commit 108220c36b
13 changed files with 897 additions and 8 deletions
+104
View File
@@ -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
+75
View File
@@ -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())