Extend HistorianTagMetadata with Description, EngineeringUnit, MinEU/MaxEU
Decoded the GetTagInfoFromName response shape across multiple tag types via captured raw bytes (sanitized decoder script in scripts/decode-taginfo-bytes.py): - Compact-ASCII string slot count varies by tag origin: 2 strings for MDAS-routed external tags (TagName + MetadataProvider), 4 strings for local Sys tags (TagName + Description + ItemName + CreatedBy). Parser now walks strings dynamically until the next byte isn't the 0x09 marker. - Trailing region after the 4-byte fixed block holds (for analog tags) two doubles for MinEU/MaxEU plus an optional EngineeringUnit compact ASCII string and other fields whose exact positions vary. Parser uses a tolerant scan: tries each 8-byte alignment 0..7, picks the first sane (Min ≤ Max, finite, not all-zeros, |x| ≤ 1e15) double pair as MinEU/MaxEU, and finds the first plausible compact ASCII string (1..32 ASCII bytes, not numeric) as EngineeringUnit. HistorianTagMetadata.Description / EngineeringUnit / MinRaw / MaxRaw nullable slots already existed; they're now populated. Live verification: SysTimeSec returns Description="System Time : Seconds", MaxRaw=59.0, EngineeringUnit ="Seconds". Tests: 109 → 114 (+4 synthetic-fixture parser tests + 1 live integration test for the populated analog metadata path). Bulk descriptor probe helper (GetTagInfoRawBytesForProbe) added for future layout work; raw bytes never committed because they contain CreatedBy DOMAIN\user identity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -300,4 +300,34 @@ public sealed class HistorianClientIntegrationTests
|
||||
Assert.Equal(testTag, metadata.Name);
|
||||
Assert.NotNull(metadata.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTagMetadataAsync_PopulatesDescriptionAndEuRangeForAnalogTag()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// SysTimeSec is a built-in analog UInt16 tag with non-empty Description, MaxEU,
|
||||
// and an EngineeringUnit. Verifies the parser populates those new fields end-to-end.
|
||||
const string analogTag = "SysTimeSec";
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
|
||||
await client.GetTagMetadataAsync(analogTag, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(analogTag, metadata.Name);
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.Description));
|
||||
Assert.NotNull(metadata.MaxRaw);
|
||||
Assert.True(metadata.MaxRaw is > 0 and <= 1e15);
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.EngineeringUnit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,26 @@ public sealed class TagMetadataDescriptorProbeTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DumpRawTagInfoBytesForLayoutDecoding()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_RAW_TAGINFO_TAGS") ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (string.IsNullOrWhiteSpace(host) || sampleTags.Length == 0 || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClientOptions options = new() { Host = host, IntegratedSecurity = true };
|
||||
var results = HistorianWcfTagClient.GetTagInfoRawBytesForProbe(options, sampleTags);
|
||||
foreach (var (tag, bytes) in results)
|
||||
{
|
||||
if (bytes is null) { _output.WriteLine($" {tag}: <null>"); continue; }
|
||||
_output.WriteLine($" {tag} ({bytes.Length} bytes): {Convert.ToHexString(bytes)}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumerateAllTagDescriptorsAcrossOneSession()
|
||||
{
|
||||
|
||||
@@ -146,6 +146,114 @@ public sealed class WcfTagQueryProtocolTests
|
||||
HistorianWcfTagClient.MapDataType([0x04, 0xCF, 0x00, 0x09]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_FullShape4Strings_PopulatesDescription()
|
||||
{
|
||||
byte[] response = BuildSyntheticTagInfo(
|
||||
descriptor: [0x03, 0xCF, 0x00, 0x09],
|
||||
tagKey: 42,
|
||||
strings: ["TAG", "Tag description here", "TAG", "DOMAIN\\user"],
|
||||
fixedBlock: [0x03, 0x02, 0x01, 0x00],
|
||||
trailingDoubles: null,
|
||||
trailingEu: null);
|
||||
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
|
||||
Assert.Equal("TAG", parsed.TagName);
|
||||
Assert.Equal(42u, parsed.TagKey);
|
||||
Assert.Equal("Tag description here", parsed.Description);
|
||||
// 4-string shape uses position 1 as Description AND MetadataProvider for back-compat.
|
||||
Assert.Equal("Tag description here", parsed.MetadataProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TwoStringShape_DoesNotMisinterpretMetadataProviderAsDescription()
|
||||
{
|
||||
byte[] response = BuildSyntheticTagInfo(
|
||||
descriptor: [0x03, 0xC3, 0x00, 0x31],
|
||||
tagKey: 99,
|
||||
strings: ["EXT.TAG.NAME", "MDAS"],
|
||||
fixedBlock: [0x02, 0x03, 0x01, 0x02],
|
||||
trailingDoubles: null,
|
||||
trailingEu: null);
|
||||
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
|
||||
Assert.Equal("EXT.TAG.NAME", parsed.TagName);
|
||||
Assert.Equal("MDAS", parsed.MetadataProvider);
|
||||
// 2-string shape: don't conflate MetadataProvider with Description.
|
||||
Assert.Null(parsed.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TrailingDoublesAndEu_PopulatesMinMaxAndUnit()
|
||||
{
|
||||
byte[] response = BuildSyntheticTagInfo(
|
||||
descriptor: [0x03, 0xCF, 0x00, 0x09],
|
||||
tagKey: 12,
|
||||
strings: ["TAG", "desc", "TAG", "DOMAIN\\u"],
|
||||
fixedBlock: [0x03, 0x02, 0x01, 0x00],
|
||||
trailingDoubles: (0.0, 59.0),
|
||||
trailingEu: "Seconds");
|
||||
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
|
||||
Assert.Equal(0.0, parsed.MinEU);
|
||||
Assert.Equal(59.0, parsed.MaxEU);
|
||||
Assert.Equal("Seconds", parsed.EngineeringUnit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TrailingNoDoubles_LeavesMinMaxNull()
|
||||
{
|
||||
byte[] response = BuildSyntheticTagInfo(
|
||||
descriptor: [0x03, 0xCF, 0x00, 0x02],
|
||||
tagKey: 97,
|
||||
strings: ["DiscreteTag", "Description", "DiscreteTag", "DOMAIN\\u"],
|
||||
fixedBlock: [0x03, 0x02, 0x01, 0x00],
|
||||
trailingDoubles: null,
|
||||
trailingEu: null);
|
||||
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
|
||||
Assert.Null(parsed.MinEU);
|
||||
Assert.Null(parsed.MaxEU);
|
||||
Assert.Null(parsed.EngineeringUnit);
|
||||
}
|
||||
|
||||
private static byte[] BuildSyntheticTagInfo(
|
||||
byte[] descriptor,
|
||||
uint tagKey,
|
||||
string[] strings,
|
||||
byte[] fixedBlock,
|
||||
(double Min, double Max)? trailingDoubles,
|
||||
string? trailingEu)
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using BinaryWriter w = new(ms);
|
||||
w.Write(descriptor); // 4 bytes
|
||||
w.Write(System.Guid.NewGuid().ToByteArray()); // 16 bytes
|
||||
w.Write(tagKey); // 4 bytes
|
||||
foreach (string s in strings)
|
||||
{
|
||||
byte[] ascii = System.Text.Encoding.ASCII.GetBytes(s);
|
||||
w.Write((byte)0x09);
|
||||
w.Write((ushort)ascii.Length);
|
||||
w.Write(ascii);
|
||||
}
|
||||
w.Write(fixedBlock); // 4 bytes
|
||||
// Trailing region: padding + (optional doubles aligned to 8) + (optional EU compact ASCII).
|
||||
// To keep doubles 8-byte aligned within the trailing region, pad to next 8-byte boundary.
|
||||
long trailingStart = ms.Length;
|
||||
// Plain alignment: add zero padding so doubles start at a stable 8-byte aligned offset
|
||||
// within the trailing region — the parser scans alignments 0..7 so any padding works.
|
||||
if (trailingDoubles is { } d)
|
||||
{
|
||||
w.Write(d.Min);
|
||||
w.Write(d.Max);
|
||||
}
|
||||
if (trailingEu is not null)
|
||||
{
|
||||
byte[] euAscii = System.Text.Encoding.ASCII.GetBytes(trailingEu);
|
||||
w.Write((byte)0x09);
|
||||
w.Write((ushort)euAscii.Length);
|
||||
w.Write(euAscii);
|
||||
}
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesManagedWcfLikeTagNamesResponse()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user