R1.11 AddTagExtendedPropertiesAsync: extended-property write via AddTEx

Adds user-defined extended properties to an existing tag via the 2020 WCF
AddTEx (AddTagExtendedProperties) op. Write-enabled connection + uppercase
storage-session GUID handle; reuses the write orchestrator open/priming chain.

The AddTEx inBuff is the exact inverse of the R1.5 GetTepByNm read-response
framing, so the serializer mirrors the read parser:
  uint32 groupCount + 0x01(group) + [0x09+u16+ASCII tag] + uint32 propCount
  + per prop{ 0x02 + [0x09+u16+ASCII name] + 0x43 VT_BSTR + u16 payloadLen
  + u16 charCount + UTF-16 value } + 0x01(group trailer) + 0x00(terminator).
The trailing 0x00 is required — without it inBuff is one byte short and the
server throws SErrorException in CHistStorage::AddTagExtendedProperties. The
golden fixture pins the clean inBuff the live server accepted (dumped via
AVEVA_HISTORIAN_TEP_DUMP); read-back verified via R1.5. String (0x43) values only.

Delete (DelTep) is deferred: the native DeleteTagExtendedPropertiesByName does a
client-side sync check and returns err 229 for a just-added property, so the
DelTep request never reaches the wire and its inBuff can't be captured yet.

