AB CIP @tags walker — CIP Symbol Object decoder + LibplctagTagEnumerator. Closes task #178. CipSymbolObjectDecoder (pure-managed, no libplctag dep) parses the raw Symbol Object (class 0x6B) blob returned by reading the @tags pseudo-tag into an enumerable sequence of AbCipDiscoveredTag records. Entry layout per Rockwell CIP Vol 1 + Logix 5000 CIP Programming Manual 1756-PM019, cross-checked against libplctag's ab/cip.c handle_listed_tags_reply — u32 instance-id + u16 symbol-type + u16 element-length + 3×u32 array-dims + u16 name-length + name[len] + even-pad. Symbol-type lower 12 bits carry the CIP type code (0xC1 BOOL, 0xC2 SINT, …, 0xD0 STRING), bit 12 is the system-tag flag, bit 15 is the struct flag (when set lower 12 bits become the template instance id). Truncated tails stop decoding gracefully — caller keeps whatever parsed cleanly rather than getting an exception mid-walk. Program:-scope names (Program:MainProgram.StepIndex) are split via SplitProgramScope so the enumerator surfaces scope + simple name separately. 12 atomic type codes mapped (BOOL/SINT/INT/DINT/LINT/USINT/UINT/UDINT/ULINT/REAL/LREAL/STRING + DT/DATE_AND_TIME under Dt); unknown codes return null so the caller treats them as opaque Structure. LibplctagTagEnumerator is the real production walker — creates a libplctag Tag with name=@tags against the device's gateway/port/path, InitializeAsync + ReadAsync + GetBuffer, hands bytes to the decoder. Factory LibplctagTagEnumeratorFactory replaces EmptyAbCipTagEnumeratorFactory as the AbCipDriver default. AbCipDriverOptions gains EnableControllerBrowse (default false) matching the TwinCAT pattern — keeps the strict-config path for deployments where only declared tags should appear. When true, DiscoverAsync walks each device's @tags + emits surviving symbols under Discovered/ sub-folder. System-tag filter (AbCipSystemTagFilter shipped in PR 5) runs alongside the wire-layer system-flag hint. Tests — 18 new CipSymbolObjectDecoderTests with crafted byte arrays matching the documented layout — single-entry DInt, theory across 12 atomic type codes, unknown→null, struct flag override, system flag surface, Program:-scope split, multi-entry wire-order with even-pad, truncated-buffer graceful stop, empty buffer, SplitProgramScope theory across 6 shapes. 4 pre-existing AbCipDriverDiscoveryTests that tested controller-enumeration behavior updated with EnableControllerBrowse=true so they continue exercising the walker path (behavior unchanged from their perspective). Total AbCip unit tests now 192/192 passing (+26 from the RMW merge's 166); full solution builds 0 errors; other drivers untouched. Field validation note — the decoder layout matches published Rockwell docs + libplctag C source, but actual @tags responses vary slightly by controller firmware (some ship an older entry format with u16 array dims instead of u32). Any layout drift surfaces as gibberish names in the Discovered/ folder; field testing will flag that for a decoder patch if it occurs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-19 21:13:20 -04:00
parent 91e6153b5d
commit 088c4817fe
6 changed files with 396 additions and 4 deletions

View File

