Files
histsdk/scripts/decode-taginfo-bytes.py
dohertj2 5310952ab2 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>
2026-05-04 07:02:31 -04:00

106 lines
3.6 KiB
Python

"""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())