Shipped: HistorianClient.AddTagExtendedPropertiesAsync/AddTagExtendedPropertyAsync;
HistorianTagExtendedPropertyProtocol.SerializeAddRequest; orchestrator path;
golden WcfTagExtendedPropertyWriteProtocolTests (4); gated live write/read-back test;
native-harness `add-tep` scenario + Capture-AddTagExtendedProperties.ps1 +
decode-add-tep-capture.py. Doc: wcf-add-tag-extended-properties.md. 233 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-21 01:43:19 -04:00
parent 108220c36b
commit 08b950caee
11 changed files with 701 additions and 3 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ blob needs RE).
| Create string/discrete tag | `AddTag` | `History.EnsureTags` | ⬜ | GATED/BOUNDED | native AddTag rejects these types server-side; needs different metadata path |
| Delete tag(s) | `DeleteTags` | `History.DeleteTags` | ✅ | DONE | |
| Rename tag(s) | `RenameTags` | (History op) | ⬜ | BOUNDED | `AllowRenameTags` param already probed |
| Add/Delete extended properties | `AddTagExtendedProperties`, `DeleteTagExtendedPropertiesByName` | `History.AddTagExtendedProperties` / `DeleteTagExtendedProperties` | | BOUNDED | gRPC op + TEP serialize |
| Add/Delete extended properties | `AddTagExtendedProperties`, `DeleteTagExtendedPropertiesByName` | `History.AddTagExtendedProperties` / `DeleteTagExtendedProperties` | 🟗 | BOUNDED | **Add DONE** (`AddTagExtendedPropertiesAsync`, AddTEx; inBuff = inverse of R1.5 read framing + trailing `01 00`). Delete (DelTep) deferred — native sync gate (err 229) blocks capturing its inBuff. See `wcf-add-tag-extended-properties.md` |
| Add/Delete localized properties | `AddTagLocalizedProperties`, `DeleteTagLocalizedPropertiesByName` | `History.AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` | ⬜ | BOUNDED | |
## 6. Data writes — values
+1 -1
View File
@@ -121,7 +121,7 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
| ID | Capability | gRPC op | Payload | Notes |
|---|---|---|---|---|
| R1.10 | `RenameTagsAsync` | History rename op | rename request buffer | `AllowRenameTags` already probed |
| R1.11 | Extended-property **write** | `History.AddTagExtendedProperties` (+ groups) / `DeleteTagExtendedProperties` | TEP serialize | mirror analog CTagMetadata discipline |
| ~~R1.11~~ | Extended-property **write** | `History.AddTagExtendedProperties` (AddTEx) | ✅ **Add DONE (2026-06-21), live-verified.** `AddTagExtendedPropertiesAsync`/`AddTagExtendedPropertyAsync` (write mode, uppercase handle). inBuff = exact inverse of the R1.5 read framing (`uint32 groupCount + 0x01 + compact-ASCII tag + uint32 propCount + per prop[0x02 + compact-ASCII name + 0x43 VT_BSTR value] + 0x01 trailer + 0x00 terminator`); the trailing `0x00` is required or the server throws. Golden `WcfTagExtendedPropertyWriteProtocolTests` + gated live write/read-back test. **Delete (DelTep) deferred** — native client-side sync gate (err 229) blocks capturing its inBuff. See `docs/reverse-engineering/wcf-add-tag-extended-properties.md`. |
| R1.12 | Localized-property **write** | `History.AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` | localized serialize | |
| R1.13 | Non-analog tag create (string/discrete) | `History.EnsureTags` | distinct CTagMetadata variant | ⚠ native AddTag rejected some types — confirm server path first; may be GATED |
@@ -0,0 +1,72 @@
# Extended-property write over 2020 WCF — AddTEx (HCAL R1.11)
**Status: ✅ Add DONE + live-verified (2026-06-21). Delete (DelTep) deferred — see below.**
`HistorianClient.AddTagExtendedPropertiesAsync` / `AddTagExtendedPropertyAsync` writes user-defined
extended properties onto an existing tag via the 2020 WCF **`AddTEx`** (AddTagExtendedProperties) op,
and they read back via the R1.5 `GetTagExtendedPropertiesAsync` path. Verified end-to-end from the
pure-managed .NET 10 client against the local 2020 Historian (create tag → add property → read back →
delete tag).
## The op
```
bool AddTagExtendedProperties(string handle, byte[] inBuff, out byte[] errorBuffer) // AddTEx
```
On `IHistoryServiceContract2` (History service). Requires a **write-enabled** connection (Open2 mode
`0x401`) and the uppercase storage-session GUID handle — the SDK reuses the write orchestrator's
open + priming chain (the same one used by EnsT2/DelT). The tag is referenced by name inside `inBuff`;
no extra per-connection tag registration was needed (the server resolves it).
## The inBuff — the exact inverse of the R1.5 read response
The native `AddTagExtendedProperties(TagExtendedPropertyGroupList, out err)` packs its groups into the
`AddTEx` `inBuff` with the **same framing the R1.5 `GetTepByNm` response uses**, so the write serializer
is the inverse of `HistorianTagExtendedPropertyProtocol.ParseResponse`:
```
uint32 groupCount (= 1)
byte 0x01 (group marker)
0x09 + uint16 byteLen + ASCII tagName (compact-ASCII string)
uint32 propertyCount
repeated propertyCount times:
byte 0x02 (property marker)
0x09 + uint16 byteLen + ASCII propertyName
0x43 + uint16 payloadLen + uint16 charCount + UTF-16LE value (VT_BSTR variant; payloadLen = 2 + charCount*2)
byte 0x01 (group trailer)
byte 0x00 (buffer terminator)
```
⚠️ **The trailing `0x01 0x00`** matters: the group trailer is `0x01` (as in the read parser) **plus a
final `0x00` buffer terminator**. Omitting the `0x00` makes `inBuff` one byte short and the server throws
`SErrorException` in `aahClientAccessPoint::CHistStorage::AddTagExtendedProperties` (AddTEx returns
false). The read parser tolerates the extra byte because it only consumes one trailing byte per group.
Only the string (`0x43` VT_BSTR) value variant is evidence-backed (matching the read path). The raw
instrument capture mangles the final byte with MDAS chunk markers, so the golden fixture pins the
**clean** byte[] the SDK handed the channel (dumped via `AVEVA_HISTORIAN_TEP_DUMP`) — the exact buffer
the live server accepted.
## Delete (DelTep) — deferred
`DeleteTagExtendedProperties` (`DelTep`) is **not shipped yet**. The native
`DeleteTagExtendedPropertiesByName(tag, propertyNames, deleteFromServer, out err)` performs a
**client-side sync check** and returns error **229 ("Tag extended property not synchronized with
server")** when deleting a just-added property — so the `DelTep` request never reached the wire and its
inBuff could not be captured. Capturing it needs a property that is already server-synchronized (add it
in one session, then delete in a later one). Left for a follow-up rather than shipping a guessed buffer.
## Shipped surface
- `HistorianClient.AddTagExtendedPropertiesAsync(tag, IReadOnlyList<HistorianTagExtendedProperty>)` and
`AddTagExtendedPropertyAsync(tag, name, value)`.
- `HistorianTagExtendedPropertyProtocol.SerializeAddRequest` (the inBuff serializer; lives beside the
R1.5 read parser); orchestrator path in `HistorianWcfTagWriteOrchestrator`.
- Golden `WcfTagExtendedPropertyWriteProtocolTests` (pins the server-accepted buffer + layout); gated
live test `AddTagExtendedPropertiesAsync_AgainstLocalHistorian_WritesAndReadsBack`.
## Capture / decode tooling
`scripts/Capture-AddTagExtendedProperties.ps1` (native-harness `add-tep` scenario +
instrument-wcf-{write,read}message; sandbox-guarded create→add→[optional delete]) and
`scripts/decode-add-tep-capture.py`.
@@ -0,0 +1,104 @@
<#
.SYNOPSIS
Captures the native AVEVA client's AddTagExtendedProperties / DeleteTagExtendedProperties wire
traffic (HCAL roadmap R1.11) so the AddTEx / DelTep inBuff layout can be decoded, not guessed.
.DESCRIPTION
Drives the .NET-Framework NativeTraceHarness's `add-tep` scenario against the live Historian with
an IL-rewritten copy of aahClientManaged.dll whose ClientMessageEncoder.WriteMessage AND
ReadMessage are instrumented. The harness opens a WRITE-enabled connection, creates a sandbox tag
(RetestSdkWrite...), and calls AddTagExtendedProperties(TagExtendedPropertyGroupList, out err) with
one string property (and DeleteTagExtendedPropertiesByName when -Delete).
Decode with scripts/decode-add-tep-capture.py: the WCF.WriteMessage.Body whose op is AddTEx carries
the inBuff (tag name + property name/value); DelTep carries the delete inBuff (tag + property names).
SAFETY: sandbox-guarded — the tag MUST start with 'RetestSdkWrite'. Default run leaves the tag +
property in place (unless -Delete); pass -Delete to also capture DelTep and remove the property.
.NOTES
Artifacts are diagnostic and gitignored. Sanitize before copying into docs/.
#>
[CmdletBinding()]
param(
[string]$ServerName = "localhost",
[int]$TcpPort = 32568,
[string]$TepTag = "RetestSdkWriteTepTag",
[string]$PropName = "SdkTestProp",
[string]$PropValue = "SdkTestValue",
[switch]$Delete,
[string]$Configuration = "Debug"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
if (-not $TepTag.StartsWith("RetestSdkWrite")) { throw "-TepTag must start with 'RetestSdkWrite' (sandbox guard)." }
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-add-tep"
$currentCopy = Join-Path $captureDir "current-copy"
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
$capturePath = Join-Path $captureDir "add-tep-capture-latest.ndjson"
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
Write-Host "== Capturing add-tep ($TepTag : $PropName=$PropValue) ==" -ForegroundColor Green
$harnessArgs = @(
"--scenario", "add-tep",
"--server-name", $ServerName,
"--tcp-port", "$TcpPort",
"--tep-tag", $TepTag,
"--tep-name", $PropName,
"--tep-value", $PropValue,
"--current-dir", $currentCopy,
"--managed-dll-path", $harnessDll
)
if ($Delete) { $harnessArgs += "--tep-delete" }
$harnessJson = $null
try {
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
} catch {
Write-Host " (add-tep 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 (AddTagExtendedProperties / Rows):" -ForegroundColor Cyan
$harnessJson | Select-Object -Last 24
Write-Host "`nDecode with: python scripts\decode-add-tep-capture.py" -ForegroundColor Cyan
+115
View File
@@ -0,0 +1,115 @@
"""Decode the AddTagExtendedProperties / DeleteTagExtendedProperties WCF inBuff (HCAL R1.11).
Reads the capture produced by scripts/Capture-AddTagExtendedProperties.ps1 and locates the AddTEx /
DelTep WriteMessage bodies by the sandbox tag + property name/value, then dumps the inBuff bytes so
the framing (tag name, property count, per-property name + value markers) can be read off. Compare to
the R1.5 read-response encoding in HistorianTagExtendedPropertyProtocol.
Output is diagnostic. Sanitize before copying into docs/.
"""
import base64
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-add-tep"
CAP = CAPDIR / "add-tep-capture-latest.ndjson"
TAG = "RetestSdkWriteTepTag"
PROP = "SdkTestProp"
VALUE = "SdkTestValue"
OP_ADD = b"AddTEx"
OP_DEL = b"DelTep"
def hexdump(label, buf, base=0):
print(f"=== {label}: {len(buf)} bytes ===")
for off in range(0, len(buf), 16):
c = buf[off:off + 16]
hp = " ".join(f"{x:02X}" for x in c)
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
print(f" {base + off:04X} {hp:<48} |{ap}|")
print()
def ascii_strings(buf, minlen=3):
out, cur, start = [], [], 0
for i, x in enumerate(buf):
if 32 <= x < 127:
if not cur:
start = i
cur.append(chr(x))
else:
if len(cur) >= minlen:
out.append((start, "".join(cur)))
cur = []
if len(cur) >= minlen:
out.append((start, "".join(cur)))
return out
def u16_strings(buf, minlen=3):
out, i = [], 0
while i < len(buf) - 1:
j, chars = i, []
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
chars.append(chr(buf[j]))
j += 2
if len(chars) >= minlen:
out.append((i, "".join(chars)))
i = j
else:
i += 1
return out
def main() -> int:
if not CAP.exists():
print(f"Missing capture: {CAP}\nRun scripts/Capture-AddTagExtendedProperties.ps1 first.")
return 1
records = []
for line in CAP.open(encoding="utf-8-sig"):
if line.strip():
records.append(json.loads(line))
tag_a, prop_a, val_a = TAG.encode("ascii"), PROP.encode("ascii"), VALUE.encode("ascii")
tag_u, prop_u, val_u = TAG.encode("utf-16-le"), PROP.encode("utf-16-le"), VALUE.encode("utf-16-le")
print(f"== {len(records)} MDAS bodies captured ==")
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
flags = []
if OP_ADD in body:
flags.append("AddTEx")
if OP_DEL in body:
flags.append("DelTep")
if prop_a in body or prop_u in body:
flags.append("PROP")
if val_a in body or val_u in body:
flags.append("VALUE")
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
def dump(op):
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
if rec.get("Phase") == "WCF.WriteMessage.Body" and op in body:
hexdump(f"[{idx}] {op.decode()} WriteMessage", body)
print(" UTF-16 strings:")
for off, s in u16_strings(body):
print(f" 0x{off:04X} {s!r}")
print(" ASCII strings:")
for off, s in ascii_strings(body):
print(f" 0x{off:04X} {s!r}")
print()
print("\n== AddTEx request(s) ==")
dump(OP_ADD)
print("\n== DelTep request(s) ==")
dump(OP_DEL)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -179,6 +179,28 @@ public sealed class HistorianClient : IAsyncDisposable
return _protocol.GetTagExtendedPropertiesAsync(tag, cancellationToken);
}
/// <summary>
/// Adds (or updates) extended (user-defined) properties on an existing tag via the 2020 WCF
/// <c>AddTEx</c> (AddTagExtendedProperties) op. Requires a write-enabled connection. String-valued
/// properties only (the evidence-backed surface). The new properties are read back via
/// <see cref="GetTagExtendedPropertiesAsync"/>. See <c>HistorianTagExtendedPropertyProtocol</c>.
/// </summary>
public Task<bool> AddTagExtendedPropertiesAsync(string tag, IReadOnlyList<HistorianTagExtendedProperty> properties, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
ArgumentNullException.ThrowIfNull(properties);
return new HistorianWcfTagWriteOrchestrator(_options).AddTagExtendedPropertiesAsync(tag, properties, cancellationToken);
}
/// <summary>Convenience overload of <see cref="AddTagExtendedPropertiesAsync"/> for a single
/// string-valued property.</summary>
public Task<bool> AddTagExtendedPropertyAsync(string tag, string name, string value, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return AddTagExtendedPropertiesAsync(tag, [new HistorianTagExtendedProperty(name, value ?? string.Empty)], cancellationToken);
}
/// <summary>
/// Creates or updates the named tag in the Historian Runtime database via
/// <c>EnsureTags2</c>. Currently only <see cref="HistorianDataType.Float"/> is
@@ -55,6 +55,83 @@ internal static class HistorianTagExtendedPropertyProtocol
return stream.ToArray();
}
private const byte GroupMarker = 0x01;
private const byte PropertyMarker = 0x02;
/// <summary>
/// Serializes the <c>AddTEx</c> (AddTagExtendedProperties) inBuff for a single tag's string
/// properties. The buffer is the exact inverse of the <see cref="ParseResponse"/> group framing
/// (decoded from a native <c>AddTagExtendedProperties</c> capture; see
/// <c>docs/reverse-engineering/wcf-add-tag-extended-properties.md</c>):
/// <code>
/// uint32 groupCount (= 1)
/// byte 0x01 (group marker)
/// 0x09 + uint16 byteLen + ASCII tagName
/// uint32 propertyCount
/// repeated: byte 0x02 (property marker) + 0x09 + uint16 byteLen + ASCII propName
/// + 0x43 VT_BSTR + uint16 payloadLen + uint16 charCount + UTF-16LE value
/// byte 0x01 (group trailer)
/// </code>
/// Only string-valued properties are evidence-backed (the VT_BSTR variant), matching the read path.
/// </summary>
public static byte[] SerializeAddRequest(string tagName, IReadOnlyList<HistorianTagExtendedProperty> properties)
{
ArgumentException.ThrowIfNullOrEmpty(tagName);
ArgumentNullException.ThrowIfNull(properties);
if (properties.Count == 0)
{
throw new ArgumentException("At least one extended property is required.", nameof(properties));
}
using MemoryStream stream = new();
Span<byte> u32 = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(u32, 1u); // group count
stream.Write(u32);
stream.WriteByte(GroupMarker);
WriteCompactAscii(stream, tagName);
BinaryPrimitives.WriteUInt32LittleEndian(u32, checked((uint)properties.Count));
stream.Write(u32);
foreach (HistorianTagExtendedProperty property in properties)
{
ArgumentException.ThrowIfNullOrEmpty(property?.Name, nameof(properties));
stream.WriteByte(PropertyMarker);
WriteCompactAscii(stream, property.Name);
WriteVariantString(stream, property.Value ?? string.Empty);
}
stream.WriteByte(GroupMarker); // group trailer
stream.WriteByte(0x00); // buffer terminator (captured: the native inBuff ends 0x01 0x00)
return stream.ToArray();
}
private static void WriteCompactAscii(MemoryStream stream, string value)
{
byte[] ascii = Encoding.ASCII.GetBytes(value);
stream.WriteByte(CompactStringMarker);
Span<byte> u16 = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(u16, checked((ushort)ascii.Length));
stream.Write(u16);
stream.Write(ascii, 0, ascii.Length);
}
private static void WriteVariantString(MemoryStream stream, string value)
{
byte[] utf16 = Encoding.Unicode.GetBytes(value);
ushort charCount = checked((ushort)value.Length);
stream.WriteByte(VariantTypeBStr);
Span<byte> u16 = stackalloc byte[2];
// payloadLen = the bytes that follow it: the uint16 charCount field (2) + UTF-16 bytes.
BinaryPrimitives.WriteUInt16LittleEndian(u16, checked((ushort)(2 + utf16.Length)));
stream.Write(u16);
BinaryPrimitives.WriteUInt16LittleEndian(u16, charCount);
stream.Write(u16);
stream.Write(utf16, 0, utf16.Length);
}
/// <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
@@ -41,6 +41,18 @@ internal sealed class HistorianWcfTagWriteOrchestrator
return Task.Run(() => DeleteTag(tagName), cancellationToken);
}
public Task<bool> AddTagExtendedPropertiesAsync(
string tagName, IReadOnlyList<HistorianTagExtendedProperty> properties, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
ArgumentNullException.ThrowIfNull(properties);
if (properties.Count == 0)
{
throw new ArgumentException("At least one extended property is required.", nameof(properties));
}
return Task.Run(() => AddTagExtendedProperties(tagName, properties), cancellationToken);
}
private bool EnsureTag(HistorianTagDefinition definition)
{
Guid contextKey = Guid.NewGuid();
@@ -86,6 +98,43 @@ internal sealed class HistorianWcfTagWriteOrchestrator
return result;
}
private bool AddTagExtendedProperties(string tagName, IReadOnlyList<HistorianTagExtendedProperty> properties)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
EndpointAddress retrievalEndpoint = _options.Transport == HistorianTransport.LocalPipe
? HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval)
: HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
bool result = false;
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
additionalSetup: (historyChannel, context) =>
{
RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
byte[] inBuff = HistorianTagExtendedPropertyProtocol.SerializeAddRequest(tagName, properties);
DumpTepIfRequested(inBuff);
bool ok = historyChannel.AddTagExtendedProperties(handle, inBuff, out byte[] errorBuffer);
WriteDiag("AddTEx", $"Returned={ok} Tag={tagName} PropCount={properties.Count} InLen={inBuff.Length} ErrLen={errorBuffer?.Length ?? -1} ErrHex={(errorBuffer is null ? "<null>" : Convert.ToHexString(errorBuffer))}");
result = ok;
});
return result;
}
/// <summary>Env-gated dump of the clean AddTEx inBuff (base64) for golden-fixture capture,
/// mirroring the rename/SQL dump hooks — avoids hand-stitching MDAS chunk markers.</summary>
private static void DumpTepIfRequested(byte[] inBuff)
{
string? path = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_TEP_DUMP");
if (string.IsNullOrWhiteSpace(path)) return;
try { File.AppendAllText(path, Convert.ToBase64String(inBuff) + Environment.NewLine); } catch { }
}
private static bool SendEnsureTags2(
IHistoryServiceContract2 historyChannel,
HistorianWcfAuthChainHelper.OpenConnectionContext context,
@@ -505,6 +505,66 @@ public sealed class HistorianClientIntegrationTests
Assert.True(deleted, "DeleteTagAsync returned false against the live Historian.");
}
[Fact]
public async Task AddTagExtendedPropertiesAsync_AgainstLocalHistorian_WritesAndReadsBack()
{
// Safety: localhost only, sandbox tag must start with "RetestSdkWrite". Creates the tag,
// adds an extended property, reads it back via R1.5, and deletes the tag. Gated on
// HISTORIAN_WRITE_SANDBOX_TAG.
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
if (string.IsNullOrWhiteSpace(sandboxTag) || !sandboxTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
{
return; // safety gate
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "SDK ext-property write live test",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = 0.0,
MaxEU = 100.0,
}, CancellationToken.None);
try
{
const string propName = "SdkLiveTestProp";
const string propValue = "SdkLiveTestValue";
bool added = await client.AddTagExtendedPropertyAsync(sandboxTag, propName, propValue, CancellationToken.None);
Assert.True(added, "AddTagExtendedPropertyAsync returned false against the live Historian.");
// Read back via R1.5 (server may take a moment to surface the new property).
bool found = false;
for (int i = 0; i < 10 && !found; i++)
{
await Task.Delay(500);
var props = await client.GetTagExtendedPropertiesAsync(sandboxTag, CancellationToken.None);
found = props.Any(p =>
string.Equals(p.Name, propName, StringComparison.OrdinalIgnoreCase) &&
string.Equals(p.Value, propValue, StringComparison.Ordinal));
}
Assert.True(found, $"Extended property '{propName}={propValue}' was not read back after the write.");
}
finally
{
try { await client.DeleteTagAsync(sandboxTag, CancellationToken.None); } catch { }
}
}
// Round-trip every live-verified analog data type + the non-default-range case. The
// sandbox tag name is suffixed per case so the runs don't collide. Always cleans up.
[Theory]
@@ -0,0 +1,72 @@
using System.Buffers.Binary;
using System.Text;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
/// <summary>
/// Golden-byte tests for the <c>AddTEx</c> (AddTagExtendedProperties) inBuff serializer (HCAL R1.11).
/// The reference buffer is the exact byte[] the SDK handed the WCF channel in the live
/// <c>AddTagExtendedPropertiesAsync_AgainstLocalHistorian_WritesAndReadsBack</c> run — the server
/// accepted it and the property was read back, so it is server-validated.
/// </summary>
public sealed class WcfTagExtendedPropertyWriteProtocolTests
{
// Server-accepted AddTEx inBuff for tag "RetestSdkWriteTepSdk3", property "SdkLiveTestProp" =
// "SdkLiveTestValue", dumped via AVEVA_HISTORIAN_TEP_DUMP during the live write test.
private const string ServerAcceptedInBuffBase64 =
"AQAAAAEJFQBSZXRlc3RTZGtXcml0ZVRlcFNkazMBAAAAAgkPAFNka0xpdmVUZXN0UHJvcEMiABAAUwBkAGsATABpAHYAZQBUAGUAcwB0AFYAYQBsAHUAZQABAA==";
[Fact]
public void SerializeAddRequest_MatchesServerAcceptedBuffer()
{
byte[] expected = Convert.FromBase64String(ServerAcceptedInBuffBase64);
byte[] actual = HistorianTagExtendedPropertyProtocol.SerializeAddRequest(
"RetestSdkWriteTepSdk3",
[new HistorianTagExtendedProperty("SdkLiveTestProp", "SdkLiveTestValue")]);
Assert.Equal(expected, actual);
}
[Fact]
public void SerializeAddRequest_SingleProperty_HasExpectedLayout()
{
byte[] buf = HistorianTagExtendedPropertyProtocol.SerializeAddRequest(
"ReactorTemp", [new HistorianTagExtendedProperty("Location", "PlantA")]);
int c = 0;
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(c, 4))); c += 4; // group count
Assert.Equal(0x01, buf[c++]); // group marker
Assert.Equal(0x09, buf[c++]); // compact string marker
Assert.Equal(11, BinaryPrimitives.ReadUInt16LittleEndian(buf.AsSpan(c, 2))); c += 2; // tag byte len
Assert.Equal("ReactorTemp", Encoding.ASCII.GetString(buf.AsSpan(c, 11))); c += 11;
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(c, 4))); c += 4; // property count
Assert.Equal(0x02, buf[c++]); // property marker
Assert.Equal(0x09, buf[c++]);
Assert.Equal(8, BinaryPrimitives.ReadUInt16LittleEndian(buf.AsSpan(c, 2))); c += 2;
Assert.Equal("Location", Encoding.ASCII.GetString(buf.AsSpan(c, 8))); c += 8;
Assert.Equal(0x43, buf[c++]); // VT_BSTR
Assert.Equal(14, BinaryPrimitives.ReadUInt16LittleEndian(buf.AsSpan(c, 2))); c += 2; // payloadLen = 2 + 6*2
Assert.Equal(6, BinaryPrimitives.ReadUInt16LittleEndian(buf.AsSpan(c, 2))); c += 2; // char count
Assert.Equal("PlantA", Encoding.Unicode.GetString(buf.AsSpan(c, 12))); c += 12;
Assert.Equal(0x01, buf[c++]); // group trailer
Assert.Equal(0x00, buf[c++]); // buffer terminator
Assert.Equal(buf.Length, c);
}
[Fact]
public void SerializeAddRequest_MultipleProperties_EncodesCount()
{
byte[] buf = HistorianTagExtendedPropertyProtocol.SerializeAddRequest(
"T", [new HistorianTagExtendedProperty("A", "1"), new HistorianTagExtendedProperty("B", "2")]);
// group count @0, then 0x01 marker, 0x09 + u16(1) + "T" => property count at offset 4+1+1+2+1 = 9
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(9, 4)));
}
[Fact]
public void SerializeAddRequest_NoProperties_Throws()
{
Assert.Throws<ArgumentException>(() =>
HistorianTagExtendedPropertyProtocol.SerializeAddRequest("T", Array.Empty<HistorianTagExtendedProperty>()));
}
}
@@ -131,7 +131,7 @@ internal static class Program
object connectionArgs = Activator.CreateInstance(connectionArgsType)!;
SetProperty(connectionArgs, "ServerName", serverName);
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
SetProperty(connectionArgs, "ReadOnly", !(IsWriteScenario(scenario) || IsEventSendScenario(scenario)));
SetProperty(connectionArgs, "ReadOnly", !(IsWriteScenario(scenario) || IsEventSendScenario(scenario) || IsAddTagExtendedPropertiesScenario(scenario)));
SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity);
SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventConnectionScenario(scenario) ? "Event" : "Process"));
if (directConnection)
@@ -323,6 +323,120 @@ internal static class Program
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsAddTagExtendedPropertiesScenario(scenario))
{
// R1.11 capture: drive AddTagExtendedProperties(TagExtendedPropertyGroupList, out err)
// — and optionally DeleteTagExtendedPropertiesByName — so instrument-wcf-writemessage can
// observe the AddTEx / DelTep inBuff (tag + property name/value framing). Sandbox-guarded.
string tepTag = GetArg(args, "--tep-tag") ?? "RetestSdkWriteTepTag";
if (!tepTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"add-tep scenario refuses tags that don't start with 'RetestSdkWrite'. Pass --tep-tag RetestSdkWrite...");
}
string propName = GetArg(args, "--tep-name") ?? "SdkTestProp";
string propValue = GetArg(args, "--tep-value") ?? "SdkTestValue";
var tepRows = new List<object>();
// 1) Ensure the tag exists (AddTag) unless --tep-skip-create.
if (!HasFlag(args, "--tep-skip-create"))
{
Type tagDefType = GetType(assembly, "ArchestrA.HistorianTag");
Type tagDataTypeEnum = GetType(assembly, "ArchestrA.HistorianDataType");
Type tagStorageTypeEnum = GetType(assembly, "ArchestrA.HistorianStorageType");
object tag = Activator.CreateInstance(tagDefType)!;
SetProperty(tag, "TagName", tepTag);
SetProperty(tag, "TagDescription", "SDK ext-property write RE sandbox tag");
SetProperty(tag, "EngineeringUnit", "test");
SetProperty(tag, "TagDataType", Enum.Parse(tagDataTypeEnum, "Float", ignoreCase: true));
SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, "Cyclic", ignoreCase: true));
SetProperty(tag, "MinEU", 0.0);
SetProperty(tag, "MaxEU", 100.0);
SetProperty(tag, "MinRaw", 0.0);
SetProperty(tag, "MaxRaw", 100.0);
SetProperty(tag, "StorageRate", 1000u);
SetProperty(tag, "ApplyScaling", false);
object addError = Activator.CreateInstance(errorType)!;
MethodInfo addTagMethod = accessType.GetMethod("AddTag", new[] { tagDefType, typeof(uint).MakeByRefType(), errorType.MakeByRefType() })!;
object?[] addTagArgs = [tag, 0u, addError];
bool addOk = (bool)addTagMethod.Invoke(access, addTagArgs)!;
tepRows.Add(new { Kind = "AddTag", Success = addOk, ErrorDescription = GetPropertyText(addTagArgs[2]!, "ErrorDescription") });
}
// Prime the tag identity (same reason as the read scenario — server-side resolution).
MethodInfo? getTagInfoByName = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "GetTagInfoByName" && m.GetParameters().Length == 4);
if (getTagInfoByName is not null)
{
object tibError = Activator.CreateInstance(errorType)!;
object?[] tibArgs = new object?[] { tepTag, true, null, tibError };
try { getTagInfoByName.Invoke(access, tibArgs); } catch { }
}
// 2) Build TagExtendedPropertyGroupList { TagExtendedPropertyGroup { TagName, [TagExtendedProperty] } }
Type listType = GetType(assembly, "ArchestrA.TagExtendedPropertyGroupList");
Type groupType = GetType(assembly, "ArchestrA.TagExtendedPropertyGroup");
Type propType = GetType(assembly, "ArchestrA.TagExtendedProperty");
Type propDataTypeEnum = GetType(assembly, "ArchestrA.TagExtendedPropertyDataType");
object list = Activator.CreateInstance(listType)!;
object group = Activator.CreateInstance(groupType)!;
SetProperty(group, "TagName", tepTag);
object prop = Activator.CreateInstance(propType)!;
SetProperty(prop, "PropertyName", propName);
SetProperty(prop, "Type", Enum.Parse(propDataTypeEnum, "String", ignoreCase: true));
SetProperty(prop, "Value", propValue);
groupType.GetMethod("Add", new[] { propType })!.Invoke(group, [prop]);
listType.GetMethod("Add", new[] { groupType })!.Invoke(list, [group]);
MethodInfo addTepMethod = accessType.GetMethods()
.First(m => m.Name == "AddTagExtendedProperties" && m.GetParameters().Length == 2);
object addTepError = Activator.CreateInstance(errorType)!;
object?[] addTepArgs = [list, addTepError];
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-tep");
bool addTepOk = false;
string? addTepEx = null;
try { addTepOk = (bool)addTepMethod.Invoke(access, addTepArgs)!; }
catch (TargetInvocationException ex) { addTepEx = FormatException(ex.InnerException ?? ex); }
tepRows.Add(new
{
Kind = "AddTagExtendedProperties",
Success = addTepOk,
Exception = addTepEx,
ErrorDescription = GetPropertyText(addTepArgs[1]!, "ErrorDescription"),
ErrorCode = GetPropertyText(addTepArgs[1]!, "ErrorCode"),
});
// 3) Optional delete (DelTep) to capture its inBuff too.
if (HasFlag(args, "--tep-delete"))
{
MethodInfo? delTepMethod = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "DeleteTagExtendedPropertiesByName" && m.GetParameters().Length == 4);
if (delTepMethod is not null)
{
Type namesColType = delTepMethod.GetParameters()[1].ParameterType; // StringCollection
object names = Activator.CreateInstance(namesColType)!;
namesColType.GetMethod("Add", new[] { typeof(string) })!.Invoke(names, [propName]);
object delErr = Activator.CreateInstance(errorType)!;
object?[] delArgs = [tepTag, names, true, delErr];
bool delOk = false; string? delEx = null;
try { delOk = (bool)delTepMethod.Invoke(access, delArgs)!; }
catch (TargetInvocationException ex) { delEx = FormatException(ex.InnerException ?? ex); }
tepRows.Add(new { Kind = "DeleteTagExtendedPropertiesByName", Success = delOk, Exception = delEx, ErrorDescription = GetPropertyText(delArgs[3]!, "ErrorDescription") });
}
}
Console.WriteLine(Serialize(new
{
Scenario = scenario,
TepTag = tepTag,
PropertyName = propName,
PropertyValue = propValue,
Rows = tepRows,
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsEventSendScenario(scenario))
{
// R2.1 capture: drive AddStreamedValue(HistorianEvent) and let instrument-wcf-*
@@ -1638,6 +1752,19 @@ internal static class Program
|| scenario.Equals("tag-tep", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Extended-property WRITE scenario (R1.11 capture): opens a write-enabled connection and calls
/// <c>AddTagExtendedProperties(TagExtendedPropertyGroupList, out err)</c> (and optionally
/// <c>DeleteTagExtendedPropertiesByName</c>) so instrument-wcf-writemessage can observe the
/// <c>AddTEx</c>/<c>DelTep</c> inBuff. Sandbox-guarded: the tag must start with RetestSdkWrite.
/// </summary>
private static bool IsAddTagExtendedPropertiesScenario(string scenario)
{
return scenario.Equals("add-tep", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tep-write", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("extended-property-write", StringComparison.OrdinalIgnoreCase);
}
private static bool IsEventConnectionScenario(string scenario)
{
return IsEventScenario(scenario) || IsEventSendScenario(scenario);