diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 209a232..db43d52 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -287,56 +287,127 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
- for (var i = 0; i < fullReferences.Count; i++)
- {
- var reference = fullReferences[i];
- if (!_tagsByName.TryGetValue(reference, out var def))
- {
- results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
- continue;
- }
- if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
- {
- results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
- continue;
- }
+ // Task #194 — plan the batch: members of the same parent UDT get collapsed into one
+ // whole-UDT read + in-memory member decode; every other reference falls back to the
+ // per-tag path that's been here since PR 3. Planner is a pure function over the
+ // current tag map; BOOL/String/Structure members stay on the fallback path because
+ // declaration-only offsets can't place them under Logix alignment rules.
+ var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
- try
- {
- var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
- await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
+ foreach (var group in plan.Groups)
+ await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
- var status = runtime.GetStatus();
- if (status != 0)
- {
- results[i] = new DataValueSnapshot(null,
- AbCipStatusMapper.MapLibplctagStatus(status), null, now);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
- $"libplctag status {status} reading {reference}");
- continue;
- }
-
- var tagPath = AbCipTagPath.TryParse(def.TagPath);
- var bitIndex = tagPath?.BitIndex;
- var value = runtime.DecodeValue(def.DataType, bitIndex);
- results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
- _health = new DriverHealth(DriverState.Healthy, now, null);
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception ex)
- {
- results[i] = new DataValueSnapshot(null,
- AbCipStatusMapper.BadCommunicationError, null, now);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
- }
- }
+ foreach (var fb in plan.Fallbacks)
+ await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false);
return results;
}
+ private async Task ReadSingleAsync(
+ AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ {
+ if (!_tagsByName.TryGetValue(reference, out var def))
+ {
+ results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
+ return;
+ }
+ if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
+ {
+ results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
+ return;
+ }
+
+ try
+ {
+ var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
+ await runtime.ReadAsync(ct).ConfigureAwait(false);
+
+ var status = runtime.GetStatus();
+ if (status != 0)
+ {
+ results[fb.OriginalIndex] = new DataValueSnapshot(null,
+ AbCipStatusMapper.MapLibplctagStatus(status), null, now);
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
+ $"libplctag status {status} reading {reference}");
+ return;
+ }
+
+ var tagPath = AbCipTagPath.TryParse(def.TagPath);
+ var bitIndex = tagPath?.BitIndex;
+ var value = runtime.DecodeValue(def.DataType, bitIndex);
+ results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
+ _health = new DriverHealth(DriverState.Healthy, now, null);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ results[fb.OriginalIndex] = new DataValueSnapshot(null,
+ AbCipStatusMapper.BadCommunicationError, null, now);
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ }
+ }
+
+ ///
+ /// Task #194 — perform one whole-UDT read on the parent tag, then decode each
+ /// grouped member from the runtime's buffer at its computed byte offset. A per-group
+ /// failure (parent read raised, non-zero libplctag status, or missing device) stamps
+ /// the mapped fault across every grouped member only — sibling groups + the
+ /// per-tag fallback list are unaffected.
+ ///
+ private async Task ReadGroupAsync(
+ AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ {
+ var parent = group.ParentDefinition;
+
+ if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
+ {
+ StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
+ return;
+ }
+
+ try
+ {
+ var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false);
+ await runtime.ReadAsync(ct).ConfigureAwait(false);
+
+ var status = runtime.GetStatus();
+ if (status != 0)
+ {
+ var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
+ StampGroupStatus(group, results, now, mapped);
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
+ $"libplctag status {status} reading UDT {group.ParentName}");
+ return;
+ }
+
+ foreach (var member in group.Members)
+ {
+ var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null);
+ results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
+ }
+ _health = new DriverHealth(DriverState.Healthy, now, null);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ }
+ }
+
+ private static void StampGroupStatus(
+ AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, uint statusCode)
+ {
+ foreach (var member in group.Members)
+ results[member.OriginalIndex] = new DataValueSnapshot(null, statusCode, null, now);
+ }
+
// ---- IWritable ----
///
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs
new file mode 100644
index 0000000..eefedb4
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs
@@ -0,0 +1,78 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Computes byte offsets for declared UDT members under Logix natural-alignment rules so
+/// a single whole-UDT read (task #194) can decode each member from one buffer without
+/// re-reading per member. Declaration-driven — the caller supplies
+/// rows; this helper produces the offset each member
+/// sits at in the parent tag's read buffer.
+///
+///
+/// Alignment rules applied per Rockwell "Logix 5000 Data Access" manual + the
+/// libplctag test fixtures: each member aligns to its natural boundary (SInt 1, Int 2,
+/// DInt/Real/Dt 4, LInt/ULInt/LReal 8), padding inserted before the member as needed.
+/// The total size is padded to the alignment of the largest member so arrays-of-UDT also
+/// work at element stride — though this helper is used only on single instances today.
+///
+/// returns null on unsupported member types
+/// (, ,
+/// ). Whole-UDT grouping opts out of those groups
+/// and falls back to the per-tag read path — BOOL members are packed into a hidden host
+/// byte at the top of the UDT under Logix, so their offset can't be computed from
+/// declared-member order alone. The CIP Template Object reader produces a
+/// that carries real offsets for BOOL + nested structs; when
+/// that shape is cached the driver can take the richer path instead.
+///
+public static class AbCipUdtMemberLayout
+{
+ ///
+ /// Try to compute member offsets for the supplied declared members. Returns null
+ /// if any member type is unsupported for declaration-only layout.
+ ///
+ public static IReadOnlyDictionary? TryBuild(
+ IReadOnlyList members)
+ {
+ ArgumentNullException.ThrowIfNull(members);
+ if (members.Count == 0) return null;
+
+ var offsets = new Dictionary(members.Count, StringComparer.OrdinalIgnoreCase);
+ var cursor = 0;
+
+ foreach (var member in members)
+ {
+ if (!TryGetSizeAlign(member.DataType, out var size, out var align))
+ return null;
+
+ if (cursor % align != 0)
+ cursor += align - (cursor % align);
+
+ offsets[member.Name] = cursor;
+ cursor += size;
+ }
+
+ return offsets;
+ }
+
+ ///
+ /// Natural size + alignment for a Logix atomic type. false for types excluded
+ /// from declaration-only grouping (Bool / String / Structure).
+ ///
+ private static bool TryGetSizeAlign(AbCipDataType type, out int size, out int align)
+ {
+ switch (type)
+ {
+ case AbCipDataType.SInt: case AbCipDataType.USInt:
+ size = 1; align = 1; return true;
+ case AbCipDataType.Int: case AbCipDataType.UInt:
+ size = 2; align = 2; return true;
+ case AbCipDataType.DInt: case AbCipDataType.UDInt:
+ case AbCipDataType.Real: case AbCipDataType.Dt:
+ size = 4; align = 4; return true;
+ case AbCipDataType.LInt: case AbCipDataType.ULInt:
+ case AbCipDataType.LReal:
+ size = 8; align = 8; return true;
+ default:
+ size = 0; align = 0; return false;
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs
new file mode 100644
index 0000000..690e357
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs
@@ -0,0 +1,109 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Task #194 — groups a ReadAsync batch of full-references into whole-UDT reads where
+/// possible. A group is emitted for every parent UDT tag whose declared
+/// s produced a valid offset map AND at least two of
+/// its members appear in the batch; every other reference stays in the per-tag fallback
+/// list that runs through its existing read path.
+/// Pure function — the planner never touches the runtime + never reads the PLC.
+///
+public static class AbCipUdtReadPlanner
+{
+ ///
+ /// Split into whole-UDT groups + per-tag leftovers.
+ /// is the driver's _tagsByName map — both parent
+ /// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
+ /// to match the driver's dictionary semantics.
+ ///
+ public static AbCipUdtReadPlan Build(
+ IReadOnlyList requests,
+ IReadOnlyDictionary tagsByName)
+ {
+ ArgumentNullException.ThrowIfNull(requests);
+ ArgumentNullException.ThrowIfNull(tagsByName);
+
+ var fallback = new List(requests.Count);
+ var byParent = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ for (var i = 0; i < requests.Count; i++)
+ {
+ var name = requests[i];
+ if (!tagsByName.TryGetValue(name, out var def))
+ {
+ fallback.Add(new AbCipUdtReadFallback(i, name));
+ continue;
+ }
+
+ var (parentName, memberName) = SplitParentMember(name);
+ if (parentName is null || memberName is null
+ || !tagsByName.TryGetValue(parentName, out var parent)
+ || parent.DataType != AbCipDataType.Structure
+ || parent.Members is not { Count: > 0 })
+ {
+ fallback.Add(new AbCipUdtReadFallback(i, name));
+ continue;
+ }
+
+ var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
+ if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
+ {
+ fallback.Add(new AbCipUdtReadFallback(i, name));
+ continue;
+ }
+
+ if (!byParent.TryGetValue(parentName, out var members))
+ {
+ members = new List();
+ byParent[parentName] = members;
+ }
+ members.Add(new AbCipUdtReadMember(i, def, offset));
+ }
+
+ // A single-member group saves nothing (one whole-UDT read replaces one per-member read)
+ // — demote to fallback to avoid paying the cost of reading the full UDT buffer only to
+ // pull one field out.
+ var groups = new List(byParent.Count);
+ foreach (var (parentName, members) in byParent)
+ {
+ if (members.Count < 2)
+ {
+ foreach (var m in members)
+ fallback.Add(new AbCipUdtReadFallback(m.OriginalIndex, m.Definition.Name));
+ continue;
+ }
+ groups.Add(new AbCipUdtReadGroup(parentName, tagsByName[parentName], members));
+ }
+
+ return new AbCipUdtReadPlan(groups, fallback);
+ }
+
+ private static (string? Parent, string? Member) SplitParentMember(string reference)
+ {
+ var dot = reference.IndexOf('.');
+ if (dot <= 0 || dot == reference.Length - 1) return (null, null);
+ return (reference[..dot], reference[(dot + 1)..]);
+ }
+}
+
+/// A planner output: grouped UDT reads + per-tag fallbacks.
+public sealed record AbCipUdtReadPlan(
+ IReadOnlyList Groups,
+ IReadOnlyList Fallbacks);
+
+/// One UDT parent whose members were batched into a single read.
+public sealed record AbCipUdtReadGroup(
+ string ParentName,
+ AbCipTagDefinition ParentDefinition,
+ IReadOnlyList Members);
+
+///
+/// One member inside an . OriginalIndex is the
+/// slot in the caller's request list so the decoded value lands at the correct output
+/// offset. Definition is the fanned-out member-level tag definition. Offset
+/// is the byte offset within the parent UDT buffer where this member lives.
+///
+public sealed record AbCipUdtReadMember(int OriginalIndex, AbCipTagDefinition Definition, int Offset);
+
+/// A reference that falls back to the per-tag read path.
+public sealed record AbCipUdtReadFallback(int OriginalIndex, string Reference);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
index e01e011..16c9c90 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
@@ -31,6 +31,17 @@ public interface IAbCipTagRuntime : IDisposable
///
object? DecodeValue(AbCipDataType type, int? bitIndex);
+ ///
+ /// Decode a value at an arbitrary byte offset in the local buffer. Task #194 —
+ /// whole-UDT reads perform one on the parent UDT tag then
+ /// call this per declared member with its computed offset, avoiding one libplctag
+ /// round-trip per member. Implementations that do not support offset-aware decoding
+ /// may fall back to when is zero;
+ /// offsets greater than zero against an unsupporting runtime should return null
+ /// so the planner can skip grouping.
+ ///
+ object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex);
+
///
/// Encode into the local buffer per the tag's type. Callers
/// pair this with .
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index 891d27d..aeda12f 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -32,24 +32,26 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
public int GetStatus() => (int)_tag.GetStatus();
- public object? DecodeValue(AbCipDataType type, int? bitIndex) => type switch
+ public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
+
+ public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
- : _tag.GetInt8(0) != 0,
- AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(0),
- AbCipDataType.USInt => (int)_tag.GetUInt8(0),
- AbCipDataType.Int => (int)_tag.GetInt16(0),
- AbCipDataType.UInt => (int)_tag.GetUInt16(0),
- AbCipDataType.DInt => _tag.GetInt32(0),
- AbCipDataType.UDInt => (int)_tag.GetUInt32(0),
- AbCipDataType.LInt => _tag.GetInt64(0),
- AbCipDataType.ULInt => (long)_tag.GetUInt64(0),
- AbCipDataType.Real => _tag.GetFloat32(0),
- AbCipDataType.LReal => _tag.GetFloat64(0),
- AbCipDataType.String => _tag.GetString(0),
- AbCipDataType.Dt => _tag.GetInt32(0), // seconds-since-epoch DINT; consumer widens as needed
- AbCipDataType.Structure => null, // UDT whole-tag decode lands in PR 6
+ : _tag.GetInt8(offset) != 0,
+ AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
+ AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
+ AbCipDataType.Int => (int)_tag.GetInt16(offset),
+ AbCipDataType.UInt => (int)_tag.GetUInt16(offset),
+ AbCipDataType.DInt => _tag.GetInt32(offset),
+ AbCipDataType.UDInt => (int)_tag.GetUInt32(offset),
+ AbCipDataType.LInt => _tag.GetInt64(offset),
+ AbCipDataType.ULInt => (long)_tag.GetUInt64(offset),
+ AbCipDataType.Real => _tag.GetFloat32(offset),
+ AbCipDataType.LReal => _tag.GetFloat64(offset),
+ AbCipDataType.String => _tag.GetString(offset),
+ AbCipDataType.Dt => _tag.GetInt32(offset),
+ AbCipDataType.Structure => null,
_ => null,
};
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs
new file mode 100644
index 0000000..f2436e2
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs
@@ -0,0 +1,130 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// Task #194 — ReadAsync integration tests for the whole-UDT grouping path. The fake
+/// runtime records ReadCount + surfaces member values by byte offset so we can assert
+/// both "one read per parent UDT" and "each member decoded at the correct offset."
+///
+[Trait("Category", "Unit")]
+public sealed class AbCipDriverWholeUdtReadTests
+{
+ private const string Device = "ab://10.0.0.5/1,0";
+
+ private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
+ {
+ var factory = new FakeAbCipTagFactory();
+ var opts = new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(Device)],
+ Tags = tags,
+ };
+ return (new AbCipDriver(opts, "drv-1", factory), factory);
+ }
+
+ private static AbCipTagDefinition MotorUdt() => new(
+ "Motor", Device, "Motor", AbCipDataType.Structure, Members:
+ [
+ new AbCipStructureMember("Speed", AbCipDataType.DInt), // offset 0
+ new AbCipStructureMember("Torque", AbCipDataType.Real), // offset 4
+ ]);
+
+ [Fact]
+ public async Task Two_members_of_same_udt_trigger_one_parent_read()
+ {
+ var (drv, factory) = NewDriver(MotorUdt());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
+
+ snapshots.Count.ShouldBe(2);
+ snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
+ snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
+
+ // Factory should have created ONE runtime (for the parent "Motor") + issued ONE read.
+ // Without the optimization two runtimes (one per member) + two reads would appear.
+ factory.Tags.Count.ShouldBe(1);
+ factory.Tags.ShouldContainKey("Motor");
+ factory.Tags["Motor"].ReadCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Each_member_decodes_at_its_own_offset()
+ {
+ var (drv, factory) = NewDriver(MotorUdt());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ // Arrange the offset-keyed values before the read fires — the planner places
+ // Speed at offset 0 (DInt) and Torque at offset 4 (Real).
+ // The fake records CreationParams so we fetch it up front by the parent name.
+ var snapshotsTask = drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
+ // The factory creates the runtime inside ReadAsync; we need to set the offset map
+ // AFTER creation. Easier path: create the runtime on demand by reading once then
+ // re-arming. Instead: seed via a pre-read by constructing the fake in the factory's
+ // customise hook.
+ var snapshots = await snapshotsTask;
+
+ // First run establishes the runtime + gives the fake a chance to hold its reference.
+ factory.Tags["Motor"].ValuesByOffset[0] = 1234; // Speed
+ factory.Tags["Motor"].ValuesByOffset[4] = 9.5f; // Torque
+
+ snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
+ snapshots[0].Value.ShouldBe(1234);
+ snapshots[1].Value.ShouldBe(9.5f);
+ }
+
+ [Fact]
+ public async Task Parent_read_failure_stamps_every_grouped_member_Bad()
+ {
+ var (drv, factory) = NewDriver(MotorUdt());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ // Prime runtime existence via a first (successful) read so we can flip it to error.
+ await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
+ factory.Tags["Motor"].Status = -3; // libplctag BadTimeout — mapped in AbCipStatusMapper
+
+ var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
+
+ snapshots.Count.ShouldBe(2);
+ snapshots[0].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
+ snapshots[0].Value.ShouldBeNull();
+ snapshots[1].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
+ snapshots[1].Value.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Mixed_batch_groups_udt_and_falls_back_atomics()
+ {
+ var plain = new AbCipTagDefinition("PlainDint", Device, "PlainDint", AbCipDataType.DInt);
+ var (drv, factory) = NewDriver(MotorUdt(), plain);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snapshots = await drv.ReadAsync(
+ ["Motor.Speed", "PlainDint", "Motor.Torque"], CancellationToken.None);
+
+ snapshots.Count.ShouldBe(3);
+ // Motor parent ran one read, PlainDint ran its own read = 2 runtimes, 2 reads total.
+ factory.Tags.Count.ShouldBe(2);
+ factory.Tags.ShouldContainKey("Motor");
+ factory.Tags.ShouldContainKey("PlainDint");
+ factory.Tags["Motor"].ReadCount.ShouldBe(1);
+ factory.Tags["PlainDint"].ReadCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Single_member_of_Udt_uses_per_tag_read_path()
+ {
+ // One member of a UDT doesn't benefit from grouping — the planner demotes to
+ // fallback so the member-level runtime (distinct from the parent runtime) is used,
+ // matching pre-#194 behavior.
+ var (drv, factory) = NewDriver(MotorUdt());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.ReadAsync(["Motor.Speed"], CancellationToken.None);
+
+ factory.Tags.ShouldContainKey("Motor.Speed");
+ factory.Tags.ShouldNotContainKey("Motor");
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberLayoutTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberLayoutTests.cs
new file mode 100644
index 0000000..6901cb5
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberLayoutTests.cs
@@ -0,0 +1,72 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class AbCipUdtMemberLayoutTests
+{
+ [Fact]
+ public void Packed_Atomics_Get_Natural_Alignment_Offsets()
+ {
+ // DInt (4 align) + Real (4) + Int (2) + LInt (8 — forces 2-byte pad before it)
+ var members = new[]
+ {
+ new AbCipStructureMember("A", AbCipDataType.DInt),
+ new AbCipStructureMember("B", AbCipDataType.Real),
+ new AbCipStructureMember("C", AbCipDataType.Int),
+ new AbCipStructureMember("D", AbCipDataType.LInt),
+ };
+
+ var offsets = AbCipUdtMemberLayout.TryBuild(members);
+ offsets.ShouldNotBeNull();
+ offsets!["A"].ShouldBe(0);
+ offsets["B"].ShouldBe(4);
+ offsets["C"].ShouldBe(8);
+ // cursor at 10 after Int; LInt needs 8-byte alignment → pad to 16
+ offsets["D"].ShouldBe(16);
+ }
+
+ [Fact]
+ public void SInt_Packed_Without_Padding()
+ {
+ var members = new[]
+ {
+ new AbCipStructureMember("X", AbCipDataType.SInt),
+ new AbCipStructureMember("Y", AbCipDataType.SInt),
+ new AbCipStructureMember("Z", AbCipDataType.SInt),
+ };
+ var offsets = AbCipUdtMemberLayout.TryBuild(members);
+ offsets!["X"].ShouldBe(0);
+ offsets["Y"].ShouldBe(1);
+ offsets["Z"].ShouldBe(2);
+ }
+
+ [Fact]
+ public void Returns_Null_When_Member_Is_Bool()
+ {
+ // BOOL storage in Logix UDTs is packed into a hidden host byte; declaration-only
+ // layout can't place it. Grouping opts out; per-tag read path handles the member.
+ var members = new[]
+ {
+ new AbCipStructureMember("A", AbCipDataType.DInt),
+ new AbCipStructureMember("Flag", AbCipDataType.Bool),
+ };
+ AbCipUdtMemberLayout.TryBuild(members).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Returns_Null_When_Member_Is_String_Or_Structure()
+ {
+ AbCipUdtMemberLayout.TryBuild(
+ new[] { new AbCipStructureMember("Name", AbCipDataType.String) }).ShouldBeNull();
+ AbCipUdtMemberLayout.TryBuild(
+ new[] { new AbCipStructureMember("Nested", AbCipDataType.Structure) }).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Returns_Null_On_Empty_Members()
+ {
+ AbCipUdtMemberLayout.TryBuild(Array.Empty()).ShouldBeNull();
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtReadPlannerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtReadPlannerTests.cs
new file mode 100644
index 0000000..3725362
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtReadPlannerTests.cs
@@ -0,0 +1,123 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class AbCipUdtReadPlannerTests
+{
+ private const string Device = "ab://10.0.0.1/1,0";
+
+ [Fact]
+ public void Groups_Two_Members_Of_The_Same_Udt_Parent()
+ {
+ var tags = BuildUdtTagMap(out var _);
+ var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags);
+
+ plan.Groups.Count.ShouldBe(1);
+ plan.Groups[0].ParentName.ShouldBe("Motor");
+ plan.Groups[0].Members.Count.ShouldBe(2);
+ plan.Fallbacks.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void Single_Member_Reference_Falls_Back_To_Per_Tag_Path()
+ {
+ // Reading just one member of a UDT gains nothing from grouping — one whole-UDT read
+ // vs one member read is equivalent cost but more client-side work. Planner demotes.
+ var tags = BuildUdtTagMap(out var _);
+ var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags);
+
+ plan.Groups.ShouldBeEmpty();
+ plan.Fallbacks.Count.ShouldBe(1);
+ plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed");
+ }
+
+ [Fact]
+ public void Unknown_References_Fall_Back_Without_Affecting_Groups()
+ {
+ var tags = BuildUdtTagMap(out var _);
+ var plan = AbCipUdtReadPlanner.Build(
+ new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags);
+
+ plan.Groups.Count.ShouldBe(1);
+ plan.Groups[0].Members.Count.ShouldBe(2);
+ plan.Fallbacks.Count.ShouldBe(2);
+ plan.Fallbacks.ShouldContain(f => f.Reference == "DoesNotExist");
+ plan.Fallbacks.ShouldContain(f => f.Reference == "Motor.NonMember");
+ }
+
+ [Fact]
+ public void Atomic_Top_Level_Tag_Falls_Back_Untouched()
+ {
+ var tags = BuildUdtTagMap(out var _);
+ tags = new Dictionary(tags, StringComparer.OrdinalIgnoreCase)
+ {
+ ["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt),
+ };
+ var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags);
+
+ plan.Groups.Count.ShouldBe(1);
+ plan.Fallbacks.Count.ShouldBe(1);
+ plan.Fallbacks[0].Reference.ShouldBe("PlainDint");
+ }
+
+ [Fact]
+ public void Udt_With_Bool_Member_Does_Not_Group()
+ {
+ // Any BOOL in the declared members disqualifies the group — offset rules for BOOL
+ // can't be determined from declaration alone (Logix packs them into a hidden host
+ // byte). Fallback path reads each member individually.
+ var members = new[]
+ {
+ new AbCipStructureMember("Run", AbCipDataType.Bool),
+ new AbCipStructureMember("Speed", AbCipDataType.DInt),
+ };
+ var parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
+ Members: members);
+ var tags = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Motor"] = parent,
+ ["Motor.Run"] = new("Motor.Run", Device, "Motor.Run", AbCipDataType.Bool),
+ ["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
+ };
+
+ var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags);
+
+ plan.Groups.ShouldBeEmpty();
+ plan.Fallbacks.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public void Original_Indices_Preserved_For_Out_Of_Order_Batches()
+ {
+ var tags = BuildUdtTagMap(out var _);
+ var plan = AbCipUdtReadPlanner.Build(
+ new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags);
+
+ // Motor.Speed was at index 1, Motor.Torque at 3 — must survive through the plan so
+ // ReadAsync can write decoded values back at the right output slot.
+ plan.Groups.ShouldHaveSingleItem();
+ var group = plan.Groups[0];
+ group.Members.ShouldContain(m => m.OriginalIndex == 1 && m.Definition.Name == "Motor.Speed");
+ group.Members.ShouldContain(m => m.OriginalIndex == 3 && m.Definition.Name == "Motor.Torque");
+ plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 0 && f.Reference == "Other");
+ plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 2 && f.Reference == "DoesNotExist");
+ }
+
+ private static Dictionary BuildUdtTagMap(out AbCipTagDefinition parent)
+ {
+ var members = new[]
+ {
+ new AbCipStructureMember("Speed", AbCipDataType.DInt),
+ new AbCipStructureMember("Torque", AbCipDataType.Real),
+ };
+ parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: members);
+ return new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Motor"] = parent,
+ ["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
+ ["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real),
+ };
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
index 78dac91..9ecdf29 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
@@ -47,6 +47,21 @@ internal class FakeAbCipTag : IAbCipTagRuntime
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
+ ///
+ /// Task #194 whole-UDT read support. Tests drive multi-member decoding by setting
+ /// — keyed by member byte offset — before invoking
+ /// . Falls back to when the
+ /// offset is zero or unmapped so existing tests that never set the offset map keep
+ /// working unchanged.
+ ///
+ public Dictionary ValuesByOffset { get; } = new();
+
+ public virtual object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex)
+ {
+ if (ValuesByOffset.TryGetValue(offset, out var v)) return v;
+ return offset == 0 ? Value : null;
+ }
+
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
public virtual void Dispose() => Disposed = true;