@@ -0,0 +1,128 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Decoder for the CIP Symbol Object (class 0x6B) response returned by Logix controllers
/// when a client reads the <c>@tags</c> pseudo-tag. Parses the concatenated tag-info
/// entries into a sequence of <see cref="AbCipDiscoveredTag"/>s that the driver can stream
/// into the address-space builder.
/// </summary>
/// <remarks>
/// <para>Entry layout (little-endian) per Rockwell CIP Vol 1 + Logix 5000 CIP Programming
/// Manual (1756-PM019 chapter "Symbol Object"), cross-checked against libplctag's
/// <c>ab/cip.c</c> <c>handle_listed_tags_reply</c>:</para>
/// <list type="table">
/// <item><term>u32</term><description>Symbol Instance ID — opaque identifier for the tag.</description></item>
/// <item><term>u16</term><description>Symbol Type — lower 12 bits = CIP type code (0xC1 BOOL,
/// 0xC2 SINT, …, 0xD0 STRING). Bit 12 = system-tag flag. Bit 13 = reserved.
/// Bit 15 = struct flag; when set, the lower 12 bits are the template instance id
/// (not a primitive type code).</description></item>
/// <item><term>u16</term><description>Element length — bytes per element (e.g. 4 for DINT).</description></item>
/// <item><term>u32 × 3</term><description>Array dimensions — zero for scalar tags.</description></item>
/// <item><term>u16</term><description>Symbol name length in bytes.</description></item>
/// <item><term>u8 × N</term><description>ASCII symbol name, padded to an even byte boundary.</description></item>
/// </list>
///
/// <para><c>Program:</c>-scope tags arrive with their scope prefix baked into the name
/// (<c>Program:MainProgram.StepIndex</c>); decoder strips the prefix + emits the scope
/// separately so the driver's IAddressSpaceBuilder can organise them.</para>
/// </remarks>
public static class CipSymbolObjectDecoder
{
// Fixed header size in bytes — instance-id(4) + symbol-type(2) + element-length(2)
// + array-dims(4×3) + name-length(2) = 22.
private const int FixedHeaderSize = 22;
private const ushort SymbolTypeSystemFlag = 0x1000;
private const ushort SymbolTypeStructFlag = 0x8000;
private const ushort SymbolTypeTypeCodeMask = 0x0FFF;
/// <summary>
/// Decode the raw <c>@tags</c> blob into an enumerable sequence. Malformed entries at
/// the tail cause decoding to stop gracefully — the caller gets whatever it could parse
/// cleanly before the corruption.
/// </summary>
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
return DecodeImpl(buffer);
}
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer)
{
var pos = 0;
while (pos + FixedHeaderSize <= buffer.Length)
{
var instanceId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos));
var symbolType = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 4));
// element_length at pos+6 (u16) — useful for array sizing but not surfaced here
// array_dims at pos+8, pos+12, pos+16 — same (scalar-tag case has all zeros)
var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20));
pos += FixedHeaderSize;
if (pos + nameLength > buffer.Length) break;
var name = Encoding.ASCII.GetString(buffer, pos, nameLength);
pos += nameLength;
if ((pos & 1) != 0) pos++; // even-align for the next entry
if (string.IsNullOrWhiteSpace(name)) continue;
var isSystem = (symbolType & SymbolTypeSystemFlag) != 0;
var isStruct = (symbolType & SymbolTypeStructFlag) != 0;
var typeCode = symbolType & SymbolTypeTypeCodeMask;
var (programScope, simpleName) = SplitProgramScope(name);
var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode);
yield return new AbCipDiscoveredTag(
Name: simpleName,
ProgramScope: programScope,
DataType: dataType ?? AbCipDataType.Structure, // unknown type code → treat as opaque
ReadOnly: false, // Symbol Object doesn't carry write-protection bits; lift via AccessControl Object later
IsSystemTag: isSystem);
_ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today
}
}
/// <summary>
/// Split a <c>Program:MainProgram.StepIndex</c>-style name into its scope + local
/// parts. Names without the <c>Program:</c> prefix pass through unchanged.
/// </summary>
internal static (string? programScope, string simpleName) SplitProgramScope(string fullName)
{
const string prefix = "Program:";
if (!fullName.StartsWith(prefix, StringComparison.Ordinal)) return (null, fullName);
var afterPrefix = fullName[prefix.Length..];
var dot = afterPrefix.IndexOf('.');
if (dot <= 0) return (null, fullName); // malformed scope — surface the raw name
return (afterPrefix[..dot], afterPrefix[(dot + 1)..]);
}
/// <summary>
/// Map a CIP atomic type code (lower 12 bits of the symbol-type field) to our
/// <see cref="AbCipDataType"/> surface. Returns <c>null</c> for unrecognised codes —
/// caller treats those as <see cref="AbCipDataType.Structure"/> so the symbol is still
/// surfaced + downstream config can add a concrete type override.
/// </summary>
internal static AbCipDataType? MapTypeCode(byte typeCode) => typeCode switch
{
0xC1 => AbCipDataType.Bool,
0xC2 => AbCipDataType.SInt,
0xC3 => AbCipDataType.Int,
0xC4 => AbCipDataType.DInt,
0xC5 => AbCipDataType.LInt,
0xC6 => AbCipDataType.USInt,
0xC7 => AbCipDataType.UInt,
0xC8 => AbCipDataType.UDInt,
0xC9 => AbCipDataType.ULInt,
0xCA => AbCipDataType.Real,
0xCB => AbCipDataType.LReal,
0xCD => AbCipDataType.Dt, // DATE
0xCF => AbCipDataType.Dt, // DATE_AND_TIME
0xD0 => AbCipDataType.String,
_ => null,
};
}