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 855ebc2f..51b0bfa2 100644
Binary files a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs and b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs differ
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.