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) { } }
+ }
+}