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 51b0bfa2..376bbe4b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -50,10 +50,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // DiscoverAsync threads into ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the // Template Object off the controller and cache it in the id-keyed _templateCache. No seed // needed. - // * NESTED struct members — SEAM-ONLY / a documented deferral. A decoded Template Object member - // block carries NO nested-template id, so a nested struct's shape can't be re-fetched in - // production. Tests link nested sub-shapes by member name via SeedDiscoveredUdtShapeForTest; - // without a seed a nested struct member is dropped (the top-level UDT still expands). + // * NESTED struct members — FUNCTIONAL in production. A decoded Template Object member block carries + // the nested UDT's template instance id (low 12 bits of the member info, captured by + // CipTemplateObjectDecoder onto AbCipUdtMember.NestedTemplateId), which the fan-out threads into + // ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the nested Template Object off the + // controller. The name-keyed seed (SeedDiscoveredUdtShapeForTest) still wins first for tests; a + // nested member whose id can't be resolved is dropped (the top-level UDT still expands). private readonly Dictionary _discoveredUdtShapes = new(StringComparer.OrdinalIgnoreCase); @@ -65,12 +67,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// /// Test seam — seed a discovered-UDT shape under for - /// . Needed only for NESTED-struct sub-shapes (linked by - /// member name): a decoded Template Object member block carries no nested-template id, so a - /// nested struct's shape genuinely can't be re-fetched in production — seeding is the only way - /// a fan-out test can recurse into it. The TOP-LEVEL discovered UDT does NOT need a seed; the - /// production path fetches its shape via from the discovered - /// tag's template instance id. + /// , consulted by name before any id-keyed fetch. Lets a + /// fan-out test link a nested sub-shape by member name without a live Template Object read. + /// Neither the TOP-LEVEL discovered UDT nor a NESTED-struct member needs a seed in production: + /// both carry a template instance id (the top level on + /// , a nested member on + /// ) that the fan-out fetches via + /// . /// /// The device the struct lives on. /// The discovered struct type or nested-member name. @@ -1176,8 +1179,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // members live a level deeper, so the cap is checked against depth + 1. if (depth + 1 > MaxUdtDepth) continue; if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here + // The decoded parent shape carries the nested UDT's template instance id (low 12 bits of the + // member info, captured by CipTemplateObjectDecoder) — thread it so the nested shape is fetched + // off the controller via FetchUdtShapeAsync, making nested-struct members addressable in + // production (name-keyed seeded shapes still win first, for the test seam). var nested = await ResolveDiscoveredUdtShapeAsync( - deviceHostAddress, member.Name, templateInstanceId: null, cancellationToken) + deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken) .ConfigureAwait(false); if (nested is not null) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs index 369c45d2..5618713a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs @@ -54,8 +54,16 @@ public sealed record AbCipUdtShape( IReadOnlyList Members); /// One member of a Logix UDT. +/// Member name as reported by the Template Object. +/// Byte offset of the member from the struct start. +/// Decoded member data type ( for a nested UDT). +/// Element count (1 for a scalar member). +/// For a nested-struct member, the nested UDT's CIP template instance id +/// (low 12 bits of the member info, same encoding as the Symbol Object) so the nested shape can be +/// fetched via ; null for a scalar member. public sealed record AbCipUdtMember( string Name, int Offset, AbCipDataType DataType, - int ArrayLength); + int ArrayLength, + uint? NestedTemplateId = null); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs index 0fb1a5ce..1d0b1f3c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs @@ -88,12 +88,18 @@ public static class CipTemplateObjectDecoder ? AbCipDataType.Structure : (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure); + // For a struct member the low 12 bits are the nested UDT's template instance id (same encoding as the + // Symbol Object), NOT a primitive type code — capture it so the nested shape can be fetched. Use the + // FULL 12-bit mask (not the byte-cast typeCode, which truncates a 12-bit id). + var nestedTemplateId = isStruct ? (uint?)(info & MemberInfoTypeCodeMask) : null; + var memberName = i < memberNames.Length ? memberNames[i] : $""; members.Add(new AbCipUdtMember( Name: memberName, Offset: offset, DataType: dataType, - ArrayLength: arraySize == 0 ? 1 : arraySize)); + ArrayLength: arraySize == 0 ? 1 : arraySize, + NestedTemplateId: nestedTemplateId)); } return new AbCipUdtShape( 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 a8a9a3ad..8c6e3342 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 @@ -227,6 +227,66 @@ public sealed class AbCipDriverDiscoveryTests byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean); } + /// + /// PRODUCTION PATH (no test seam): a controller-discovered UDT whose Template Object carries + /// a NESTED struct member fans the nested member out into its own atomic leaves WITHOUT any + /// SeedDiscoveredUdtShapeForTest. The nested member block carries the nested UDT's + /// template instance id (low 12 bits, struct flag set); the decoder captures it as + /// NestedTemplateId and DiscoverAsync threads it into a real FetchUdtShapeAsync + /// for the nested id. Proves the nested fetch actually happened (reader consulted for the + /// nested id) and that the nested struct's leaf member is addressable. + /// + [Fact] + public async Task Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam() + { + const string device = "ab://10.0.0.5/1,0"; + const uint motorTemplateId = 0x2A; // parent UDT template id + const uint statusTemplateId = 0x123; // nested UDT template id — exceeds a byte (12-bit proof) + + var builder = new RecordingBuilder(); + var enumeratorFactory = new FakeEnumeratorFactory( + new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false, + TemplateInstanceId: motorTemplateId)); + + // The parent template carries an atomic member (Speed/Real) + a NESTED struct member (Status) + // whose member-info encodes the struct flag (0x8000) | the nested template id (0x123). The + // nested template id is read off @udt/0x123 and yields one atomic leaf (Code/DInt). + var templateReaderFactory = new IdKeyedTemplateReaderFactory + { + Templates = + { + [motorTemplateId] = BuildSimpleTemplate("MotorUdt", 12, + ("Speed", 0xCA, 0, 0), // Real + ("Status", (ushort)(0x8000 | statusTemplateId), 0, 8)), // struct + nested id 0x123 + [statusTemplateId] = BuildSimpleTemplate("StatusUdt", 4, + ("Code", 0xC4, 0, 0)), // DInt + }, + }; + + 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 nested template id actually drove a fetch — the reader was consulted for it (the seam + // would have left LastTemplateId at the parent id and never read the nested shape). + templateReaderFactory.RequestedIds.ShouldContain(statusTemplateId); + + var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info); + // The parent atomic leaf is emitted. + byName.ShouldContainKey("Motor1.Speed"); + byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32); + // The NESTED struct's atomic leaf is addressable — proving the nested fetch + fan-out ran. + byName.ShouldContainKey("Motor1.Status.Code"); + byName["Motor1.Status.Code"].DriverDataType.ShouldBe(DriverDataType.Int32); + // A Motor1.Status sub-folder was materialised for the nested struct. + builder.Folders.ShouldContain(f => f.BrowseName == "Motor1.Status"); + } + /// /// 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 @@ -619,6 +679,8 @@ public sealed class AbCipDriverDiscoveryTests public Dictionary Templates { get; } = new(); /// Gets the last template id any reader was asked for. public uint? LastTemplateId { get; private set; } + /// Gets every template id any reader was asked for, in request order. + public List RequestedIds { get; } = new(); /// Creates a reader over the shared template map. public IAbCipTemplateReader Create() => new Reader(this); @@ -629,6 +691,7 @@ public sealed class AbCipDriverDiscoveryTests AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken) { outer.LastTemplateId = templateInstanceId; + outer.RequestedIds.Add(templateInstanceId); return Task.FromResult( outer.Templates.TryGetValue(templateInstanceId, out var blob) ? blob : []); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipTemplateObjectDecoderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipTemplateObjectDecoderTests.cs index be83b5d5..aeb5a987 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipTemplateObjectDecoderTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipTemplateObjectDecoderTests.cs @@ -86,6 +86,31 @@ public sealed class CipTemplateObjectDecoderTests shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure); } + /// + /// A struct member's lower 12 bits carry the nested UDT's template instance id (same + /// encoding as the Symbol Object); the decoder captures it as NestedTemplateId so the + /// nested shape can be fetched. A scalar member carries no nested id. Uses the FULL 12-bit + /// mask (0x123 here exceeds a byte) to prove the id is not byte-truncated. + /// + [Fact] + public void Struct_member_captures_nested_template_id_scalar_member_does_not() + { + var bytes = BuildTemplate("ContainerUdt", instanceSize: 8, + ("Inner", info: (ushort)(0x8000 | 0x123), arraySize: 0, offset: 0), // struct + nested id 0x123 + ("Count", info: 0xC4, arraySize: 0, offset: 4)); // scalar DINT, no nested id + + var shape = CipTemplateObjectDecoder.Decode(bytes); + + shape.ShouldNotBeNull(); + shape.Members.Count.ShouldBe(2); + shape.Members[0].Name.ShouldBe("Inner"); + shape.Members[0].DataType.ShouldBe(AbCipDataType.Structure); + shape.Members[0].NestedTemplateId.ShouldBe(0x123u); // full 12-bit id, not byte-truncated + shape.Members[1].Name.ShouldBe("Count"); + shape.Members[1].DataType.ShouldBe(AbCipDataType.DInt); + shape.Members[1].NestedTemplateId.ShouldBeNull(); + } + /// Verifies that array members carry correct non-one array lengths. [Fact] public void Array_member_carries_non_one_ArrayLength()