From 4a7b0fde7b121b4861d5481696fad2c1eed07348 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 20:30:12 -0400 Subject: [PATCH] fix(abcip): thread CIP template-instance-id so discovered-UDT expansion works in production (review) --- .../AbCipDriver.cs | Bin 72490 -> 74953 bytes .../CipSymbolObjectDecoder.cs | 9 +- .../IAbCipTagEnumerator.cs | 6 +- .../LibplctagTagEnumerator.cs | 3 + .../AbCipDriverDiscoveryTests.cs | 212 ++++++++++++++++-- .../CipSymbolObjectDecoderTests.cs | 29 ++- 6 files changed, 239 insertions(+), 20 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 855ebc2f81c6b9e97b541ada786a005d02e7acc4..51b0bfa2ddbc6934dc8b33f0864aeb65572ca4b0 100644 GIT binary patch delta 3206 zcmai0O>9(E6b{e;ffmX?Em96e(rIC4iXj2}7e?A*%`k1~6oO&lo%im{Jo@I{$Gi8n z3_lhVm6b{Bb!FTbqYF1`nz%G^i7t$Ze;WctU9>e3jE&;TbMJf8c~h!1O@Ai$e&?L; zeCIp&K=Z5>oPG^UPGjMJol^)7o1eOO;q#!04xXkr!A}r>BOGJ@( zZ36sCCh4EbDX)rBrunFo>6UO?p@Zi=vFgTXcsHrqqc-dp7Zz!AuWD$F` zT*@&UhI7wfriK8M8qA~~aNo-lgK`zW8VREKf)v^~oE{pHjrF^;u~hD~<@PU!&yqqS zOG_^1OotpUh-1(pv;8Pc7A>O~g{<63@v>uJJdYBSW2%D5=|2y|A>IdrR2aoEQ;Zgg zOT%*v)WH?`$=h&fI}pbNgBVzyB~;Bq-q_qcm4FNbPN^9U?%@B_OjJMWfXu*XcAy`) z@Q^1c(Nd0jTr)X3kUAS5&ZI9w5(t>g@JrMYNVQ4x)bog)fK0fK-hH zkh7UFj+uI%g-mCL<%Z2C$FQt;Ue$}ZmJ>Z*(>lrp2itPS!NEWAEcbJ9uK3}QD~D-= zLrN4TEFxds)bdJq6l~HGMdk~vLs5diDlpNq!QxMOahpz1TdRu;%eH}D zb&+u1o{%%^cgx0x*?zdpEC;V(9t1LDX^6F5>TGLYW;vxlBnV58S!|F)F&k7JK{6cX zSO&4>y@n&hM}f0whdFhLM+Ea!Vm=m^vkE@*IdWDYo@rCg)yzy5iGUCiF9Jdpb{X;*C6GcqltP5zm||X67;>uS z5TdNNcVRWbG(D+>Kng4R?=b9tQ+*Ur!sb0w1?u=nC#Hntvygz$kTgNxb_eKadTNPM6 z4>gvwCl;ER7NR;Cc%3|!YTAVW;arA}*N#$Ce$%4K(0OX~6=SR}pTo;6A6T5nNedp) zt;UWKK&v%qk=bb9^2$FuWM}V2>Hb#xl;LJ9`$fEo{7E zC={krdR`+zI`#4p8lI{`D~X|xV=$YNysC&8!Y8&}mNP%>KaPMTr$xn-N@>Xw%9)lG zl?r=~*$XIJ)JC6|zyG{TUB>D=iquiS6G)H4@VYC?+SbBUaekw1 zQttSrQ@TBMs@ClWafOe|ud-99DZiX+TGOr0YBIivJClP{zUDqriFd-)WQk5Ki(?eT4<0TEj(9y;HQN{a>L?^ zTYZZM<@n;s+LIqFruKTYM7(j{SCd4xNHN{!Dcw!z!8r!Qqxs%eMbY)_&DLaJ=)%!n zIQUAy8#PF!s+i3G70YTW!T1&9smLUT4jgQynlQ^Y+^)u8Sx8zjC^(X{X&_HEDHLs- zA__iHqc|uy2lT`YziIel#q#6s4(#qyaLQgUzQO;c0+oVUNwNEh>4l{;Pk2d*u z@%Ug3T4fk~2SwJsykZR0%cRo17Lw(m8COOtydk%3J0R~jw$9%8ZB1SCf9rO3=HbpY zQ#gYC_!x1xH9@;HsGeF}sLG;#!C}5{JP<$exiEslSF_;kk>53v8fn(6qi_UNr zA6)(FBfW)*u0}063=y1k31+ME#u-triM#2fUQX)iWrAW~`^Gqe070)nQ?NxG$2ZP> z?o!1Bhk}orJ2Cs-qlfE8R&k`tv!v=sHo0cuGSo!s@VMet1>qRRaJNu94|uFB4?ad8 z{BGlm${#C8!2@T7bTsKt^`3-$?QYYqAl_i$kG)}BdFyVooL<`cba!dq8#qf8CUKtF zK9wIXJ*zE7+4l6(`q}xV=Cv1)ltJbhYNZDy28RQFt4n3s$b+pIv2bi{Rb40mTT4tX P{dIdC2AwVc{aNe3o7hQI delta 1298 zcmZ8hU1%It6n4|trul1|#0nbhNo;LOI^9N#fhNWVTS!Z2{MlCUMeod<-Mw~p?r`s& zjjPD^LGVEzgroRk9|X}N2$luwleF|H)KZ0_eGvaD=0R*$^B_X0o}J00P3CDhbAG<> zJKvdwh4xRsX#f0J=YawmYrr7-;Hl8lXc4B}r(|@(x6hSDgrMA)(c_$|6rI3NK2P)g z4{ZRWsJV3Ci~&!C;35PvJjGo`L8BLI!>=Q@jNsNRIssNOSgD2rR-!=goVlbk_~H5~4bg_HK6Iq%@r-;@obEBeG6_AV}5*kCh+pN_1P{E3e8bqK%+_Mh~^k>%tn<1Y8 z-Fjo-d5skzLjx@^kyd`}S*b#ZR1`CclB^n47 zNbaJ1QQOPnCSa_JQten&!8(W_FyIRBR64D#R3@f45f@4QeIN>5}PqYJ&GV=a5wL`ZHB2@h96 z8sxPI%lfezF>`liDONEZ+*0km=QU$BD}J06d3~6^T^ORDx4coGMP6p6Wz8vy&{44k zo0>6>qM&xij>#U(3bB04f-oXOFNl5S0U_6*@1$eE#IBR(XMS{RnMqn(*#q;;vfdgd zIHQW|-MD5CNS##wUICs}wD^)7Li?eSd43_D^)sJ0ESQ{a^OdON-s~ z&ByoBSHI09Id5O94@ex63OqTOUpM84MHlU znE>8sMS5!z=9b2H!=yKL)XYf33zx5=e~?XSa5C>r?Ol`m+0r=wA0F;r6YOt3O3yUc z1#NSj&NqkXv88_csX0c2e@)ZrS9&^B;G-U%;+y{aKrdiLT3$38jH9%ze+OM~h8t&> zbM18Qhk?eWe+F{&;>C@PGyndwfudWTRKLA>V=?JL*KhB9?@X?bqTf3j_x}fN?|+cX U)#%6Xj?r6xbe>mjxfh222anjz!vFvP diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs index 9110d2ff..740b9efe 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs @@ -78,12 +78,19 @@ public static class CipSymbolObjectDecoder var (programScope, simpleName) = SplitProgramScope(name); var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode); + // For a struct symbol the lower 12 bits (typeCode) are NOT a primitive CIP type code — + // they are the CIP template instance id (per the bit-15 struct-flag remarks above). Surface + // it so the driver can fetch the UDT's Template Object during discovery. Atomic symbols + // carry no template id. + var templateInstanceId = isStruct ? (uint?)typeCode : null; + 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); + IsSystemTag: isSystem, + TemplateInstanceId: templateInstanceId); _ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs index 8163ab96..de596e0c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs @@ -45,6 +45,9 @@ public interface IAbCipTagEnumeratorFactory /// reported a 1-D array (even of length 1). Surfaces the tag as an OPC UA array node at /// discovery; alone can't distinguish a scalar from a 1-element /// array. +/// The CIP template instance id for a discovered Structure tag +/// (null for non-structs / when unknown). Used to fetch the UDT's Template Object shape +/// during discovery so the struct can be fanned out into addressable member variables. public sealed record AbCipDiscoveredTag( string Name, string? ProgramScope, @@ -52,7 +55,8 @@ public sealed record AbCipDiscoveredTag( bool ReadOnly, bool IsSystemTag = false, int ElementCount = 1, - bool IsArray = false); + bool IsArray = false, + uint? TemplateInstanceId = null); /// /// No-op enumerator returning an empty sequence. Useful for tests + strict-config diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs index 26b241a4..2dc7d5ec 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs @@ -45,6 +45,9 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator await _tag.ReadAsync(cancellationToken).ConfigureAwait(false); var buffer = _tag.GetBuffer(); + // The decoder already surfaces each struct tag's CIP template instance id on + // AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so + // re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out. foreach (var tag in CipSymbolObjectDecoder.Decode(buffer)) yield return tag; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs index 18519c3c..a8a9a3ad 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs @@ -1,4 +1,6 @@ +using System.Buffers.Binary; using System.Runtime.CompilerServices; +using System.Text; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -166,6 +168,65 @@ public sealed class AbCipDriverDiscoveryTests byName["Motor1.Status.Code"].DriverDataType.ShouldBe(DriverDataType.Int32); } + /// + /// PRODUCTION PATH (no test seam): a controller-discovered Structure tag carries its CIP + /// template instance id (surfaced by the Symbol Object decoder onto + /// ). DiscoverAsync threads that id into + /// FetchUdtShapeAsync, which reads the Template Object off the controller via the + /// injected and fans the top-level UDT out into atomic + /// member Variables — WITHOUT any call to SeedDiscoveredUdtShapeForTest. This proves the + /// production top-level expansion is functional, not inert. + /// + [Fact] + public async Task Controller_discovered_UDT_top_level_expands_via_template_id_fetch_no_seam() + { + const string device = "ab://10.0.0.5/1,0"; + const uint motorTemplateId = 0x2A; // lower 12 bits of the struct symbol type + + var builder = new RecordingBuilder(); + // Discovered tag carries the template instance id — exactly what the real Symbol Object + // decoder produces for a struct symbol. + var enumeratorFactory = new FakeEnumeratorFactory( + new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false, + TemplateInstanceId: motorTemplateId)); + + // Fake template reader keyed by id: a Read Template for motorTemplateId returns a real + // Template Object blob with two atomic members (Speed/Real, Running/Bool). No seed seam. + var templateReaderFactory = new IdKeyedTemplateReaderFactory + { + Templates = + { + [motorTemplateId] = BuildSimpleTemplate("MotorUdt", 8, + ("Speed", 0xCA, 0, 0), // 0xCA = Real + ("Running", 0xC1, 0, 4)), // 0xC1 = Bool + }, + }; + + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(device)], + EnableControllerBrowse = true, + }, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templateReaderFactory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + // The reader was actually consulted for the discovered tag's template id — the production + // fetch path ran (not the seam). + templateReaderFactory.LastTemplateId.ShouldBe(motorTemplateId); + + // The Motor1 sub-folder + atomic member leaves are emitted; the bogus Structure placeholder + // Variable is NOT. + builder.Folders.ShouldContain(f => f.BrowseName == "Motor1"); + builder.Variables.ShouldNotContain(v => v.Info.FullName == "Motor1"); + + var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info); + byName.ShouldContainKey("Motor1.Speed"); + byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32); + byName.ShouldContainKey("Motor1.Running"); + byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean); + } + /// /// A discovered Structure whose shape cannot be resolved degrades to the prior single /// Variable behavior (no regression): one Variable under the Discovered folder keyed by @@ -194,6 +255,51 @@ public sealed class AbCipDriverDiscoveryTests builder.Folders.ShouldNotContain(f => f.BrowseName == "MysteryUdt"); } + /// + /// A nested struct member whose sub-shape resolves but yields NO emittable atomic leaf must + /// not leave an empty nested sub-folder in the browse tree — the folder is materialised lazily, + /// only once a leaf is actually emitted into it. Here Empty (a nested struct) has only + /// a further nested struct child whose own shape is unresolvable, so the whole Empty + /// branch produces no leaf and must produce no folder either; the sibling atomic leaf is kept. + /// + [Fact] + public async Task Controller_discovered_UDT_does_not_leave_empty_nested_subfolder() + { + const string device = "ab://10.0.0.5/1,0"; + var builder = new RecordingBuilder(); + var enumeratorFactory = new FakeEnumeratorFactory( + new AbCipDiscoveredTag("Top", null, AbCipDataType.Structure, ReadOnly: false)); + + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(device)], + EnableControllerBrowse = true, + }, "drv-1", enumeratorFactory: enumeratorFactory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Top has one atomic leaf (Ok) + a nested struct (Empty). Empty's shape resolves, but its + // only member is a further nested struct (Ghost) whose shape is NOT seeded → unresolvable → + // no leaf anywhere under Empty. + drv.SeedDiscoveredUdtShapeForTest(device, "Top", new AbCipUdtShape("TopUdt", 8, + [ + new AbCipUdtMember("Ok", 0, AbCipDataType.DInt, ArrayLength: 1), + new AbCipUdtMember("Empty", 4, AbCipDataType.Structure, ArrayLength: 1), + ])); + drv.SeedDiscoveredUdtShapeForTest(device, "Empty", new AbCipUdtShape("EmptyUdt", 4, + [ + new AbCipUdtMember("Ghost", 0, AbCipDataType.Structure, ArrayLength: 1), + ])); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + // The Top folder + its atomic leaf are emitted. + builder.Folders.ShouldContain(f => f.BrowseName == "Top"); + builder.Variables.Select(v => v.Info.FullName).ShouldContain("Top.Ok"); + // The Empty nested struct yields no leaf → no empty sub-folder is created for it. + builder.Folders.ShouldNotContain(f => f.BrowseName == "Empty"); + builder.Variables.Select(v => v.Info.FullName).ShouldNotContain(n => n.StartsWith("Top.Empty", StringComparison.Ordinal)); + } + /// /// Nested-struct recursion is bounded by MaxUdtDepth: a member at the cap depth /// is dropped rather than emitted. Here the chain L0.L1.L2.… is seeded deeper than the @@ -252,27 +358,42 @@ public sealed class AbCipDriverDiscoveryTests } /// - /// A discovered member-path read resolves to the member's atomic type via the - /// equipment-tag resolver over the authored TagConfig (FullName = Parent.Member), - /// so no extra registration is needed for discovered members to be readable. + /// A discovered member-path read goes end-to-end through the driver: with NO pre-declared + /// tag for the member, resolves the authored TagConfig + /// JSON blob (FullName = Parent.Member) via the equipment-tag resolver, materialises a + /// runtime on the device, reads it, and decodes the member's atomic value — proving discovered + /// members are readable with no extra registration. The libplctag tag name the driver builds + /// for the member equals the dotted member path. /// [Fact] - public void Discovered_member_path_resolves_to_member_atomic_type() + public async Task Discovered_member_path_reads_through_driver_as_member_atomic_type() { - var drv = new AbCipDriver(new AbCipDriverOptions - { - Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], - }, "drv-1"); - - // The wire reference handed to ReadAsync for a discovered member is the authored - // TagConfig JSON blob (FullName = Motor1.Status.Code, atomic type Real here). + const string device = "ab://10.0.0.5/1,0"; + // The wire reference handed to ReadAsync for a discovered member is the authored TagConfig + // JSON blob (FullName = Motor1.Status.Code, atomic type Real here). No tag is pre-declared. const string tagConfig = "{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\",\"deviceHostAddress\":\"ab://10.0.0.5/1,0\"}"; - AbCipEquipmentTagParser.TryParse(tagConfig, out var def).ShouldBeTrue(); - def.TagPath.ShouldBe("Motor1.Status.Code"); - def.DataType.ShouldBe(AbCipDataType.Real); - def.DataType.ToDriverDataType().ShouldBe(DriverDataType.Float32); + var tagFactory = new FakeAbCipTagFactory + { + // The driver creates the runtime keyed by the libplctag tag name = the dotted member + // path; seed a Real value the read should surface. + Customise = p => new FakeAbCipTag(p) { Value = p.TagName == "Motor1.Status.Code" ? 12.5f : null }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(device)], + }, "drv-1", tagFactory: tagFactory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.ReadAsync([tagConfig], CancellationToken.None); + + results.Count.ShouldBe(1); + results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + results[0].Value.ShouldBe(12.5f); + // The driver built a runtime under the dotted member tag name — confirming the member-path + // read addresses the member directly (no parent-UDT registration needed). + tagFactory.Tags.ShouldContainKey("Motor1.Status.Code"); } /// Verifies that controller enumeration honours system tag hint and filter. @@ -486,4 +607,65 @@ public sealed class AbCipDriverDiscoveryTests public void Dispose() { } } } + + /// + /// Fake whose readers return a Template Object blob + /// keyed by template instance id — the production fetch path (FetchUdtShapeAsync) keyed by the + /// id the Symbol Object decoder surfaces, with NO use of the seed seam. + /// + private sealed class IdKeyedTemplateReaderFactory : IAbCipTemplateReaderFactory + { + /// Gets the template blobs keyed by template instance id. + public Dictionary Templates { get; } = new(); + /// Gets the last template id any reader was asked for. + public uint? LastTemplateId { get; private set; } + + /// Creates a reader over the shared template map. + public IAbCipTemplateReader Create() => new Reader(this); + + private sealed class Reader(IdKeyedTemplateReaderFactory outer) : IAbCipTemplateReader + { + public Task ReadAsync( + AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken) + { + outer.LastTemplateId = templateInstanceId; + return Task.FromResult( + outer.Templates.TryGetValue(templateInstanceId, out var blob) ? blob : []); + } + + public void Dispose() { } + } + } + + /// + /// Build a minimal CIP Template Object blob can decode + /// into an . Mirrors the helper in + /// AbCipFetchUdtShapeTests: 12-byte header + 8-byte member blocks + semicolon/NUL + /// terminated UDT-then-member name strings. + /// + private static byte[] BuildSimpleTemplate( + string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members) + { + const int headerSize = 12; + const int blockSize = 8; + var strings = new MemoryStream(); + void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); } + Add(name); + foreach (var m in members) Add(m.n); + var stringsArr = strings.ToArray(); + + var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length]; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize); + for (var i = 0; i < members.Length; i++) + { + var o = headerSize + i * blockSize; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off); + } + Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length); + return buf; + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs index 1d937f24..b5a1fc0d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs @@ -98,11 +98,15 @@ public sealed class CipSymbolObjectDecoderTests CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull(); } - /// Verifies that struct flag overrides type code. + /// + /// Verifies that the struct flag overrides the type code AND that the lower 12 bits are + /// surfaced as the CIP template instance id (used by discovery to fetch the UDT's Template + /// Object). This is the plumbing that makes top-level discovered-UDT expansion functional. + /// [Fact] - public void Struct_flag_overrides_type_code_and_yields_Structure() + public void Struct_flag_yields_Structure_and_surfaces_template_instance_id() { - // 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234) + // 0x8000 (struct flag) | 0x0234 (template instance id in the lower 12 bits) var bytes = BuildEntry( instanceId: 5, symbolType: 0x8000 | 0x0234, @@ -112,6 +116,25 @@ public sealed class CipSymbolObjectDecoderTests var tag = CipSymbolObjectDecoder.Decode(bytes).Single(); tag.DataType.ShouldBe(AbCipDataType.Structure); + tag.TemplateInstanceId.ShouldBe(0x0234u); + } + + /// + /// Verifies that an atomic (non-struct) symbol carries no template instance id — only struct + /// symbols repurpose the lower 12 bits as a template id. + /// + [Fact] + public void Atomic_symbol_has_no_template_instance_id() + { + var bytes = BuildEntry( + instanceId: 7, + symbolType: 0xC4, // DINT — atomic, not a struct + elementLength: 4, + arrayDims: (0, 0, 0), + name: "Counter"); + + var tag = CipSymbolObjectDecoder.Decode(bytes).Single(); + tag.TemplateInstanceId.ShouldBeNull(); } /// Verifies that system flag surfaces as IsSystemTag true.