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;