From 4e1414026e24d2b014c4a3e495809ac1c7b9dd22 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 20:11:41 -0400 Subject: [PATCH] feat(abcip): expand controller-discovered UDTs into addressable member variables --- .../AbCipDriver.cs | Bin 61448 -> 72490 bytes .../AbCipDriverDiscoveryTests.cs | 162 ++++++++++++++++++ 2 files changed, 162 insertions(+) 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 d8b7be013c6675469ded25c903e0407899005d5f..855ebc2f81c6b9e97b541ada786a005d02e7acc4 100644 GIT binary patch delta 8014 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 delta 50 zcmV-20L}lZwgiaa1F*pdladA%vz`(x7qj>;Q>&AJq6V`B+&u!5w9^!m%+nLIz2xo# Iv(NimA4W|WLI3~& 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 7a1b3ac5..18519c3c 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 @@ -113,6 +113,168 @@ public sealed class AbCipDriverDiscoveryTests builder.Variables.Select(v => v.Info.FullName).ShouldContain("Program:MainProgram.StepIndex"); } + /// + /// A controller-discovered UDT (a Structure tag) is expanded into a member sub-folder + /// with one Variable per atomic member, mirroring the pre-declared fan-out. A nested + /// struct member recurses into its own atomic leaves (dot-joined full names). The bogus + /// single Structure → String placeholder Variable must NOT be emitted. + /// + [Fact] + public async Task Controller_discovered_UDT_expands_into_member_variables() + { + var builder = new RecordingBuilder(); + var enumeratorFactory = new FakeEnumeratorFactory( + new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false)); + + 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); + + // Seed the discovered-UDT shapes the driver fans out from. The top-level Motor1 shape + // carries two atomic members (Speed/Real, Running/Bool) + a nested struct member + // (Status) whose own shape (Code/DInt) is seeded under its member name. + drv.SeedDiscoveredUdtShapeForTest("ab://10.0.0.5/1,0", "Motor1", new AbCipUdtShape( + "MotorUdt", 16, + [ + new AbCipUdtMember("Speed", 0, AbCipDataType.Real, ArrayLength: 1), + new AbCipUdtMember("Running", 4, AbCipDataType.Bool, ArrayLength: 1), + new AbCipUdtMember("Status", 8, AbCipDataType.Structure, ArrayLength: 1), + ])); + drv.SeedDiscoveredUdtShapeForTest("ab://10.0.0.5/1,0", "Status", new AbCipUdtShape( + "StatusUdt", 4, + [ + new AbCipUdtMember("Code", 0, AbCipDataType.DInt, ArrayLength: 1), + ])); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + // A Motor1 sub-folder is created under the Discovered folder. + builder.Folders.ShouldContain(f => f.BrowseName == "Motor1"); + // The bare Structure placeholder Variable is NOT emitted (no Motor1 String node). + 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); + // Nested struct leaf — dot-joined full name + the nested member's atomic type. + byName.ShouldContainKey("Motor1.Status.Code"); + byName["Motor1.Status.Code"].DriverDataType.ShouldBe(DriverDataType.Int32); + } + + /// + /// 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 + /// the bare struct name, never a broken member fan-out. + /// + [Fact] + public async Task Controller_discovered_UDT_with_unresolvable_shape_degrades_to_single_variable() + { + var builder = new RecordingBuilder(); + var enumeratorFactory = new FakeEnumeratorFactory( + new AbCipDiscoveredTag("MysteryUdt", null, AbCipDataType.Structure, ReadOnly: false)); + + 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); + + // No shape seeded + no fake template reader → FetchUdtShapeAsync returns null → degrade. + await drv.DiscoverAsync(builder, CancellationToken.None); + + var mystery = builder.Variables.Where(v => v.Info.FullName == "MysteryUdt").ToList(); + mystery.Count.ShouldBe(1); + // No member fan-out folder for an unresolvable shape. + builder.Folders.ShouldNotContain(f => f.BrowseName == "MysteryUdt"); + } + + /// + /// 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 + /// cap; the leaf beyond the cap must not appear. + /// + [Fact] + public async Task Controller_discovered_UDT_drops_members_deeper_than_MaxUdtDepth() + { + var builder = new RecordingBuilder(); + const string device = "ab://10.0.0.5/1,0"; + var enumeratorFactory = new FakeEnumeratorFactory( + new AbCipDiscoveredTag("Deep", 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); + + // Build a chain Deep -> N1 -> N2 -> ... -> N12 (struct each level) with an atomic Leaf at + // every level. AbCipDriver.MaxUdtDepth bounds how deep the fan-out recurses. + const int chainDepth = 12; + drv.SeedDiscoveredUdtShapeForTest(device, "Deep", new AbCipUdtShape("Deep", 8, + [ + new AbCipUdtMember("Leaf", 0, AbCipDataType.DInt, ArrayLength: 1), + new AbCipUdtMember("N1", 4, AbCipDataType.Structure, ArrayLength: 1), + ])); + for (var i = 1; i <= chainDepth; i++) + { + var next = i < chainDepth ? new[] { new AbCipUdtMember($"N{i + 1}", 4, AbCipDataType.Structure, 1) } : []; + var members = new List { new($"Leaf", 0, AbCipDataType.DInt, 1) }; + members.AddRange(next); + drv.SeedDiscoveredUdtShapeForTest(device, $"N{i}", new AbCipUdtShape($"N{i}", 8, members)); + } + + await drv.DiscoverAsync(builder, CancellationToken.None); + + var names = builder.Variables.Select(v => v.Info.FullName).ToHashSet(); + // The top-level leaf is present. + names.ShouldContain("Deep.Leaf"); + // A shallow nested leaf (well within the cap) is kept. + names.ShouldContain("Deep.N1.Leaf"); + names.ShouldContain("Deep.N1.N2.Leaf"); + // A leaf at the deepest chain level (well beyond the depth cap of 8) must be dropped. + var deepestPrefix = string.Join('.', Enumerable.Range(1, chainDepth).Select(i => $"N{i}")); + names.ShouldNotContain($"Deep.{deepestPrefix}.Leaf"); + // No member full-name should carry more than MaxUdtDepth path segments below the parent + // (parent + at most MaxUdtDepth dotted segments). The deepest emitted leaf proves the cap. + var maxSegmentsBelowParent = names + .Where(n => n.StartsWith("Deep.", StringComparison.Ordinal)) + .Select(n => n.Split('.').Length - 1) // segments after "Deep" + .DefaultIfEmpty(0) + .Max(); + maxSegmentsBelowParent.ShouldBeLessThanOrEqualTo(AbCipDriver.MaxUdtDepth); + } + + /// + /// 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. + /// + [Fact] + public void Discovered_member_path_resolves_to_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 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); + } + /// Verifies that controller enumeration honours system tag hint and filter. [Fact] public async Task Controller_enumeration_honours_system_tag_hint_and_filter()