From 088c4817fe052facb25eb0f61cec7282ba4ce841 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 21:13:20 -0400 Subject: [PATCH] =?UTF-8?q?AB=20CIP=20@tags=20walker=20=E2=80=94=20CIP=20S?= =?UTF-8?q?ymbol=20Object=20decoder=20+=20LibplctagTagEnumerator.=20Closes?= =?UTF-8?q?=20task=20#178.=20CipSymbolObjectDecoder=20(pure-managed,=20no?= =?UTF-8?q?=20libplctag=20dep)=20parses=20the=20raw=20Symbol=20Object=20(c?= =?UTF-8?q?lass=200x6B)=20blob=20returned=20by=20reading=20the=20@tags=20p?= =?UTF-8?q?seudo-tag=20into=20an=20enumerable=20sequence=20of=20AbCipDisco?= =?UTF-8?q?veredTag=20records.=20Entry=20layout=20per=20Rockwell=20CIP=20V?= =?UTF-8?q?ol=201=20+=20Logix=205000=20CIP=20Programming=20Manual=201756-P?= =?UTF-8?q?M019,=20cross-checked=20against=20libplctag's=20ab/cip.c=20hand?= =?UTF-8?q?le=5Flisted=5Ftags=5Freply=20=E2=80=94=20u32=20instance-id=20+?= =?UTF-8?q?=20u16=20symbol-type=20+=20u16=20element-length=20+=203=C3=97u3?= =?UTF-8?q?2=20array-dims=20+=20u16=20name-length=20+=20name[len]=20+=20ev?= =?UTF-8?q?en-pad.=20Symbol-type=20lower=2012=20bits=20carry=20the=20CIP?= =?UTF-8?q?=20type=20code=20(0xC1=20BOOL,=200xC2=20SINT,=20=E2=80=A6,=200x?= =?UTF-8?q?D0=20STRING),=20bit=2012=20is=20the=20system-tag=20flag,=20bit?= =?UTF-8?q?=2015=20is=20the=20struct=20flag=20(when=20set=20lower=2012=20b?= =?UTF-8?q?its=20become=20the=20template=20instance=20id).=20Truncated=20t?= =?UTF-8?q?ails=20stop=20decoding=20gracefully=20=E2=80=94=20caller=20keep?= =?UTF-8?q?s=20whatever=20parsed=20cleanly=20rather=20than=20getting=20an?= =?UTF-8?q?=20exception=20mid-walk.=20Program:-scope=20names=20(Program:Ma?= =?UTF-8?q?inProgram.StepIndex)=20are=20split=20via=20SplitProgramScope=20?= =?UTF-8?q?so=20the=20enumerator=20surfaces=20scope=20+=20simple=20name=20?= =?UTF-8?q?separately.=2012=20atomic=20type=20codes=20mapped=20(BOOL/SINT/?= =?UTF-8?q?INT/DINT/LINT/USINT/UINT/UDINT/ULINT/REAL/LREAL/STRING=20+=20DT?= =?UTF-8?q?/DATE=5FAND=5FTIME=20under=20Dt);=20unknown=20codes=20return=20?= =?UTF-8?q?null=20so=20the=20caller=20treats=20them=20as=20opaque=20Struct?= =?UTF-8?q?ure.=20LibplctagTagEnumerator=20is=20the=20real=20production=20?= =?UTF-8?q?walker=20=E2=80=94=20creates=20a=20libplctag=20Tag=20with=20nam?= =?UTF-8?q?e=3D@tags=20against=20the=20device's=20gateway/port/path,=20Ini?= =?UTF-8?q?tializeAsync=20+=20ReadAsync=20+=20GetBuffer,=20hands=20bytes?= =?UTF-8?q?=20to=20the=20decoder.=20Factory=20LibplctagTagEnumeratorFactor?= =?UTF-8?q?y=20replaces=20EmptyAbCipTagEnumeratorFactory=20as=20the=20AbCi?= =?UTF-8?q?pDriver=20default.=20AbCipDriverOptions=20gains=20EnableControl?= =?UTF-8?q?lerBrowse=20(default=20false)=20matching=20the=20TwinCAT=20patt?= =?UTF-8?q?ern=20=E2=80=94=20keeps=20the=20strict-config=20path=20for=20de?= =?UTF-8?q?ployments=20where=20only=20declared=20tags=20should=20appear.?= =?UTF-8?q?=20When=20true,=20DiscoverAsync=20walks=20each=20device's=20@ta?= =?UTF-8?q?gs=20+=20emits=20surviving=20symbols=20under=20Discovered/=20su?= =?UTF-8?q?b-folder.=20System-tag=20filter=20(AbCipSystemTagFilter=20shipp?= =?UTF-8?q?ed=20in=20PR=205)=20runs=20alongside=20the=20wire-layer=20syste?= =?UTF-8?q?m-flag=20hint.=20Tests=20=E2=80=94=2018=20new=20CipSymbolObject?= =?UTF-8?q?DecoderTests=20with=20crafted=20byte=20arrays=20matching=20the?= =?UTF-8?q?=20documented=20layout=20=E2=80=94=20single-entry=20DInt,=20the?= =?UTF-8?q?ory=20across=2012=20atomic=20type=20codes,=20unknown=E2=86=92nu?= =?UTF-8?q?ll,=20struct=20flag=20override,=20system=20flag=20surface,=20Pr?= =?UTF-8?q?ogram:-scope=20split,=20multi-entry=20wire-order=20with=20even-?= =?UTF-8?q?pad,=20truncated-buffer=20graceful=20stop,=20empty=20buffer,=20?= =?UTF-8?q?SplitProgramScope=20theory=20across=206=20shapes.=204=20pre-exi?= =?UTF-8?q?sting=20AbCipDriverDiscoveryTests=20that=20tested=20controller-?= =?UTF-8?q?enumeration=20behavior=20updated=20with=20EnableControllerBrows?= =?UTF-8?q?e=3Dtrue=20so=20they=20continue=20exercising=20the=20walker=20p?= =?UTF-8?q?ath=20(behavior=20unchanged=20from=20their=20perspective).=20To?= =?UTF-8?q?tal=20AbCip=20unit=20tests=20now=20192/192=20passing=20(+26=20f?= =?UTF-8?q?rom=20the=20RMW=20merge's=20166);=20full=20solution=20builds=20?= =?UTF-8?q?0=20errors;=20other=20drivers=20untouched.=20Field=20validation?= =?UTF-8?q?=20note=20=E2=80=94=20the=20decoder=20layout=20matches=20publis?= =?UTF-8?q?hed=20Rockwell=20docs=20+=20libplctag=20C=20source,=20but=20act?= =?UTF-8?q?ual=20@tags=20responses=20vary=20slightly=20by=20controller=20f?= =?UTF-8?q?irmware=20(some=20ship=20an=20older=20entry=20format=20with=20u?= =?UTF-8?q?16=20array=20dims=20instead=20of=20u32).=20Any=20layout=20drift?= =?UTF-8?q?=20surfaces=20as=20gibberish=20names=20in=20the=20Discovered/?= =?UTF-8?q?=20folder;=20field=20testing=20will=20flag=20that=20for=20a=20d?= =?UTF-8?q?ecoder=20patch=20if=20it=20occurs.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AbCipDriver.cs | 10 +- .../AbCipDriverOptions.cs | 9 + .../CipSymbolObjectDecoder.cs | 128 ++++++++++++ .../LibplctagTagEnumerator.cs | 63 ++++++ .../AbCipDriverDiscoveryTests.cs | 4 + .../CipSymbolObjectDecoderTests.cs | 186 ++++++++++++++++++ 6 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 311bca0..a45421c 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -44,7 +44,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, _options = options; _driverInstanceId = driverInstanceId; _tagFactory = tagFactory ?? new LibplctagTagFactory(); - _enumeratorFactory = enumeratorFactory ?? new EmptyAbCipTagEnumeratorFactory(); + _enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory(); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => @@ -559,9 +559,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag)); } - // Controller-discovered tags — optional. Default enumerator returns an empty sequence; - // tests + the follow-up real @tags walker plug in via the ctor parameter. - if (_devices.TryGetValue(device.HostAddress, out var state)) + // Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags + // walker (LibplctagTagEnumerator) is the factory default since task #178 shipped, + // so leaving the flag off keeps the strict-config path for deployments where only + // declared tags should appear. + if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state)) { using var enumerator = _enumeratorFactory.Create(); var deviceParams = new AbCipTagCreateParams( diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs index 735fca2..469a64b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs @@ -29,6 +29,15 @@ public sealed class AbCipDriverOptions /// not pass a more specific value. Matches the Modbus driver's 2-second default. /// public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); + + /// + /// When true, DiscoverAsync walks each device's Logix symbol table via + /// the @tags pseudo-tag + surfaces controller-resident globals under a + /// Discovered/ sub-folder. Pre-declared tags always emit regardless. Default + /// false to keep the strict-config path for deployments where only declared tags + /// should appear in the address space. + /// + public bool EnableControllerBrowse { get; init; } } /// diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs new file mode 100644 index 0000000..e5248cb --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs @@ -0,0 +1,128 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Decoder for the CIP Symbol Object (class 0x6B) response returned by Logix controllers +/// when a client reads the @tags pseudo-tag. Parses the concatenated tag-info +/// entries into a sequence of s that the driver can stream +/// into the address-space builder. +/// +/// +/// Entry layout (little-endian) per Rockwell CIP Vol 1 + Logix 5000 CIP Programming +/// Manual (1756-PM019 chapter "Symbol Object"), cross-checked against libplctag's +/// ab/cip.c handle_listed_tags_reply: +/// +/// u32Symbol Instance ID — opaque identifier for the tag. +/// u16Symbol 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). +/// u16Element length — bytes per element (e.g. 4 for DINT). +/// u32 × 3Array dimensions — zero for scalar tags. +/// u16Symbol name length in bytes. +/// u8 × NASCII symbol name, padded to an even byte boundary. +/// +/// +/// Program:-scope tags arrive with their scope prefix baked into the name +/// (Program:MainProgram.StepIndex); decoder strips the prefix + emits the scope +/// separately so the driver's IAddressSpaceBuilder can organise them. +/// +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; + + /// + /// Decode the raw @tags 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. + /// + public static IEnumerable Decode(byte[] buffer) + { + ArgumentNullException.ThrowIfNull(buffer); + return DecodeImpl(buffer); + } + + private static IEnumerable 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 + } + } + + /// + /// Split a Program:MainProgram.StepIndex-style name into its scope + local + /// parts. Names without the Program: prefix pass through unchanged. + /// + 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)..]); + } + + /// + /// Map a CIP atomic type code (lower 12 bits of the symbol-type field) to our + /// surface. Returns null for unrecognised codes — + /// caller treats those as so the symbol is still + /// surfaced + downstream config can add a concrete type override. + /// + 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, + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs new file mode 100644 index 0000000..77476f6 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs @@ -0,0 +1,63 @@ +using System.Runtime.CompilerServices; +using libplctag; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Real that walks a Logix controller's symbol table by +/// reading the @tags pseudo-tag via libplctag + decoding the CIP Symbol Object +/// response with . +/// +/// +/// libplctag's Tag.GetBuffer() returns the raw Symbol Object bytes when the +/// tag name is @tags. The decoder walks the concatenated entries + emits +/// records matching our driver surface. +/// +/// Task #178 closed the stub gap from PR 5 — +/// is still available for tests that don't want to touch the native library, but the +/// production factory default now wires this implementation in. +/// +internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator +{ + private Tag? _tag; + + public async IAsyncEnumerable EnumerateAsync( + AbCipTagCreateParams deviceParams, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + // Build a tag specifically for the @tags pseudo — same gateway + path as the device, + // distinguished by the name alone. + _tag = new Tag + { + Gateway = deviceParams.Gateway, + Path = deviceParams.CipPath, + PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute), + Protocol = Protocol.ab_eip, + Name = "@tags", + Timeout = deviceParams.Timeout, + }; + + await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false); + await _tag.ReadAsync(cancellationToken).ConfigureAwait(false); + + var buffer = _tag.GetBuffer(); + foreach (var tag in CipSymbolObjectDecoder.Decode(buffer)) + yield return tag; + } + + public void Dispose() => _tag?.Dispose(); + + private static PlcType MapPlcType(string attribute) => attribute switch + { + "controllogix" => PlcType.ControlLogix, + "compactlogix" => PlcType.ControlLogix, + "micro800" => PlcType.Micro800, + _ => PlcType.ControlLogix, + }; +} + +/// Factory for . +internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory +{ + public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator(); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs index bfee8c0..1a07857 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs @@ -97,6 +97,7 @@ public sealed class AbCipDriverDiscoveryTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + EnableControllerBrowse = true, }, "drv-1", enumeratorFactory: enumeratorFactory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -119,6 +120,7 @@ public sealed class AbCipDriverDiscoveryTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + EnableControllerBrowse = true, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -137,6 +139,7 @@ public sealed class AbCipDriverDiscoveryTests var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + EnableControllerBrowse = true, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -153,6 +156,7 @@ public sealed class AbCipDriverDiscoveryTests { Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)], Timeout = TimeSpan.FromSeconds(7), + EnableControllerBrowse = true, }, "drv-1", enumeratorFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs new file mode 100644 index 0000000..9efa4af --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs @@ -0,0 +1,186 @@ +using System.Buffers.Binary; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +[Trait("Category", "Unit")] +public sealed class CipSymbolObjectDecoderTests +{ + /// + /// Build one Symbol Object entry in the byte layout + /// instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len] pad. + /// + private static byte[] BuildEntry( + uint instanceId, + ushort symbolType, + ushort elementLength, + (uint, uint, uint) arrayDims, + string name) + { + var nameBytes = Encoding.ASCII.GetBytes(name); + var nameLen = nameBytes.Length; + var totalLen = 22 + nameLen; + if ((totalLen & 1) != 0) totalLen++; // pad to even + + var buf = new byte[totalLen]; + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen); + Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen); + return buf; + } + + private static byte[] Concat(params byte[][] chunks) + { + var total = chunks.Sum(c => c.Length); + var result = new byte[total]; + var pos = 0; + foreach (var c in chunks) + { + Buffer.BlockCopy(c, 0, result, pos, c.Length); + pos += c.Length; + } + return result; + } + + [Fact] + public void Single_DInt_entry_decodes_to_scalar_DInt_tag() + { + var bytes = BuildEntry( + instanceId: 42, + symbolType: 0xC4, + elementLength: 4, + arrayDims: (0, 0, 0), + name: "Counter"); + + var tags = CipSymbolObjectDecoder.Decode(bytes).ToList(); + + tags.Count.ShouldBe(1); + tags[0].Name.ShouldBe("Counter"); + tags[0].ProgramScope.ShouldBeNull(); + tags[0].DataType.ShouldBe(AbCipDataType.DInt); + tags[0].IsSystemTag.ShouldBeFalse(); + } + + [Theory] + [InlineData((byte)0xC1, AbCipDataType.Bool)] + [InlineData((byte)0xC2, AbCipDataType.SInt)] + [InlineData((byte)0xC3, AbCipDataType.Int)] + [InlineData((byte)0xC4, AbCipDataType.DInt)] + [InlineData((byte)0xC5, AbCipDataType.LInt)] + [InlineData((byte)0xC6, AbCipDataType.USInt)] + [InlineData((byte)0xC7, AbCipDataType.UInt)] + [InlineData((byte)0xC8, AbCipDataType.UDInt)] + [InlineData((byte)0xC9, AbCipDataType.ULInt)] + [InlineData((byte)0xCA, AbCipDataType.Real)] + [InlineData((byte)0xCB, AbCipDataType.LReal)] + [InlineData((byte)0xD0, AbCipDataType.String)] + public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected) + { + CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected); + } + + [Fact] + public void Unknown_type_code_returns_null_so_caller_treats_as_opaque() + { + CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull(); + } + + [Fact] + public void Struct_flag_overrides_type_code_and_yields_Structure() + { + // 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234) + var bytes = BuildEntry( + instanceId: 5, + symbolType: 0x8000 | 0x0234, + elementLength: 16, + arrayDims: (0, 0, 0), + name: "Motor1"); + + var tag = CipSymbolObjectDecoder.Decode(bytes).Single(); + tag.DataType.ShouldBe(AbCipDataType.Structure); + } + + [Fact] + public void System_flag_surfaces_as_IsSystemTag_true() + { + var bytes = BuildEntry( + instanceId: 99, + symbolType: 0x1000 | 0xC4, // system flag + DINT + elementLength: 4, + arrayDims: (0, 0, 0), + name: "__Reserved_1"); + + var tag = CipSymbolObjectDecoder.Decode(bytes).Single(); + tag.IsSystemTag.ShouldBeTrue(); + tag.DataType.ShouldBe(AbCipDataType.DInt); + } + + [Fact] + public void Program_scope_name_splits_prefix_into_ProgramScope() + { + var bytes = BuildEntry( + instanceId: 1, + symbolType: 0xC4, + elementLength: 4, + arrayDims: (0, 0, 0), + name: "Program:MainProgram.StepIndex"); + + var tag = CipSymbolObjectDecoder.Decode(bytes).Single(); + tag.ProgramScope.ShouldBe("MainProgram"); + tag.Name.ShouldBe("StepIndex"); + } + + [Fact] + public void Multiple_entries_decode_in_wire_order_with_even_padding() + { + // Name "Abc" is 3 bytes — triggers the even-pad branch between entries. + var bytes = Concat( + BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // DINT named "Abc" (3-byte name, pads to 4) + BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi")); // REAL named "Pi" + + var tags = CipSymbolObjectDecoder.Decode(bytes).ToList(); + tags.Count.ShouldBe(2); + tags[0].Name.ShouldBe("Abc"); + tags[0].DataType.ShouldBe(AbCipDataType.DInt); + tags[1].Name.ShouldBe("Pi"); + tags[1].DataType.ShouldBe(AbCipDataType.Real); + } + + [Fact] + public void Truncated_buffer_stops_decoding_gracefully() + { + var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter"); + // Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw. + var truncated = full.Take(full.Length - 5).ToArray(); + + CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry + } + + [Fact] + public void Empty_buffer_yields_no_tags() + { + CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty(); + } + + [Theory] + [InlineData("Counter", null, "Counter")] + [InlineData("Program:MainProgram.Step", "MainProgram", "Step")] + [InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")] + [InlineData("Program:", null, "Program:")] // malformed — no dot + [InlineData("Program:OnlyProg", null, "Program:OnlyProg")] + [InlineData("Motor.Status.Running", null, "Motor.Status.Running")] + public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName) + { + var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input); + scope.ShouldBe(expectedScope); + name.ShouldBe(expectedName); + } +}