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:
dohertj2
2026-05-04 07:02:31 -04:00
parent feba3e5013
commit 5310952ab2
6 changed files with 392 additions and 7 deletions
@@ -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()
{