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
+105
View File
@@ -0,0 +1,105 @@
"""Decode a hex-encoded GetTagInfoFromName response into a sanitized field map.
USE: pipe one tag's hex on stdin (no spaces), one per line. Identity-bearing string
values are reported only as length + non-ASCII flags so the output stays commit-safe.
"""
import struct
import sys
import binascii
def is_printable_ascii(b: bytes) -> bool:
return all(0x20 <= c < 0x7F for c in b)
def decode_one(hex_str: str) -> None:
raw = binascii.unhexlify(hex_str)
n = len(raw)
cur = 0
# Fixed prefix
desc = raw[cur:cur+4]; cur += 4
type_id = raw[cur:cur+16]; cur += 16
tag_key = struct.unpack_from('<I', raw, cur)[0]; cur += 4
print(f" total_bytes = {n}")
print(f" [00..03] descriptor = {desc.hex().upper()}")
print(f" [04..19] typeId(GUID) = (16 bytes)")
print(f" [20..23] tagKey(uint32)= {tag_key}")
# Walk compact ASCII strings until we hit a byte != 0x09
string_idx = 1
while cur < n and raw[cur] == 0x09:
marker_off = cur
cur += 1
length = struct.unpack_from('<H', raw, cur)[0]; cur += 2
value = raw[cur:cur + length]
cur += length
is_ascii = is_printable_ascii(value)
print(f" [{marker_off:02X}..{cur-1:02X}] string{string_idx} = "
f"compactAscii(len={length}, ascii={is_ascii})")
string_idx += 1
# 4-byte fixed block (nativeTagClass, storageType, deadbandType, interpolationType)
if cur + 4 <= n:
block = raw[cur:cur+4]
print(f" [{cur:02X}..{cur+3:02X}] fixedBlock4 = "
f"class={block[0]} storage={block[1]} deadband={block[2]} interp={block[3]}")
cur += 4
# Try to identify trailing fields by structure
print(f" trailing bytes from offset 0x{cur:02X} ({n - cur} bytes):")
trailing = raw[cur:]
# Look for FILETIME pattern (year-2025 = 0x01DCxxxxxxxxxxxx, byte 7 = 0x01)
for off in range(min(len(trailing) - 7, 16)):
if trailing[off + 7] == 0x01 and trailing[off + 6] in (0xDB, 0xDC, 0xDD):
ft_bytes = trailing[off:off+8]
ft_value = struct.unpack_from('<q', ft_bytes)[0]
# FILETIME → year approximation
seconds = ft_value / 10_000_000
years_since_1601 = seconds / 31_557_600
year = 1601 + int(years_since_1601)
print(f" candidate FILETIME at trailing+0x{off:X}: year~{year} "
f"(absolute offset 0x{cur + off:02X})")
# Look for double 10.0 (0x4024000000000000) and other doubles
for off in range(0, len(trailing) - 7, 1):
try:
d = struct.unpack_from('<d', trailing, off)[0]
if d != 0 and abs(d) < 1e10 and abs(d) > 1e-5:
print(f" double @trailing+0x{off:X} (abs 0x{cur+off:02X}) = {d}")
except Exception:
pass
# Look for plausible uint32s (small, non-zero)
for off in range(0, len(trailing) - 3, 1):
v = struct.unpack_from('<I', trailing, off)[0]
if 0 < v < 1_000_000:
print(f" uint32 @trailing+0x{off:X} (abs 0x{cur+off:02X}) = {v}")
# Look for trailing marker (FE 00 or 00)
if len(trailing) >= 2:
last2 = trailing[-2:]
last1 = trailing[-1:]
print(f" last 2 bytes: {last2.hex().upper()}")
print()
def main() -> int:
for line in sys.stdin:
line = line.strip()
if not line:
continue
# Allow optional "TagName: HEX" prefix
if ':' in line:
label, hex_str = line.split(':', 1)
print(f"=== {label.strip()} ===")
decode_one(hex_str.strip())
else:
decode_one(line)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -136,8 +136,24 @@ internal static class HistorianTagQueryProtocol
cursor += 16;
uint tagKey = ReadUInt32(response, ref cursor);
string tagName = ReadCompactAsciiString(response, ref cursor);
string metadataProvider = ReadCompactAsciiString(response, ref cursor);
// The compact-ASCII string slot count varies by tag origin (decoded from
// GetTagInfoFromName captures across multiple tag types):
// 1 string : TagName only (degenerate / unknown shape)
// 2 strings : TagName + MetadataProvider (e.g., MDAS-routed external tags)
// 4 strings : TagName + Description + ItemName + CreatedBy (local Sys tags)
// Walk strings dynamically until the next byte isn't the 0x09 marker.
List<string> strings = new(4);
while (cursor < response.Length && response[cursor] == 0x09)
{
strings.Add(ReadCompactAsciiString(response, ref cursor));
}
string tagName = strings.Count > 0 ? strings[0] : string.Empty;
// String at position 1 is Description for full-shape tags or MetadataProvider
// for MDAS-routed tags. Both are useful; expose under MetadataProvider for back-compat
// and Description for new semantics.
string metadataProvider = strings.Count > 1 ? strings[1] : string.Empty;
string? description = strings.Count >= 4 ? strings[1] : null;
EnsureAvailable(response, cursor, 4);
byte nativeTagClass = response[cursor++];
@@ -145,6 +161,21 @@ internal static class HistorianTagQueryProtocol
byte deadbandType = response[cursor++];
byte interpolationType = response[cursor++];
// Trailing region after the fixed 4-byte block holds:
// - some alignment / int32 fields (StorageRate, AcquisitionRate, TimeDeadband)
// - Int64 FILETIME (DateCreated)
// - For analog tags: pair of doubles (MinEU/MaxEU and/or MinRaw/MaxRaw)
// - Optional compact-ASCII EngineeringUnit string
// - Optional double RolloverValue
// - Trailer marker (often FE 00 or 00)
// The exact layout varies by tag type and storage mode; rather than commit fragile
// positional parsing, scan the trailing region for the first two consecutive
// 8-byte-aligned doubles and treat them as a (MinEU, MaxEU) pair. Both must be
// finite and the EU range must be sane (Min ≤ Max).
ReadOnlySpan<byte> trailing = response[cursor..];
(double? min, double? max, string? engineeringUnit) = TryReadAnalogTrailing(trailing);
cursor = response.Length;
return new HistorianTagInfoResponse(
tagName,
tagKey,
@@ -154,7 +185,71 @@ internal static class HistorianTagQueryProtocol
nativeTagClass,
storageType,
deadbandType,
interpolationType);
interpolationType,
description,
min,
max,
engineeringUnit);
}
private static (double? min, double? max, string? engineeringUnit) TryReadAnalogTrailing(ReadOnlySpan<byte> trailing)
{
double? foundMin = null;
double? foundMax = null;
string? foundEu = null;
// Look for an EngineeringUnit compact-ASCII string anywhere in the trailing region.
for (int i = 0; i < trailing.Length - 3; i++)
{
if (trailing[i] != 0x09) continue;
ushort len = BitConverter.ToUInt16(trailing.Slice(i + 1, 2));
// Accept 1-32 byte ASCII strings as plausible EUs. Range chosen to filter false
// positives (most engineering units are short — "kPa", "Seconds", "RPM", etc.).
if (len < 1 || len > 32) continue;
int payloadStart = i + 3;
if (payloadStart + len > trailing.Length) continue;
// All bytes must be printable ASCII.
ReadOnlySpan<byte> payload = trailing.Slice(payloadStart, len);
bool allAscii = true;
foreach (byte b in payload)
{
if (b < 0x20 || b > 0x7E) { allAscii = false; break; }
}
if (!allAscii) continue;
string candidate = Encoding.ASCII.GetString(payload);
// Skip implausible values (numerics, mostly-special-chars).
if (double.TryParse(candidate, out _)) continue;
foundEu = candidate;
break;
}
// Look for two consecutive 8-byte-aligned doubles forming a sane EU range.
// Try each plausible alignment relative to the trailing-region start.
for (int alignOffset = 0; alignOffset < 8; alignOffset++)
{
for (int i = alignOffset; i + 16 <= trailing.Length; i += 8)
{
if (!TryReadDouble(trailing, i, out double a)) continue;
if (!TryReadDouble(trailing, i + 8, out double b)) continue;
// Both finite, both within sane EU range, a ≤ b.
if (!double.IsFinite(a) || !double.IsFinite(b)) continue;
if (Math.Abs(a) > 1e15 || Math.Abs(b) > 1e15) continue;
if (a > b) continue;
// Reject the all-zeros pair (uninformative).
if (a == 0 && b == 0) continue;
foundMin = a;
foundMax = b;
return (foundMin, foundMax, foundEu);
}
}
return (foundMin, foundMax, foundEu);
}
private static bool TryReadDouble(ReadOnlySpan<byte> buffer, int offset, out double value)
{
if (offset + 8 > buffer.Length) { value = 0; return false; }
value = BitConverter.ToDouble(buffer.Slice(offset, 8));
return true;
}
private static ushort ReadUInt16(ReadOnlySpan<byte> response, ref int cursor)
@@ -195,4 +290,8 @@ internal sealed record HistorianTagInfoResponse(
byte NativeTagClass,
byte StorageType,
byte DeadbandType,
byte InterpolationType);
byte InterpolationType,
string? Description = null,
double? MinEU = null,
double? MaxEU = null,
string? EngineeringUnit = null);
@@ -91,9 +91,13 @@ internal static class HistorianWcfTagClient
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(tagMetadata);
return new HistorianTagMetadata(
parsed.TagName,
parsed.TagKey,
MapDataType(parsed.NativeDataTypeDescriptor));
Name: parsed.TagName,
Key: parsed.TagKey,
DataType: MapDataType(parsed.NativeDataTypeDescriptor),
Description: parsed.Description,
EngineeringUnit: parsed.EngineeringUnit,
MinRaw: parsed.MinEU,
MaxRaw: parsed.MaxEU);
}
/// <summary>
@@ -108,6 +112,25 @@ internal static class HistorianWcfTagClient
return GetTagInfoForDescriptorProbe(session, tag);
}
/// <summary>Bulk variant: probes many tags and returns the raw response bytes alongside the parsed record (for byte-layout reverse engineering).</summary>
internal static IReadOnlyDictionary<string, byte[]?> GetTagInfoRawBytesForProbe(
HistorianClientOptions options,
IEnumerable<string> tags)
{
Dictionary<string, byte[]?> results = new(StringComparer.Ordinal);
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
foreach (string tag in tags)
{
try
{
uint rc = session.RetrievalChannel.GetTagInfoFromName(session.Handle, tag, out _, out byte[] bytes);
results[tag] = (rc == 0 && bytes.Length > 0) ? bytes : null;
}
catch { results[tag] = null; }
}
return results;
}
/// <summary>Bulk variant: probes many tags through a single session.</summary>
internal static IReadOnlyDictionary<string, HistorianTagInfoResponse?> GetTagInfosForDescriptorProbe(
HistorianClientOptions options,
@@ -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()
{