diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index 9c8558c..e459cbd 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -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) | — | diff --git a/docs/reverse-engineering/wcf-string-handle-wall.md b/docs/reverse-engineering/wcf-string-handle-wall.md index 6fdd4fb..34b2122 100644 --- a/docs/reverse-engineering/wcf-string-handle-wall.md +++ b/docs/reverse-engineering/wcf-string-handle-wall.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. --- diff --git a/docs/reverse-engineering/wcf-tag-extended-properties.md b/docs/reverse-engineering/wcf-tag-extended-properties.md new file mode 100644 index 0000000..e0d4816 --- /dev/null +++ b/docs/reverse-engineering/wcf-tag-extended-properties.md @@ -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` + (`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). diff --git a/scripts/Capture-TagExtendedProperties.ps1 b/scripts/Capture-TagExtendedProperties.ps1 new file mode 100644 index 0000000..b415532 --- /dev/null +++ b/scripts/Capture-TagExtendedProperties.ps1 @@ -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 diff --git a/scripts/decode-tag-properties-capture.py b/scripts/decode-tag-properties-capture.py new file mode 100644 index 0000000..3d5be0d --- /dev/null +++ b/scripts/decode-tag-properties-capture.py @@ -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()) diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index 2a24954..54b00f6 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -166,6 +166,19 @@ public sealed class HistorianClient : IAsyncDisposable return _protocol.GetRuntimeParameterAsync(name, cancellationToken); } + /// + /// Reads the extended (user-defined) properties attached to a tag via the 2020 WCF + /// GetTepByNm 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 + /// HistorianTagExtendedPropertyProtocol. + /// + public Task> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tag); + return _protocol.GetTagExtendedPropertiesAsync(tag, cancellationToken); + } + /// /// Creates or updates the named tag in the Historian Runtime database via /// EnsureTags2. Currently only is diff --git a/src/AVEVA.Historian.Client/Models/HistorianTagExtendedProperty.cs b/src/AVEVA.Historian.Client/Models/HistorianTagExtendedProperty.cs new file mode 100644 index 0000000..15bbe5e --- /dev/null +++ b/src/AVEVA.Historian.Client/Models/HistorianTagExtendedProperty.cs @@ -0,0 +1,11 @@ +namespace AVEVA.Historian.Client.Models; + +/// +/// A single extended (user-defined) property attached to a Historian tag — a name/value pair +/// returned by GetTagExtendedPropertiesAsync. Extended properties are stored separately +/// from the core tag metadata (server table _TagExtendedProperty) and are exposed over the +/// 2020 WCF aa/Retr/GetTepByNm op. +/// +/// The property name (e.g., Location). +/// The property value as a string (the wire format is a VT_BSTR variant). +public sealed record HistorianTagExtendedProperty(string Name, string Value); diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index eb9d70e..9deb2e7 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -74,6 +74,13 @@ internal sealed class Historian2020ProtocolDialect return Wcf.HistorianWcfStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken); } + public Task> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentException.ThrowIfNullOrWhiteSpace(tag); + return Wcf.HistorianWcfTagExtendedPropertyClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken); + } + private static async IAsyncEnumerable Missing( string operation, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs new file mode 100644 index 0000000..ef42330 --- /dev/null +++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs @@ -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; + +/// +/// Serializes the GetTepByNm (GetTagExtendedPropertiesFromName) request buffer and parses +/// its response buffer for AVEVA Historian 2020 over WCF/MDAS. +/// +/// +/// Wire format captured from the native client (scripts/Capture-TagExtendedProperties.ps1 +/// + instrument-wcf-{write,read}message; golden-pinned in +/// WcfTagExtendedPropertyProtocolTests). The op is +/// aa/Retr/GetTepByNm(string handle, byte[] tagNames, ref uint sequence) -> (bool, +/// byte[] tagExtendedProperties, byte[] errorBuffer) — a string-handle op reachable from the +/// pure-managed client using the Open2 storage-session GUID as an uppercase handle (the +/// same format that unlocked GETRP/GETHI/ExeC; see wcf-string-handle-wall.md). +/// +/// tagNames request buffer: uint32 count then per name uint32 charCount +/// + UTF-16LE chars. +/// +/// tagExtendedProperties response buffer: uint32 tagCount then per tag a +/// 1-byte group marker (observed 0x01) + compact-ASCII tag name (0x09 + uint16 byte +/// length + ASCII), uint32 propertyCount, then per property a 1-byte marker (observed +/// 0x02) + compact-ASCII property name + a CRetVariant value (0x43 VT_BSTR + uint16 +/// payload length + uint16 charCount + UTF-16LE), and a 1-byte trailing marker (observed +/// 0x01). Only the string value variant (0x43) is evidence-backed; other variant +/// types throw . +/// +/// The op is sequence-paged: call with sequence = 0, 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. +/// +internal static class HistorianTagExtendedPropertyProtocol +{ + private const byte CompactStringMarker = 0x09; + private const byte VariantTypeBStr = 0x43; + + /// Serializes the tagNames request buffer for a single tag. + 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(); + } + + /// + /// Parses the tagExtendedProperties 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. + /// + public static IReadOnlyList ParseResponse(ReadOnlySpan buffer) + { + if (buffer.Length < 4) + { + return []; + } + + int cursor = 0; + uint tagCount = ReadUInt32(buffer, ref cursor); + if (tagCount == 0) + { + return []; + } + + List 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 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 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 buffer, ref int cursor) + { + EnsureAvailable(buffer, cursor, 1); + cursor++; + } + + private static ushort ReadUInt16(ReadOnlySpan 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 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 buffer, int cursor, int byteCount) + { + if (cursor < 0 || byteCount < 0 || cursor > buffer.Length - byteCount) + { + throw new InvalidDataException("GetTepByNm response ended unexpectedly."); + } + } +} + +/// One (tag, propertyName, value) row parsed from a GetTepByNm response buffer. +internal sealed record HistorianTagExtendedPropertyRow(string TagName, string PropertyName, string Value); diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagExtendedPropertyClient.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagExtendedPropertyClient.cs new file mode 100644 index 0000000..067de70 --- /dev/null +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagExtendedPropertyClient.cs @@ -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; + +/// +/// Reads tag extended properties (HCAL R1.5) over the 2020 WCF aa/Retr/GetTepByNm 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 wcf-string-handle-wall.md). +/// +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; + + /// Diagnostic: the GetTepByNm return code / error from the last call (set only on + /// server rejection). + public static string? LastError { get; private set; } + + public static Task> GetTagExtendedPropertiesAsync( + HistorianClientOptions options, + string tag, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tag); + return Task.Run(() => GetTagExtendedProperties(options, tag), cancellationToken); + } + + private static IReadOnlyList GetTagExtendedProperties( + HistorianClientOptions options, + string tag) + { + Guid contextKey = Guid.NewGuid(); + var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(options); + + LastError = null; + List 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 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 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 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 { } } + } + } +} diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs index 6b38821..18323e6 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs @@ -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 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() { diff --git a/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs new file mode 100644 index 0000000..a13ab65 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs @@ -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; + +/// +/// Golden-byte tests for the GetTepByNm (GetTagExtendedPropertiesFromName) codec. The byte layout +/// is pinned to the live capture documented on +/// (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. +/// +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 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( + () => HistorianTagExtendedPropertyProtocol.ParseResponse(buffer)); + } + + /// + /// 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]. + /// + 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 b = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(b, value); + w.Write(b); + } + + private static void WriteUInt16(BinaryWriter w, ushort value) + { + Span 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)); + } +} diff --git a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs index d2f0f60..5d14c59 100644 --- a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs +++ b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs @@ -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); } + /// + /// Tag extended-properties scenario (R1.5 capture): opens a normal authenticated process + /// connection and calls the NAME-based GetTagExtendedPropertiesByName 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. + /// + 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);