From f4d5a5ee9cf546fe62e1578e3f6ed2db93ac337d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 21:55:20 -0400 Subject: [PATCH] feat(abcip): 1-D array read via libplctag + IsArray discovery --- .../AbCipDriverOptions.cs | 10 +- .../AbCipEquipmentTagParser.cs | 23 +- .../AbCipDriver.cs | 31 ++- .../IAbCipTagEnumerator.cs | 6 +- .../IAbCipTagRuntime.cs | 20 +- .../LibplctagTagRuntime.cs | 76 ++++++ .../AbCipArrayTests.cs | 229 ++++++++++++++++++ .../FakeAbCipTag.cs | 28 +++ 8 files changed, 408 insertions(+), 15 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs index 2ff250db..66417010 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs @@ -135,6 +135,10 @@ public sealed record AbCipDeviceOptions( /// GuardLogix controller; non-safety writes violate the safety-partition isolation and are /// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the /// write attempt failing at runtime. +/// Phase 4c — number of array elements for a 1-D array tag. Defaults +/// to 1 (scalar). When greater than 1 the tag discovers as an OPC UA array node +/// (IsArray + ArrayDim) and reads via libplctag's elem_count into an +/// element-typed CLR array. Ignored for . public sealed record AbCipTagDefinition( string Name, string DeviceHostAddress, @@ -143,7 +147,8 @@ public sealed record AbCipTagDefinition( bool Writable = true, bool WriteIdempotent = false, IReadOnlyList? Members = null, - bool SafetyTag = false); + bool SafetyTag = false, + int ElementCount = 1); /// /// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. Speed, @@ -155,7 +160,8 @@ public sealed record AbCipStructureMember( string Name, AbCipDataType DataType, bool Writable = true, - bool WriteIdempotent = false); + bool WriteIdempotent = false, + int ElementCount = 1); /// Which AB PLC family the device is — selects the profile applied to connection params. public enum AbCipPlcFamily diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs index de2bef82..8d590622 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs @@ -31,9 +31,13 @@ public static class AbCipEquipmentTagParser var deviceHostAddress = ReadString(root, "deviceHostAddress"); var dataType = ReadEnum(root, "dataType", AbCipDataType.DInt); + // Phase 4c — an isArray equipment tag carries arrayLength; thread it into the def's + // ElementCount so the read pulls the whole array via libplctag elem_count. When + // isArray is absent/false (or arrayLength is missing/<=1) the tag stays scalar. + var elementCount = ReadArrayElementCount(root); def = new AbCipTagDefinition( Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath, - DataType: dataType, Writable: true); + DataType: dataType, Writable: true, ElementCount: elementCount); return true; } catch (JsonException) { return false; } @@ -41,6 +45,23 @@ public static class AbCipEquipmentTagParser catch (InvalidOperationException) { return false; } } + /// + /// Resolve the 1-D array element count from an isArray / arrayLength pair. + /// Returns 1 (scalar) unless isArray is truthy AND arrayLength is a number + /// greater than 1; matches the sink's "isArray + arrayLength" carrier. + /// + private static int ReadArrayElementCount(JsonElement o) + { + var isArray = o.TryGetProperty("isArray", out var a) && a.ValueKind == JsonValueKind.True; + if (!isArray) return 1; + if (o.TryGetProperty("arrayLength", out var len) + && len.ValueKind == JsonValueKind.Number + && len.TryGetInt32(out var n) + && n > 1) + return n; + return 1; + } + private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String && Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback; 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 ff04db65..b69a2a3c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -553,7 +553,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, var tagPath = AbCipTagPath.TryParse(def.TagPath); var bitIndex = tagPath?.BitIndex; - var value = runtime.DecodeValue(def.DataType, bitIndex); + // Phase 4c — a 1-D array tag decodes the whole buffer into an element-typed CLR + // array (int[]/float[]/bool[]/string[]…); scalar tags keep the single-value path. + var value = def.ElementCount > 1 + ? runtime.DecodeArray(def.DataType, def.ElementCount) + : runtime.DecodeValue(def.DataType, bitIndex); results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now); _health = new DriverHealth(DriverState.Healthy, now, null); } @@ -850,7 +854,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ?? throw new InvalidOperationException( $"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'."); - var runtime = _tagFactory.Create(device.BuildCreateParams(parsed.ToLibplctagName(), _options.Timeout)); + // Phase 4c — a 1-D array tag (ElementCount > 1) sets libplctag's elem_count so the read + // pulls every element in one CIP transaction; the read path then boxes them into a + // typed CLR array. Scalar tags pass the default count of 1, unchanged. + var runtime = _tagFactory.Create( + device.BuildCreateParams(parsed.ToLibplctagName(), _options.Timeout, def.ElementCount)); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); @@ -945,8 +953,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo( FullName: memberFullName, DriverDataType: member.DataType.ToDriverDataType(), - IsArray: false, - ArrayDim: null, + IsArray: member.ElementCount > 1, + ArrayDim: member.ElementCount > 1 ? (uint)member.ElementCount : null, SecurityClass: member.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly, @@ -983,8 +991,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo( FullName: fullName, DriverDataType: discovered.DataType.ToDriverDataType(), - IsArray: false, - ArrayDim: null, + IsArray: discovered.ElementCount > 1, + ArrayDim: discovered.ElementCount > 1 ? (uint)discovered.ElementCount : null, SecurityClass: discovered.ReadOnly ? SecurityClassification.ViewOnly : SecurityClassification.Operate, @@ -999,8 +1007,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new( FullName: tag.Name, DriverDataType: tag.DataType.ToDriverDataType(), - IsArray: false, - ArrayDim: null, + IsArray: tag.ElementCount > 1, + ArrayDim: tag.ElementCount > 1 ? (uint)tag.ElementCount : null, SecurityClass: (tag.Writable && !tag.SafetyTag) ? SecurityClassification.Operate : SecurityClassification.ViewOnly, @@ -1102,8 +1110,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// /// The name of the tag to create parameters for. /// The timeout for tag operations. + /// libplctag elem_count — 1 for a scalar tag, the array + /// length for a 1-D array tag (Phase 4c). Coerced to a minimum of 1. /// The computed tag creation parameters. - public AbCipTagCreateParams BuildCreateParams(string tagName, TimeSpan timeout) => new( + public AbCipTagCreateParams BuildCreateParams(string tagName, TimeSpan timeout, int elementCount = 1) => new( Gateway: ParsedAddress.Gateway, Port: ParsedAddress.Port, CipPath: ParsedAddress.CipPath, @@ -1111,7 +1121,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, TagName: tagName, Timeout: timeout, AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking, - ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize); + ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize, + ElementCount: elementCount < 1 ? 1 : elementCount); /// Disposes all runtime tag handles and clears the caches. public void DisposeHandles() diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs index 4f187b39..6bf42a06 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs @@ -39,12 +39,16 @@ public interface IAbCipTagEnumeratorFactory /// Hint from the enumerator that this is a system / infrastructure tag; /// the driver applies on top so the enumerator is not the /// single source of truth. +/// Phase 4c — libplctag elem_count reported by the Symbol +/// Object's array-dimension fields. Defaults to 1 (scalar); greater than 1 surfaces the tag +/// as an OPC UA array node at discovery. public sealed record AbCipDiscoveredTag( string Name, string? ProgramScope, AbCipDataType DataType, bool ReadOnly, - bool IsSystemTag = false); + bool IsSystemTag = false, + int ElementCount = 1); /// /// No-op enumerator returning an empty sequence. Useful for tests + strict-config diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs index 536ec6fc..68d5f211 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs @@ -50,6 +50,19 @@ public interface IAbCipTagRuntime : IDisposable /// Bit index for BOOL-within-DINT extraction, or null. object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex); + /// + /// Phase 4c — decode the local buffer into an element-typed CLR array of + /// elements, boxed as (e.g. + /// int[], float[], bool[], string[]). The driver calls this + /// for a 1-D array tag after a single array (libplctag pulls all + /// elements in one transaction via its elem_count). Each element is decoded at its + /// byte stride within the buffer; scalar (count <= 1) reads stay on + /// . + /// + /// CIP element data type to decode. + /// Number of array elements to decode. + object? DecodeArray(AbCipDataType type, int count); + /// /// Encode into the local buffer per the tag's type. Callers /// pair this with . @@ -85,6 +98,10 @@ public interface IAbCipTagFactory /// (if any) with the family profile's DefaultConnectionSize. libplctag 1.5.2 has no /// direct ConnectionSize property; the value is plumbed for forward-compat with future /// wrappers / a custom tag-attribute path (Driver.AbCip-013). +/// Phase 4c — libplctag elem_count. Forwarded to the +/// libplctag Tag.ElementCount property so a 1-D array tag pulls all elements in one +/// CIP transaction. Defaults to 1 (scalar); the driver sets it from the tag definition's +/// element count for an isArray tag. public sealed record AbCipTagCreateParams( string Gateway, int Port, @@ -93,4 +110,5 @@ public sealed record AbCipTagCreateParams( string TagName, TimeSpan Timeout, bool AllowPacking = true, - int ConnectionSize = 4002); + int ConnectionSize = 4002, + int ElementCount = 1); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index 3f3f46ca..0c88f554 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -28,6 +28,9 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime // Driver.AbCip-013 — honour the per-device or family-default AllowPacking knob so // operators can disable CIP request-packing for older firmware or a single device. AllowPacking = p.AllowPacking, + // Phase 4c — libplctag elem_count. For a 1-D array tag the driver passes the element + // count so libplctag pulls every element in one CIP read; scalar tags pass 1. + ElementCount = p.ElementCount > 1 ? p.ElementCount : 1, }; // ConnectionSize is captured on AbCipTagCreateParams for forward-compat (driver-specs.md // exposes it as a per-device option) but libplctag.NET 1.5.2 has no direct Tag property @@ -87,6 +90,79 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime _ => null, }; + /// + /// Phase 4c — decode array elements from the post-read buffer + /// into an element-typed CLR array. libplctag has already pulled all elements (the tag + /// was created with elem_count = count); we slice the local buffer per element at + /// byte stride using the same per-offset decoders the + /// scalar / UDT-member paths use. The boxed result is a strongly-typed array + /// (int[], float[], bool[], string[], …) so the OPC UA layer + /// materialises a 1-D array variant. + /// + /// The element data type to decode. + /// The number of elements to decode. + /// A boxed element-typed CLR array, or null for an unsupported element type. + public object? DecodeArray(AbCipDataType type, int count) + { + if (count < 1) count = 1; + // libplctag reports the per-element byte size once the tag has been read; it correctly + // accounts for STRING capacity + atomic widths. ElementSize is int? on the wrapper — fall + // back to a static size table when it is null/<=0 (e.g. a zero-length read) so the stride + // is never 0. + var elementSize = _tag.ElementSize ?? 0; + var stride = elementSize > 0 ? elementSize : ElementByteSize(type); + + return type switch + { + // A Logix BOOL array is bit-packed on the wire; libplctag exposes each element via + // GetBit(elementIndex) rather than a byte stride, so decode bit-by-bit. + AbCipDataType.Bool => BuildBoolArray(count), + AbCipDataType.SInt or AbCipDataType.USInt or AbCipDataType.Int or AbCipDataType.UInt + or AbCipDataType.DInt or AbCipDataType.Dt => BuildArray(count, stride, type), + AbCipDataType.UDInt => BuildArray(count, stride, type), + AbCipDataType.LInt => BuildArray(count, stride, type), + AbCipDataType.ULInt => BuildArray(count, stride, type), + AbCipDataType.Real => BuildArray(count, stride, type), + AbCipDataType.LReal => BuildArray(count, stride, type), + AbCipDataType.String => BuildArray(count, stride, type), + _ => null, + }; + } + + private bool[] BuildBoolArray(int count) + { + var arr = new bool[count]; + for (var i = 0; i < count; i++) arr[i] = _tag.GetBit(i); + return arr; + } + + private T[] BuildArray(int count, int stride, AbCipDataType type) + { + var arr = new T[count]; + for (var i = 0; i < count; i++) + { + var decoded = DecodeValueAt(type, i * stride, null); + // DecodeValueAt boxes to the element CLR type that ToDriverDataType maps to, which is + // exactly T for every branch above; an explicit cast keeps the array strongly typed. + arr[i] = decoded is null ? default! : (T)decoded; + } + return arr; + } + + /// Static fallback byte stride per atomic element, used only when libplctag has not + /// yet populated . STRING falls back to the Logix STRING wire + /// size (4-byte LEN + 82-byte DATA, 88 with alignment) — but libplctag.NET's + /// ElementSize is the real source of truth at runtime. + private static int ElementByteSize(AbCipDataType type) => type switch + { + AbCipDataType.Bool or AbCipDataType.SInt or AbCipDataType.USInt => 1, + AbCipDataType.Int or AbCipDataType.UInt => 2, + AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4, + AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8, + AbCipDataType.String => 88, + _ => 4, + }; + /// Encodes the specified value to the tag with the specified data type. /// The data type to encode. /// The bit index for bit-level access, if applicable. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs new file mode 100644 index 00000000..4ea40fc6 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs @@ -0,0 +1,229 @@ +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; + +/// +/// Phase 4c (Task 6) — 1-D array support for AbCip. Covers: discovery flips +/// / +/// for an array atomic tag + an array UDT member; the read path returns a typed CLR array +/// boxed as ; and the equipment-tag resolver threads +/// arrayLength from the TagConfig into the transient definition's element count so +/// an isArray equipment tag reads the whole array. +/// +[Trait("Category", "Unit")] +public sealed class AbCipArrayTests +{ + private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags) + { + var factory = new FakeAbCipTagFactory(); + var opts = new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = tags, + }; + var drv = new AbCipDriver(opts, "drv-array", factory); + return (drv, factory); + } + + // ---- Discovery: IsArray / ArrayDim flips ---- + + /// An atomic pre-declared tag with ElementCount > 1 discovers as a 1-D array. + [Fact] + public async Task PreDeclared_array_tag_discovers_as_IsArray_with_ArrayDim() + { + var builder = new RecordingBuilder(); + var (drv, _) = NewDriver( + new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 10), + new AbCipTagDefinition("Single", "ab://10.0.0.5/1,0", "Single", AbCipDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + var arr = builder.Variables.Single(v => v.BrowseName == "Recipe").Info; + arr.IsArray.ShouldBeTrue(); + arr.ArrayDim.ShouldBe(10u); + + var scalar = builder.Variables.Single(v => v.BrowseName == "Single").Info; + scalar.IsArray.ShouldBeFalse(); + scalar.ArrayDim.ShouldBeNull(); + } + + /// A UDT member with ElementCount > 1 discovers as a 1-D array variable. + [Fact] + public async Task Udt_array_member_discovers_as_IsArray_with_ArrayDim() + { + var builder = new RecordingBuilder(); + var (drv, _) = NewDriver( + new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure, + Members: + [ + new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4), + new AbCipStructureMember("Speed", AbCipDataType.DInt), + ])); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + var member = builder.Variables.Single(v => v.BrowseName == "Setpoints").Info; + member.IsArray.ShouldBeTrue(); + member.ArrayDim.ShouldBe(4u); + + var scalarMember = builder.Variables.Single(v => v.BrowseName == "Speed").Info; + scalarMember.IsArray.ShouldBeFalse(); + scalarMember.ArrayDim.ShouldBeNull(); + } + + // ---- Read: typed CLR array ---- + + /// An array DInt tag reads as a boxed int[] of the configured element count. + [Fact] + public async Task Array_DInt_read_returns_typed_int_array() + { + var (drv, factory) = NewDriver( + new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 4)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 11, 22, 33, 44 }); + + var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None); + + snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); + var value = snapshots.Single().Value.ShouldBeOfType(); + value.ShouldBe([11, 22, 33, 44]); + // libplctag element count must be threaded to the runtime params. + factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4); + } + + /// An array Real tag reads as a boxed float[]. + [Fact] + public async Task Array_Real_read_returns_typed_float_array() + { + var (drv, factory) = NewDriver( + new AbCipTagDefinition("Floats", "ab://10.0.0.5/1,0", "Floats", AbCipDataType.Real, ElementCount: 3)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new ArrayFakeAbCipTag(p, new float[] { 1.5f, 2.5f, 3.5f }); + + var snapshots = await drv.ReadAsync(["Floats"], CancellationToken.None); + + var value = snapshots.Single().Value.ShouldBeOfType(); + value.ShouldBe([1.5f, 2.5f, 3.5f]); + } + + /// An array Bool tag reads as a boxed bool[]. + [Fact] + public async Task Array_Bool_read_returns_typed_bool_array() + { + var (drv, factory) = NewDriver( + new AbCipTagDefinition("Flags", "ab://10.0.0.5/1,0", "Flags", AbCipDataType.Bool, ElementCount: 3)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new ArrayFakeAbCipTag(p, new bool[] { true, false, true }); + + var snapshots = await drv.ReadAsync(["Flags"], CancellationToken.None); + + var value = snapshots.Single().Value.ShouldBeOfType(); + value.ShouldBe([true, false, true]); + } + + /// An array String tag reads as a boxed string[]. + [Fact] + public async Task Array_String_read_returns_typed_string_array() + { + var (drv, factory) = NewDriver( + new AbCipTagDefinition("Names", "ab://10.0.0.5/1,0", "Names", AbCipDataType.String, ElementCount: 2)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new ArrayFakeAbCipTag(p, new string[] { "a", "b" }); + + var snapshots = await drv.ReadAsync(["Names"], CancellationToken.None); + + var value = snapshots.Single().Value.ShouldBeOfType(); + value.ShouldBe(["a", "b"]); + } + + /// A scalar tag (ElementCount 1) is unaffected — still a boxed scalar, not an array. + [Fact] + public async Task Scalar_read_path_unchanged_for_element_count_one() + { + var (drv, factory) = NewDriver( + new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 }; + + var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None); + + snapshots.Single().Value.ShouldBe(4200); + snapshots.Single().Value.ShouldNotBeOfType(); + } + + // ---- Resolver: arrayLength threading ---- + + /// The equipment-tag resolver threads arrayLength into the def's ElementCount. + [Fact] + public async Task Equipment_ref_with_arrayLength_reads_as_a_typed_array() + { + var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":4}"""; + var factory = new FakeAbCipTagFactory(); + var opts = new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [], + }; + var drv = new AbCipDriver(opts, "abcip-eq-array", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 7, 8, 9, 10 }); + + var snapshots = await drv.ReadAsync([json], CancellationToken.None); + + snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); + var value = snapshots.Single().Value.ShouldBeOfType(); + value.ShouldBe([7, 8, 9, 10]); + factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4); + } + + /// The parser threads arrayLength into the transient definition's ElementCount. + [Fact] + public void Parser_threads_arrayLength_into_ElementCount() + { + var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":8}"""; + AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + def!.ElementCount.ShouldBe(8); + } + + /// A non-array equipment ref defaults ElementCount to 1 (scalar). + [Fact] + public void Parser_defaults_ElementCount_to_one_when_not_an_array() + { + var json = """{"tagPath":"Recipe","dataType":"DInt"}"""; + AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + def!.ElementCount.ShouldBe(1); + } + + // ---- helpers ---- + + /// Minimal recorder for the discovery assertions. + 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) { } + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs index 2e437a1d..91986a20 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs @@ -88,6 +88,20 @@ internal class FakeAbCipTag : IAbCipTagRuntime return offset == 0 ? Value : null; } + /// + /// Phase 4c array-read seam. Returns (the boxed element-typed + /// CLR array a test seeds) regardless of / , + /// so a test asserts that the driver routes a 1-D array tag through this method instead of + /// the scalar . is the convenient + /// constructor-seeded variant. + /// + /// The element data type being decoded. + /// The number of elements to decode. + public virtual object? DecodeArray(AbCipDataType type, int count) => ArrayValue; + + /// Gets or sets the boxed array value returned from . + public object? ArrayValue { get; set; } + /// Encodes a value into the mock tag storage. /// The data type being encoded. /// The optional bit index for bit operations. @@ -98,6 +112,20 @@ internal class FakeAbCipTag : IAbCipTagRuntime public virtual void Dispose() => Disposed = true; } +/// +/// A pre-seeded with a boxed element-typed CLR array, returned +/// from . Mirrors a real libplctag array read where +/// elem_count elements come back in one transaction. +/// +internal sealed class ArrayFakeAbCipTag : FakeAbCipTag +{ + /// Initializes the fake with a boxed array value. + /// The tag creation parameters. + /// The boxed element-typed CLR array the read returns. + public ArrayFakeAbCipTag(AbCipTagCreateParams createParams, object array) : base(createParams) + => ArrayValue = array; +} + /// Test factory that produces s and indexes them for assertion. internal sealed class FakeAbCipTagFactory : IAbCipTagFactory {