diff --git a/scripts/decode-taginfo-bytes.py b/scripts/decode-taginfo-bytes.py new file mode 100644 index 0000000..3e67b8f --- /dev/null +++ b/scripts/decode-taginfo-bytes.py @@ -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(' 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('= 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()) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs index 0f99736..d14a362 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagQueryProtocol.cs @@ -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 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 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 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 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 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 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); diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs index 8bb706b..f68d1b4 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs @@ -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); } /// @@ -108,6 +112,25 @@ internal static class HistorianWcfTagClient return GetTagInfoForDescriptorProbe(session, tag); } + /// Bulk variant: probes many tags and returns the raw response bytes alongside the parsed record (for byte-layout reverse engineering). + internal static IReadOnlyDictionary GetTagInfoRawBytesForProbe( + HistorianClientOptions options, + IEnumerable tags) + { + Dictionary 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; + } + /// Bulk variant: probes many tags through a single session. internal static IReadOnlyDictionary GetTagInfosForDescriptorProbe( HistorianClientOptions options, diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs index 2ac376f..7513def 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs @@ -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)); + } } diff --git a/tests/AVEVA.Historian.Client.Tests/TagMetadataDescriptorProbeTests.cs b/tests/AVEVA.Historian.Client.Tests/TagMetadataDescriptorProbeTests.cs index 19a3ffd..c97ace4 100644 --- a/tests/AVEVA.Historian.Client.Tests/TagMetadataDescriptorProbeTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/TagMetadataDescriptorProbeTests.cs @@ -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}: "); continue; } + _output.WriteLine($" {tag} ({bytes.Length} bytes): {Convert.ToHexString(bytes)}"); + } + } + [Fact] public void EnumerateAllTagDescriptorsAcrossOneSession() { diff --git a/tests/AVEVA.Historian.Client.Tests/WcfTagQueryProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfTagQueryProtocolTests.cs index d6c6483..eb26d90 100644 --- a/tests/AVEVA.Historian.Client.Tests/WcfTagQueryProtocolTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/WcfTagQueryProtocolTests.cs @@ -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() {