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