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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user