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
@@ -296,6 +296,41 @@ public sealed class HistorianClientIntegrationTests
Assert.False(string.IsNullOrWhiteSpace(value));
}
[Fact]
public async Task GetTagExtendedPropertiesAsync_AgainstLocalHistorian_ReturnsProperties()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
// A tag that carries at least one extended property. Gated on its own env var so the test
// skips cleanly when no such tag is configured (no tag name is hardcoded).
string? tepTag = Environment.GetEnvironmentVariable("HISTORIAN_TEP_TAG");
if (string.IsNullOrWhiteSpace(host)
|| !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)
|| !OperatingSystem.IsWindows()
|| string.IsNullOrWhiteSpace(tepTag))
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
// GetTepByNm rides the storage-session GUID as an uppercase string handle. The configured
// tag has at least one string-valued extended property (e.g. Location).
IReadOnlyList<AVEVA.Historian.Client.Models.HistorianTagExtendedProperty> properties =
await client.GetTagExtendedPropertiesAsync(tepTag, CancellationToken.None);
Assert.NotEmpty(properties);
Assert.All(properties, p =>
{
Assert.False(string.IsNullOrWhiteSpace(p.Name));
Assert.NotNull(p.Value);
});
}
[Fact]
public async Task GetConnectionStatusAsync_AgainstLocalHistorian_ReportsConnectedToServer()
{
@@ -0,0 +1,120 @@
using System.Buffers.Binary;
using System.Text;
using AVEVA.Historian.Client.Protocol;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
/// <summary>
/// Golden-byte tests for the GetTepByNm (GetTagExtendedPropertiesFromName) codec. The byte layout
/// is pinned to the live capture documented on <see cref="HistorianTagExtendedPropertyProtocol"/>
/// (scripts/Capture-TagExtendedProperties.ps1); synthetic tag/property values are used here so no
/// dev tag names land in the repo, but the framing is byte-for-byte the captured structure.
/// </summary>
public sealed class WcfTagExtendedPropertyProtocolTests
{
[Fact]
public void SerializeRequest_SingleTag_MatchesCapturedLayout()
{
// tagNames = uint32 count(1) + uint32 charCount + UTF-16LE chars.
const string tag = "Reactor.Temp1";
byte[] actual = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag);
using MemoryStream expected = new();
using (BinaryWriter w = new(expected, Encoding.Unicode, leaveOpen: true))
{
w.Write(1u);
w.Write((uint)tag.Length);
w.Write(Encoding.Unicode.GetBytes(tag));
}
Assert.Equal(expected.ToArray(), actual);
}
[Fact]
public void ParseResponse_SingleStringProperty_ExtractsNameAndValue()
{
byte[] buffer = BuildResponse("Reactor.Temp1", "Location", "Plant/AreaA");
IReadOnlyList<HistorianTagExtendedPropertyRow> rows =
HistorianTagExtendedPropertyProtocol.ParseResponse(buffer);
HistorianTagExtendedPropertyRow row = Assert.Single(rows);
Assert.Equal("Reactor.Temp1", row.TagName);
Assert.Equal("Location", row.PropertyName);
Assert.Equal("Plant/AreaA", row.Value);
}
[Fact]
public void ParseResponse_ZeroTagCount_ReturnsEmpty_ForPagingTermination()
{
// The terminal page of the sequence loop carries tagCount = 0.
byte[] terminal = [0x00, 0x00, 0x00, 0x00];
Assert.Empty(HistorianTagExtendedPropertyProtocol.ParseResponse(terminal));
}
[Fact]
public void ParseResponse_NonStringVariant_Throws()
{
// Replace the 0x43 VT_BSTR marker with an unmapped variant type (0x03).
byte[] buffer = BuildResponse("Reactor.Temp1", "Location", "Plant/AreaA");
int variantOffset = Array.IndexOf(buffer, (byte)0x43);
Assert.True(variantOffset > 0);
buffer[variantOffset] = 0x03;
Assert.Throws<ProtocolEvidenceMissingException>(
() => HistorianTagExtendedPropertyProtocol.ParseResponse(buffer));
}
/// <summary>
/// Builds a GetTepByNm response buffer byte-for-byte per the captured layout: uint32 tagCount,
/// then per tag [marker 0x01][compact-ASCII name][uint32 propCount][per prop marker 0x02 +
/// compact-ASCII name + 0x43 VT_BSTR value][trailing 0x01].
/// </summary>
private static byte[] BuildResponse(string tag, string propName, string propValue)
{
using MemoryStream ms = new();
using BinaryWriter w = new(ms, Encoding.ASCII, leaveOpen: true);
WriteUInt32(w, 1u); // tagCount
w.Write((byte)0x01); // group marker
WriteCompactAscii(w, tag);
WriteUInt32(w, 1u); // propertyCount
w.Write((byte)0x02); // property marker
WriteCompactAscii(w, propName);
WriteVariantString(w, propValue);
w.Write((byte)0x01); // trailing marker
w.Flush();
return ms.ToArray();
}
private static void WriteUInt32(BinaryWriter w, uint value)
{
Span<byte> b = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(b, value);
w.Write(b);
}
private static void WriteUInt16(BinaryWriter w, ushort value)
{
Span<byte> b = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(b, value);
w.Write(b);
}
private static void WriteCompactAscii(BinaryWriter w, string value)
{
w.Write((byte)0x09);
WriteUInt16(w, (ushort)value.Length);
w.Write(Encoding.ASCII.GetBytes(value));
}
private static void WriteVariantString(BinaryWriter w, string value)
{
w.Write((byte)0x43); // VT_BSTR
WriteUInt16(w, (ushort)(2 + value.Length * 2)); // payload length = charCount field + UTF-16 bytes
WriteUInt16(w, (ushort)value.Length); // char count
w.Write(Encoding.Unicode.GetBytes(value));
}
}