diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index be3e8c0..6b33eff 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -61,7 +61,27 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily); _devices[device.HostAddress] = new DeviceState(addr, device, profile); } - foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag; + foreach (var tag in _options.Tags) + { + // UDT tags with declared Members fan out into synthetic member-tag entries addressable + // by composed full-reference. Parent structure tag also stored so discovery can emit a + // folder for it. + _tagsByName[tag.Name] = tag; + if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 }) + { + foreach (var member in tag.Members) + { + var memberTag = new AbCipTagDefinition( + Name: $"{tag.Name}.{member.Name}", + DeviceHostAddress: tag.DeviceHostAddress, + TagPath: $"{tag.TagPath}.{member.Name}", + DataType: member.DataType, + Writable: member.Writable, + WriteIdempotent: member.WriteIdempotent); + _tagsByName[memberTag.Name] = memberTag; + } + } + } _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } catch (Exception ex) @@ -304,12 +324,37 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, var deviceLabel = device.DeviceName ?? device.HostAddress; var deviceFolder = root.Folder(device.HostAddress, deviceLabel); - // Pre-declared tags — always emitted; the primary config path. + // Pre-declared tags — always emitted; the primary config path. UDT tags with declared + // Members fan out into a sub-folder + one Variable per member instead of a single + // Structure Variable (Structure has no useful scalar value + member-addressable paths + // are what downstream consumers actually want). var preDeclared = _options.Tags.Where(t => string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); foreach (var tag in preDeclared) { if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue; + + if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 }) + { + var udtFolder = deviceFolder.Folder(tag.Name, tag.Name); + foreach (var member in tag.Members) + { + var memberFullName = $"{tag.Name}.{member.Name}"; + udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo( + FullName: memberFullName, + DriverDataType: member.DataType.ToDriverDataType(), + IsArray: false, + ArrayDim: null, + SecurityClass: member.Writable + ? SecurityClassification.Operate + : SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: member.WriteIdempotent)); + } + continue; + } + deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag)); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs index 2d99471..552660b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs @@ -54,12 +54,30 @@ public sealed record AbCipDeviceOptions( /// Logix atomic type, or for UDT-typed tags. /// When true and the tag's ExternalAccess permits writes, IWritable routes writes here. /// Per plan decisions #44–#45, #143 — safe to replay on write timeout. Default false. +/// For -typed tags, the declared UDT +/// member layout. When supplied, discovery fans out the UDT into a folder + one Variable per +/// member (member TagPath = {tag.TagPath}.{member.Name}). When null on a Structure +/// tag, the driver treats it as a black-box and relies on downstream configuration to address +/// members individually via dotted syntax. Ignored for atomic types. public sealed record AbCipTagDefinition( string Name, string DeviceHostAddress, string TagPath, AbCipDataType DataType, bool Writable = true, + bool WriteIdempotent = false, + IReadOnlyList? Members = null); + +/// +/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. Speed, +/// Status), DataType is the atomic Logix type, Writable/WriteIdempotent mirror +/// . Declaration-driven — the real CIP Template Object reader +/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR. +/// +public sealed record AbCipStructureMember( + string Name, + AbCipDataType DataType, + bool Writable = true, bool WriteIdempotent = false); /// Which AB PLC family the device is — selects the profile applied to connection params. diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs new file mode 100644 index 0000000..0b369c2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs @@ -0,0 +1,217 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +[Trait("Category", "Unit")] +public sealed class AbCipUdtMemberTests +{ + [Fact] + public async Task UDT_with_declared_members_fans_out_to_member_variables() + { + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = + [ + new AbCipTagDefinition( + Name: "Motor1", + DeviceHostAddress: "ab://10.0.0.5/1,0", + TagPath: "Motor1", + DataType: AbCipDataType.Structure, + Members: + [ + new AbCipStructureMember("Speed", AbCipDataType.DInt), + new AbCipStructureMember("Running", AbCipDataType.Bool, Writable: false), + new AbCipStructureMember("SetPoint", AbCipDataType.Real, WriteIdempotent: true), + ]), + ], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "Motor1"); + var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList(); + variables.ShouldContain(("Speed", "Motor1.Speed")); + variables.ShouldContain(("Running", "Motor1.Running")); + variables.ShouldContain(("SetPoint", "Motor1.SetPoint")); + + builder.Variables.Single(v => v.BrowseName == "Running").Info.SecurityClass + .ShouldBe(SecurityClassification.ViewOnly); + builder.Variables.Single(v => v.BrowseName == "SetPoint").Info.WriteIdempotent + .ShouldBeTrue(); + } + + [Fact] + public async Task UDT_members_resolvable_for_read_via_synthesised_full_reference() + { + var factory = new FakeAbCipTagFactory + { + Customise = p => p.TagName switch + { + "Motor1.Speed" => new FakeAbCipTag(p) { Value = 1800 }, + "Motor1.Running" => new FakeAbCipTag(p) { Value = true }, + _ => new FakeAbCipTag(p), + }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = + [ + new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure, + Members: + [ + new AbCipStructureMember("Speed", AbCipDataType.DInt), + new AbCipStructureMember("Running", AbCipDataType.Bool), + ]), + ], + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["Motor1.Speed", "Motor1.Running"], CancellationToken.None); + + snapshots[0].Value.ShouldBe(1800); + snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + snapshots[1].Value.ShouldBe(true); + snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); + } + + [Fact] + public async Task UDT_member_write_routes_through_synthesised_tagpath() + { + var factory = new FakeAbCipTagFactory(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = + [ + new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure, + Members: + [ + new AbCipStructureMember("SetPoint", AbCipDataType.Real), + ]), + ], + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("Motor1.SetPoint", 42.5f)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); + factory.Tags["Motor1.SetPoint"].Value.ShouldBe(42.5f); + } + + [Fact] + public async Task UDT_member_read_write_honours_member_Writable_flag() + { + var factory = new FakeAbCipTagFactory(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = + [ + new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure, + Members: + [ + new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false), + ]), + ], + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("Motor1.Status", 1)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); + } + + [Fact] + public async Task Structure_tag_without_members_is_emitted_as_single_variable() + { + // Fallback path: a Structure tag with no declared Members still appears as a Variable so + // downstream configuration can address it manually. This matches the "black box" note in + // AbCipTagDefinition's docstring. + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.ShouldContain(v => v.BrowseName == "OpaqueUdt"); + builder.Folders.ShouldNotContain(f => f.BrowseName == "OpaqueUdt"); + } + + [Fact] + public async Task Empty_Members_list_is_treated_like_null() + { + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldNotContain(f => f.BrowseName == "EmptyUdt"); + builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt"); + } + + [Fact] + public async Task UDT_members_mixed_with_flat_tags_coexist() + { + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = + [ + new AbCipTagDefinition("FlatA", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt), + new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure, + Members: + [ + new AbCipStructureMember("Speed", AbCipDataType.DInt), + ]), + new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real), + ], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.Select(v => v.BrowseName).ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true); + } + + // ---- helpers ---- + + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { Folders.Add((browseName, displayName)); return this; } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) + { Variables.Add((browseName, info)); return new Handle(info.FullName); } + + public void AddProperty(string _, DriverDataType __, object? ___) { } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } + } +}