diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs index d93a7fa6..34cb4294 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs @@ -41,13 +41,31 @@ public sealed record AbLegacyDeviceOptions( /// One PCCC-backed OPC UA variable. is the canonical PCCC /// file-address string that parses via AbLegacyAddress.TryParse. /// +/// +/// Element count when the tag addresses a multi-element span of a PCCC data file (e.g. an +/// N7 integer file is inherently up to 256 words); for a scalar. +/// A PCCC data file holds at most (256) elements, so a +/// value above that is clamped where it is materialised/read. 1 reads as a scalar. +/// public sealed record AbLegacyTagDefinition( string Name, string DeviceHostAddress, string Address, AbLegacyDataType DataType, bool Writable = true, - bool WriteIdempotent = false); + bool WriteIdempotent = false, + int? ArrayLength = null); + +/// PCCC array-tag constants shared by the parser, discovery, and read paths. +public static class AbLegacyArray +{ + /// + /// Maximum element count for a single PCCC data file. The PCCC/DF1 protocol addresses a + /// data file element with a single byte sub-element offset, so a file holds at most 256 + /// elements (words for N/B/I/O/S/A, 32-bit elements for L/F). Array tags clamp to this. + /// + public const int MaxElements = 256; +} public sealed class AbLegacyProbeOptions { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs index 3f4f96e6..ad9beea0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs @@ -29,9 +29,18 @@ public static class AbLegacyEquipmentTagParser if (string.IsNullOrWhiteSpace(address)) return false; var dataType = ReadEnum(root, "dataType", AbLegacyDataType.Int); var deviceHostAddress = ReadString(root, "deviceHostAddress"); + // Phase 4c #137 — thread the equipment tag's array element count. arrayLength is the + // authoritative count; isArray is the AdminUI's boolean toggle. A positive arrayLength + // (regardless of isArray) makes this an array tag. Clamp to the PCCC file maximum + // (AbLegacyArray.MaxElements = 256) so a fat-fingered count can never request a span + // larger than a single data file holds. Absent / non-positive → null (scalar). + int? arrayLength = null; + var rawLength = ReadInt(root, "arrayLength"); + if (rawLength > 0) + arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements); def = new AbLegacyTagDefinition( Name: reference, DeviceHostAddress: deviceHostAddress, Address: address, - DataType: dataType, Writable: true); + DataType: dataType, Writable: true, ArrayLength: arrayLength); return true; } catch (JsonException) { return false; } @@ -46,4 +55,8 @@ public static class AbLegacyEquipmentTagParser private static string ReadString(JsonElement o, string name) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() ?? "" : ""; + + private static int ReadInt(JsonElement o, string name) + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number + && e.TryGetInt32(out var v) ? v : 0; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 5b6a311c..d6fce5db 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -267,7 +267,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover await runtime.ReadAsync(cancellationToken).ConfigureAwait(false); status = runtime.GetStatus(); var parsed = AbLegacyAddress.TryParse(def.Address); - value = status == 0 ? runtime.DecodeValue(def.DataType, parsed?.BitIndex) : null; + // Phase 4c #137 — when the tag addresses a multi-element span (ArrayLength > 1) + // decode the whole contiguous read into a typed CLR array; otherwise decode a + // single scalar value as before. The runtime was created with a matching + // ElementCount in EnsureTagRuntimeAsync so its buffer holds all the elements. + var arrayLen = EffectiveArrayLength(def); + if (status != 0) + value = null; + else if (arrayLen > 1) + value = runtime.DecodeArray(def.DataType, arrayLen); + else + value = runtime.DecodeValue(def.DataType, parsed?.BitIndex); } finally { @@ -428,19 +438,20 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); foreach (var tag in tagsForDevice) { - // Driver.AbLegacy-013 (tracked follow-up) — PCCC files are inherently arrays of - // elements (a single N7 file is up to 256 words), but the current tag-definition - // surface only addresses one element. IsArray/ArrayDim are hard-wired false/null - // until multi-element addressing lands; tags that genuinely span a range have to - // be enumerated one element at a time today. This is consistent with the - // PR-staged scope documented in docs/v2/driver-specs.md (AbLegacy ships with thin - // array coverage); when array support is added, ArrayCount on the tag definition - // will flow through here as it already does on the Modbus driver. + // Phase 4c #137 — PCCC data files are inherently arrays of elements (a single N7 + // file is up to 256 words). A tag whose ArrayLength addresses a multi-element span + // now materialises a 1-D array OPC UA node. ArrayDim is clamped to the PCCC file + // maximum (AbLegacyArray.MaxElements = 256) so the declared dimension can never + // exceed what a single data file holds; an ArrayLength of 1 (or null) stays scalar. + var isArray = tag.ArrayLength is int len && len > 1; + var arrayDim = isArray + ? (uint)Math.Min(tag.ArrayLength!.Value, AbLegacyArray.MaxElements) + : (uint?)null; deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( FullName: tag.Name, DriverDataType: tag.DataType.ToDriverDataType(), - IsArray: false, - ArrayDim: null, + IsArray: isArray, + ArrayDim: arrayDim, SecurityClass: tag.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly, @@ -674,6 +685,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover } } + /// + /// Phase 4c #137 — the effective libplctag element count for a tag definition: the tag's + /// clamped to the PCCC file maximum + /// ( = 256), or 1 when the tag is scalar + /// (null or non-positive ArrayLength). Used both to size the runtime at create time and to + /// decide whether the read path decodes a scalar or an array. + /// + private static int EffectiveArrayLength(AbLegacyTagDefinition def) => + def.ArrayLength is int len && len > 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1; + private async Task EnsureTagRuntimeAsync( DeviceState device, AbLegacyTagDefinition def, CancellationToken ct) { @@ -698,7 +719,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover CipPath: device.EffectiveCipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parsed.ToLibplctagName(), - Timeout: _options.Timeout)); + Timeout: _options.Timeout, + // Phase 4c #137 — multi-element PCCC file read. A multi-element span (ArrayLength + // > 1) creates the libplctag tag with that element count so a single read fetches + // the whole array from the base address; scalar tags pass 1 and read unchanged. + ElementCount: EffectiveArrayLength(def))); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs index 7f1921fa..0dadf95b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs @@ -27,6 +27,18 @@ public interface IAbLegacyTagRuntime : IDisposable /// Optional bit index for bit-level access. object? DecodeValue(AbLegacyDataType type, int? bitIndex); + /// + /// Decodes consecutive elements of a multi-element PCCC file + /// read as a typed CLR array (short[] for Int/AnalogInt, int[] for Long, + /// float[] for Float, bool[] for Bit), boxed as . The + /// runtime must have been created with a matching element count (see + /// ) so the underlying buffer holds all + /// elements. + /// + /// The PCCC element data type. + /// The number of elements to decode. + object? DecodeArray(AbLegacyDataType type, int count); + /// Encodes a value for writing to the tag. /// The data type to encode. /// Optional bit index for bit-level access. @@ -41,10 +53,23 @@ public interface IAbLegacyTagFactory IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams); } +/// The PLC gateway host (IP or DNS). +/// The EtherNet/IP TCP port. +/// The CIP routing path to the PLC (e.g. 1,0). +/// The libplctag plc attribute (e.g. slc500). +/// The PCCC file address passed to libplctag's name attribute. +/// The read/write operation timeout. +/// +/// libplctag element count for the tag. 1 (the default) reads a single PCCC file +/// element (scalar). A value > 1 reads that many consecutive elements from the base +/// address (e.g. N7:0 with an element count of 5 reads N7:0..N7:4), +/// which the runtime surfaces via . +/// public sealed record AbLegacyTagCreateParams( string Gateway, int Port, string CipPath, string LibplctagPlcAttribute, string TagName, - TimeSpan Timeout); + TimeSpan Timeout, + int ElementCount = 1); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs index 7caf3c50..548013f7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -24,6 +24,14 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer Name = p.TagName, Timeout = p.Timeout, + // Phase 4c #137 — multi-element PCCC file read. ElementCount tells libplctag how many + // consecutive elements to fetch from the base address (e.g. N7:0 with ElementCount=5 + // reads N7:0..N7:4 in one PCCC transaction). ElementCount=1 (the scalar default) leaves + // libplctag's own per-name element inference untouched, so scalar tags read exactly as + // before. ASSUMPTION: libplctag's ab_pccc layer honours elem_count for SLC/PLC-5 data + // files and lays the elements out contiguously in the tag buffer (verified against the + // libplctag.NET API surface; not live-proven — no PCCC fixture on this build host). + ElementCount = p.ElementCount > 1 ? p.ElementCount : null, }; } @@ -58,6 +66,51 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime _ => null, }; + /// + public object? DecodeArray(AbLegacyDataType type, int count) + { + // Each element is read from its byte offset within the contiguous tag buffer. PCCC word + // files (N/A → Int) are 2 bytes/element; L (Long) and F (Float) are 4 bytes/element. Bit + // (B-file) arrays read individual bits by index — libplctag's ab_pccc layer exposes the + // file's bits via GetBit(bitOffset). These element sizes are the canonical PCCC element + // widths; ASSUMPTION (not live-proven): libplctag packs multi-element reads contiguously + // with no per-element padding, matching the AbCip sibling's offset-decode pattern. + switch (type) + { + case AbLegacyDataType.Int: + case AbLegacyDataType.AnalogInt: + { + var arr = new short[count]; + for (var i = 0; i < count; i++) arr[i] = _tag.GetInt16(i * 2); + return arr; + } + case AbLegacyDataType.Long: + { + var arr = new int[count]; + for (var i = 0; i < count; i++) arr[i] = _tag.GetInt32(i * 4); + return arr; + } + case AbLegacyDataType.Float: + { + var arr = new float[count]; + for (var i = 0; i < count; i++) arr[i] = _tag.GetFloat32(i * 4); + return arr; + } + case AbLegacyDataType.Bit: + { + var arr = new bool[count]; + for (var i = 0; i < count; i++) arr[i] = _tag.GetBit(i); + return arr; + } + default: + // String / Timer / Counter / Control element arrays are not supported — these + // are structured or variable-width PCCC element types that don't lay out as a + // flat scalar array. The driver rejects them before reaching here. + throw new NotSupportedException( + $"AbLegacyDataType {type} is not supported as an array element."); + } + } + /// public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs new file mode 100644 index 00000000..f41908f8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs @@ -0,0 +1,286 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Phase 4c #137 array support for the AbLegacy (PCCC/DF1) driver. A PCCC data file +/// (e.g. N7) is inherently an array of up to 256 words; an equipment tag carrying an +/// arrayLength reads count consecutive file elements from the base address +/// (N7:0 reading count words) via libplctag's native element-count and surfaces +/// them as a typed CLR array. Proven here against ; there is no +/// live AbLegacy fixture on this machine. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyArrayTests +{ + private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(params AbLegacyTagDefinition[] tags) + { + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = tags, + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "ablegacy-array", factory); + return (drv, factory); + } + + // ---- Read: typed CLR arrays ---- + + /// An N-file (16-bit integer) array tag reads a short[]. + [Fact] + public async Task Read_N_file_array_returns_short_array() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 100, 101, 102, 103, 104 } }; + + var snapshots = await drv.ReadAsync(["Levels"], CancellationToken.None); + + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var arr = snapshots.Single().Value.ShouldBeOfType(); + arr.ShouldBe(new short[] { 100, 101, 102, 103, 104 }); + } + + /// An L-file (32-bit long) array tag reads an int[]. + [Fact] + public async Task Read_L_file_array_returns_int_array() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("Counts", "ab://10.0.0.5/1,0", "L9:0", AbLegacyDataType.Long, ArrayLength: 3)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 70000, 80000, 90000 } }; + + var snapshots = await drv.ReadAsync(["Counts"], CancellationToken.None); + + var arr = snapshots.Single().Value.ShouldBeOfType(); + arr.ShouldBe(new[] { 70000, 80000, 90000 }); + } + + /// An F-file (32-bit float) array tag reads a float[]. + [Fact] + public async Task Read_F_file_array_returns_float_array() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("Temps", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, ArrayLength: 4)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 2.5f, 3.5f, 4.5f } }; + + var snapshots = await drv.ReadAsync(["Temps"], CancellationToken.None); + + var arr = snapshots.Single().Value.ShouldBeOfType(); + arr.ShouldBe(new[] { 1.5f, 2.5f, 3.5f, 4.5f }); + } + + /// A B-file (bit) array tag reads a bool[]. + [Fact] + public async Task Read_B_file_array_returns_bool_array() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("Flags", "ab://10.0.0.5/1,0", "B3:0", AbLegacyDataType.Bit, ArrayLength: 6)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { true, false, true, false, true, false } }; + + var snapshots = await drv.ReadAsync(["Flags"], CancellationToken.None); + + var arr = snapshots.Single().Value.ShouldBeOfType(); + arr.ShouldBe(new[] { true, false, true, false, true, false }); + } + + /// The element count flows into the create params so libplctag reads N words. + [Fact] + public async Task Array_tag_threads_element_count_into_create_params() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 1, 2, 3, 4, 5 } }; + + await drv.ReadAsync(["Levels"], CancellationToken.None); + + factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(5); + } + + /// A read failure on an array tag surfaces the mapped Bad status, not a partial array. + [Fact] + public async Task Array_read_failure_surfaces_bad_status() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Status = (int)libplctag.Status.ErrorTimeout }; + + var snapshots = await drv.ReadAsync(["Levels"], CancellationToken.None); + + snapshots.Single().Value.ShouldBeNull(); + snapshots.Single().StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good); + } + + // ---- Scalar regression ---- + + /// A scalar tag (no ArrayLength) still reads a single value, not an array. + [Fact] + public async Task Scalar_tag_still_reads_single_value() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 }; + + var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None); + + snapshots.Single().Value.ShouldBe(42); + snapshots.Single().Value.ShouldNotBeOfType(); + factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1); + } + + /// An ArrayLength of 1 behaves like a scalar (single value, ElementCount 1). + [Fact] + public async Task ArrayLength_of_one_behaves_like_scalar() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 1)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 7 }; + + var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None); + + snapshots.Single().Value.ShouldBe(7); + factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1); + } + + // ---- Discovery ---- + + /// Discovery flips IsArray and surfaces ArrayDim for an array file tag. + [Fact] + public async Task Discovery_surfaces_IsArray_and_ArrayDim_for_array_tag() + { + var captured = new List(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition("Vector", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 8)], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "ablegacy-array", new FakeAbLegacyTagFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None); + + captured.Count.ShouldBe(1); + captured[0].IsArray.ShouldBeTrue(); + captured[0].ArrayDim.ShouldBe(8u); + } + + /// Scalar tag discovery keeps IsArray false / ArrayDim null (regression guard). + [Fact] + public async Task Discovery_keeps_scalar_tag_non_array() + { + var captured = new List(); + var (drv, _) = NewDriver( + new AbLegacyTagDefinition("Single", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None); + + captured[0].IsArray.ShouldBeFalse(); + captured[0].ArrayDim.ShouldBeNull(); + } + + /// Discovery caps a 1024-element tag's ArrayDim at the PCCC 256-word file maximum. + [Fact] + public async Task Discovery_caps_array_dim_at_256() + { + var captured = new List(); + var (drv, _) = NewDriver( + new AbLegacyTagDefinition("Big", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 1024)); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None); + + captured[0].IsArray.ShouldBeTrue(); + captured[0].ArrayDim.ShouldBe(256u); + } + + // ---- Equipment-tag resolver threads arrayLength ---- + + /// The equipment-tag parser threads arrayLength into the transient definition. + [Fact] + public void EquipmentTagParser_threads_arrayLength() + { + var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","isArray":true,"arrayLength":10}"""; + AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + def!.ArrayLength.ShouldBe(10); + } + + /// The parser caps arrayLength at the PCCC 256-word file maximum. + [Fact] + public void EquipmentTagParser_caps_arrayLength_at_256() + { + var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","arrayLength":99999}"""; + AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + def!.ArrayLength.ShouldBe(256); + } + + /// A scalar equipment tag (no arrayLength) leaves ArrayLength null. + [Fact] + public void EquipmentTagParser_scalar_leaves_arrayLength_null() + { + var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int"}"""; + AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + def!.ArrayLength.ShouldBeNull(); + } + + /// + /// End-to-end: an AbLegacy driver with NO authored tags reads an equipment-tag ref whose + /// TagConfig carries arrayLength — the resolver threads the count and the read + /// surfaces a typed array, capping the libplctag element count at 256. + /// + [Fact] + public async Task Driver_reads_equipment_array_ref_as_typed_array() + { + var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","arrayLength":3}"""; + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 11, 22, 33 } } }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "ablegacy-eq-array", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync([json], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var arr = r[0].Value.ShouldBeOfType(); + arr.ShouldBe(new short[] { 11, 22, 33 }); + factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(3); + } + + private sealed class RecordingBuilder(List captured) : IAddressSpaceBuilder + { + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) + { + captured.Add(info); + return new Handle(info.FullName); + } + + public void AddProperty(string name, DriverDataType dataType, object? value) { } + + 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.AbLegacy.Tests/FakeAbLegacyTag.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs index 42528da7..68dfb4b6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs @@ -10,6 +10,13 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime /// Gets or sets the tag value. public object? Value { get; set; } + /// + /// Gets or sets the typed CLR array returned by . Set this to a + /// short[] / int[] / float[] / bool[] to simulate a libplctag + /// multi-element PCCC file read; the driver boxes it straight through. + /// + public object? ArrayValue { get; set; } + /// Gets or sets the tag status code. public int Status { get; set; } @@ -81,6 +88,12 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime /// The decoded value. public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value; + /// Decodes elements as a typed CLR array. + /// The AbLegacy data type. + /// The element count. + /// The pre-set . + public virtual object? DecodeArray(AbLegacyDataType type, int count) => ArrayValue; + /// Encodes the tag value based on the specified data type and bit index. /// The AbLegacy data type. /// The bit index if applicable.