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
@@ -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,