diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index a45421c..209a232 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -27,6 +27,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly string _driverInstanceId; private readonly IAbCipTagFactory _tagFactory; private readonly IAbCipTagEnumeratorFactory _enumeratorFactory; + private readonly IAbCipTemplateReaderFactory _templateReaderFactory; private readonly AbCipTemplateCache _templateCache = new(); private readonly PollGroupEngine _poll; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); @@ -38,19 +39,63 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, public AbCipDriver(AbCipDriverOptions options, string driverInstanceId, IAbCipTagFactory? tagFactory = null, - IAbCipTagEnumeratorFactory? enumeratorFactory = null) + IAbCipTagEnumeratorFactory? enumeratorFactory = null, + IAbCipTemplateReaderFactory? templateReaderFactory = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _tagFactory = tagFactory ?? new LibplctagTagFactory(); _enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory(); + _templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory(); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); } + /// + /// Fetch + cache the shape of a Logix UDT by template instance id. First call reads + /// the Template Object off the controller; subsequent calls for the same + /// (deviceHostAddress, templateInstanceId) return the cached shape without + /// additional network traffic. null on template-not-found / decode failure so + /// callers can fall back to declaration-driven UDT fan-out. + /// + internal async Task FetchUdtShapeAsync( + string deviceHostAddress, uint templateInstanceId, CancellationToken cancellationToken) + { + var cached = _templateCache.TryGet(deviceHostAddress, templateInstanceId); + if (cached is not null) return cached; + + if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null; + + var deviceParams = new AbCipTagCreateParams( + Gateway: device.ParsedAddress.Gateway, + Port: device.ParsedAddress.Port, + CipPath: device.ParsedAddress.CipPath, + LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, + TagName: $"@udt/{templateInstanceId}", + Timeout: _options.Timeout); + + try + { + using var reader = _templateReaderFactory.Create(); + var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false); + var shape = CipTemplateObjectDecoder.Decode(buffer); + if (shape is not null) + _templateCache.Put(deviceHostAddress, templateInstanceId, shape); + return shape; + } + catch (OperationCanceledException) { throw; } + catch + { + // Template read failure — log via the driver's health surface so operators see it, + // but don't propagate since callers should fall back to declaration-driven UDT + // semantics rather than failing the whole discovery run. + return null; + } + } + /// Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics. internal AbCipTemplateCache TemplateCache => _templateCache; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs new file mode 100644 index 0000000..aa37db5 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs @@ -0,0 +1,140 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Decoder for the CIP Template Object (class 0x6C) blob returned by a Read Template +/// service. Produces an describing the UDT's name, total size, +/// + ordered member list with per-member offset + type + array length. +/// +/// +/// Wire format per Rockwell CIP Vol 1 §5A + Logix 5000 CIP Programming Manual +/// 1756-PM019 §"Template Object", cross-checked against libplctag's ab/cip.c +/// handle_read_template_reply: +/// +/// Header (fixed-size, little-endian): +/// +/// u16Member count. +/// u16Struct handle (opaque id). +/// u32Instance size — bytes per structure instance. +/// u32Member-definition total size — not used here. +/// +/// +/// Then member_count member blocks (8 bytes each): +/// +/// u16Member info — type code + flags (same encoding +/// as Symbol Object: bit 15 = struct, lower 12 = CIP type code). +/// u16Array size — 0 for scalar members. +/// u32Struct offset — byte offset from struct start. +/// +/// +/// Then strings: UDT name followed by each member name, each terminated by a +/// semicolon ; followed by a null \0. The UDT name may itself contain the +/// sequence UDTName;0\0 where 0 after the semicolon is an ASCII flag byte. +/// Decoder trims to the first semicolon. +/// +public static class CipTemplateObjectDecoder +{ + private const int HeaderSize = 12; // u16 + u16 + u32 + u32 + private const int MemberBlockSize = 8; // u16 + u16 + u32 + + private const ushort MemberInfoStructFlag = 0x8000; + private const ushort MemberInfoTypeCodeMask = 0x0FFF; + + /// + /// Decode the raw Template Object blob. Returns null when the header indicates + /// zero members or the buffer is too short to hold the fixed header. + /// + public static AbCipUdtShape? Decode(byte[] buffer) + { + ArgumentNullException.ThrowIfNull(buffer); + if (buffer.Length < HeaderSize) return null; + + var memberCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(0)); + // bytes 2-3: struct handle — opaque, not needed for the shape record + var instanceSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(4)); + // bytes 8-11: member-definition total size — inferred from names list instead + + if (memberCount == 0) return null; + + var memberBlocksOffset = HeaderSize; + var namesOffset = memberBlocksOffset + MemberBlockSize * memberCount; + if (namesOffset > buffer.Length) return null; + + var stringsSpan = buffer.AsSpan(namesOffset); + var names = ParseSemicolonTerminatedStrings(stringsSpan); + if (names.Count == 0) return null; + + // Strings layout: UDT name first, then one per member (in the same order as the + // member-info blocks). Always consume the first entry as the UDT name; missing + // trailing member names get placeholders below. + var udtName = names[0]; + var memberNames = names.Skip(1).ToArray(); + + var members = new List(memberCount); + for (var i = 0; i < memberCount; i++) + { + var blockOffset = memberBlocksOffset + (i * MemberBlockSize); + var info = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset)); + var arraySize = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset + 2)); + var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(blockOffset + 4)); + + var isStruct = (info & MemberInfoStructFlag) != 0; + var typeCode = (byte)(info & MemberInfoTypeCodeMask); + var dataType = isStruct + ? AbCipDataType.Structure + : (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure); + + var memberName = i < memberNames.Length ? memberNames[i] : $""; + members.Add(new AbCipUdtMember( + Name: memberName, + Offset: offset, + DataType: dataType, + ArrayLength: arraySize == 0 ? 1 : arraySize)); + } + + return new AbCipUdtShape( + TypeName: udtName, + TotalSize: (int)instanceSize, + Members: members); + } + + /// + /// Walk a span of NAME;\0NAME;\0… byte sequences. Splits at each semicolon — + /// the null byte after each semicolon is optional padding per Rockwell's string + /// encoding convention. Stops at a trailing null / end of buffer. + /// + internal static List ParseSemicolonTerminatedStrings(ReadOnlySpan span) + { + var result = new List(); + var start = 0; + for (var i = 0; i < span.Length; i++) + { + var b = span[i]; + if (b == ';') + { + if (i > start) + result.Add(Encoding.ASCII.GetString(span[start..i])); + // Skip the optional null/space padding following the semicolon. + while (i + 1 < span.Length && (span[i + 1] == '\0' || span[i + 1] == ' ')) + i++; + start = i + 1; + } + else if (b == 0 && start == i) + { + // Trailing null at a string boundary — done. + break; + } + } + // Trailing name without a semicolon (unlikely but observed on some firmwares). + if (start < span.Length) + { + var zeroAt = span[start..].IndexOf((byte)0); + var end = zeroAt < 0 ? span.Length : start + zeroAt; + if (end > start) + result.Add(Encoding.ASCII.GetString(span[start..end])); + } + return result; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTemplateReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTemplateReader.cs new file mode 100644 index 0000000..804dc82 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTemplateReader.cs @@ -0,0 +1,26 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Reads the raw Template Object (class 0x6C) blob for a given UDT template instance id +/// off a Logix controller. The default production implementation (see +/// ) uses libplctag's @udt/{id} pseudo-tag. +/// Tests swap in a fake via . +/// +public interface IAbCipTemplateReader : IDisposable +{ + /// + /// Read the raw template bytes for . Returns the + /// full blob the Read Template service produced — the managed + /// parses it into an . + /// + Task ReadAsync( + AbCipTagCreateParams deviceParams, + uint templateInstanceId, + CancellationToken cancellationToken); +} + +/// Factory for . +public interface IAbCipTemplateReaderFactory +{ + IAbCipTemplateReader Create(); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs new file mode 100644 index 0000000..009660e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs @@ -0,0 +1,49 @@ +using libplctag; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// libplctag-backed . Opens the @udt/{templateId} +/// pseudo-tag libplctag exposes for Template Object reads, issues a Read Template +/// internally via a normal read call, + returns the raw byte buffer so +/// can decode it. +/// +internal sealed class LibplctagTemplateReader : IAbCipTemplateReader +{ + private Tag? _tag; + + public async Task ReadAsync( + AbCipTagCreateParams deviceParams, + uint templateInstanceId, + CancellationToken cancellationToken) + { + _tag?.Dispose(); + _tag = new Tag + { + Gateway = deviceParams.Gateway, + Path = deviceParams.CipPath, + PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute), + Protocol = Protocol.ab_eip, + Name = $"@udt/{templateInstanceId}", + Timeout = deviceParams.Timeout, + }; + await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false); + await _tag.ReadAsync(cancellationToken).ConfigureAwait(false); + return _tag.GetBuffer(); + } + + public void Dispose() => _tag?.Dispose(); + + private static PlcType MapPlcType(string attribute) => attribute switch + { + "controllogix" => PlcType.ControlLogix, + "compactlogix" => PlcType.ControlLogix, + "micro800" => PlcType.Micro800, + _ => PlcType.ControlLogix, + }; +} + +internal sealed class LibplctagTemplateReaderFactory : IAbCipTemplateReaderFactory +{ + public IAbCipTemplateReader Create() => new LibplctagTemplateReader(); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs new file mode 100644 index 0000000..f262a37 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs @@ -0,0 +1,221 @@ +using System.Buffers.Binary; +using System.Reflection; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +[Trait("Category", "Unit")] +public sealed class AbCipFetchUdtShapeTests +{ + private sealed class FakeTemplateReader : IAbCipTemplateReader + { + public byte[] Response { get; set; } = []; + public int ReadCount { get; private set; } + public bool Disposed { get; private set; } + public uint LastTemplateId { get; private set; } + + public Task ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct) + { + ReadCount++; + LastTemplateId = templateInstanceId; + return Task.FromResult(Response); + } + + public void Dispose() => Disposed = true; + } + + private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory + { + public List Readers { get; } = new(); + public Func? Customise { get; set; } + + public IAbCipTemplateReader Create() + { + var r = Customise?.Invoke() ?? new FakeTemplateReader(); + Readers.Add(r); + return r; + } + } + + private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members) + { + var headerSize = 12; + var blockSize = 8; + var strings = new MemoryStream(); + void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); } + Add(name); + foreach (var m in members) Add(m.n); + var stringsArr = strings.ToArray(); + + var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length]; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize); + for (var i = 0; i < members.Length; i++) + { + var o = headerSize + i * blockSize; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off); + } + Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length); + return buf; + } + + private static Task InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId) + { + var mi = typeof(AbCipDriver).GetMethod("FetchUdtShapeAsync", + BindingFlags.NonPublic | BindingFlags.Instance)!; + return (Task)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!; + } + + [Fact] + public async Task FetchUdtShapeAsync_decodes_blob_and_caches_result() + { + var factory = new FakeTemplateReaderFactory + { + Customise = () => new FakeTemplateReader + { + Response = BuildSimpleTemplate("MotorUdt", 8, + ("Speed", 0xC4, 0, 0), + ("Enabled", 0xC1, 0, 4)), + }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1", templateReaderFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42); + + shape.ShouldNotBeNull(); + shape.TypeName.ShouldBe("MotorUdt"); + shape.Members.Count.ShouldBe(2); + + // Second fetch must hit the cache — no second reader created. + _ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42); + factory.Readers.Count.ShouldBe(1); + } + + [Fact] + public async Task FetchUdtShapeAsync_different_templateIds_each_fetch() + { + var callCount = 0; + var factory = new FakeTemplateReaderFactory + { + Customise = () => + { + callCount++; + var name = callCount == 1 ? "UdtA" : "UdtB"; + return new FakeTemplateReader + { + Response = BuildSimpleTemplate(name, 4, ("X", 0xC4, 0, 0)), + }; + }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1", templateReaderFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var a = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1); + var b = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 2); + + a!.TypeName.ShouldBe("UdtA"); + b!.TypeName.ShouldBe("UdtB"); + factory.Readers.Count.ShouldBe(2); + } + + [Fact] + public async Task FetchUdtShapeAsync_unknown_device_returns_null() + { + var factory = new FakeTemplateReaderFactory(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1", templateReaderFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var shape = await InvokeFetch(drv, "ab://10.0.0.99/1,0", 1); + shape.ShouldBeNull(); + factory.Readers.ShouldBeEmpty(); + } + + [Fact] + public async Task FetchUdtShapeAsync_decode_failure_returns_null_and_does_not_cache() + { + var factory = new FakeTemplateReaderFactory + { + Customise = () => new FakeTemplateReader { Response = [0x00, 0x00] }, // too short + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1", templateReaderFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1); + shape.ShouldBeNull(); + + // Next call retries (not cached as a failure). + var shape2 = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1); + shape2.ShouldBeNull(); + factory.Readers.Count.ShouldBe(2); + } + + [Fact] + public async Task FetchUdtShapeAsync_reader_exception_returns_null() + { + var factory = new FakeTemplateReaderFactory + { + Customise = () => new ThrowingTemplateReader(), + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1", templateReaderFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1); + shape.ShouldBeNull(); + } + + [Fact] + public async Task FlushOptionalCachesAsync_empties_template_cache() + { + var factory = new FakeTemplateReaderFactory + { + Customise = () => new FakeTemplateReader + { + Response = BuildSimpleTemplate("U", 4, ("X", 0xC4, 0, 0)), + }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1", templateReaderFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + _ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99); + drv.TemplateCache.Count.ShouldBe(1); + + await drv.FlushOptionalCachesAsync(CancellationToken.None); + drv.TemplateCache.Count.ShouldBe(0); + + // Next fetch hits the network again. + _ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99); + factory.Readers.Count.ShouldBe(2); + } + + private sealed class ThrowingTemplateReader : IAbCipTemplateReader + { + public Task ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) => + throw new InvalidOperationException("fake read failure"); + public void Dispose() { } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipTemplateObjectDecoderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipTemplateObjectDecoderTests.cs new file mode 100644 index 0000000..6cca63a --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipTemplateObjectDecoderTests.cs @@ -0,0 +1,180 @@ +using System.Buffers.Binary; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +[Trait("Category", "Unit")] +public sealed class CipTemplateObjectDecoderTests +{ + /// + /// Construct a Template Object blob — header + member blocks + semicolon-delimited + /// strings (UDT name first, then member names). + /// + private static byte[] BuildTemplate( + string udtName, + uint instanceSize, + params (string name, ushort info, ushort arraySize, uint offset)[] members) + { + var memberCount = (ushort)members.Length; + var headerSize = 12; + var memberBlockSize = 8; + var blocksSize = memberBlockSize * members.Length; + + var stringsBuf = new MemoryStream(); + void AppendString(string s) + { + var bytes = Encoding.ASCII.GetBytes(s + ";\0"); + stringsBuf.Write(bytes, 0, bytes.Length); + } + AppendString(udtName); + foreach (var m in members) AppendString(m.name); + var strings = stringsBuf.ToArray(); + + var buf = new byte[headerSize + blocksSize + strings.Length]; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), 0); + + for (var i = 0; i < members.Length; i++) + { + var o = headerSize + (i * memberBlockSize); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arraySize); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].offset); + } + Buffer.BlockCopy(strings, 0, buf, headerSize + blocksSize, strings.Length); + return buf; + } + + [Fact] + public void Simple_two_member_UDT_decodes_correctly() + { + var bytes = BuildTemplate("MotorUdt", instanceSize: 8, + ("Speed", info: 0xC4, arraySize: 0, offset: 0), // DINT at offset 0 + ("Enabled", info: 0xC1, arraySize: 0, offset: 4)); // BOOL at offset 4 + + var shape = CipTemplateObjectDecoder.Decode(bytes); + + shape.ShouldNotBeNull(); + shape.TypeName.ShouldBe("MotorUdt"); + shape.TotalSize.ShouldBe(8); + shape.Members.Count.ShouldBe(2); + shape.Members[0].Name.ShouldBe("Speed"); + shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt); + shape.Members[0].Offset.ShouldBe(0); + shape.Members[0].ArrayLength.ShouldBe(1); + shape.Members[1].Name.ShouldBe("Enabled"); + shape.Members[1].DataType.ShouldBe(AbCipDataType.Bool); + shape.Members[1].Offset.ShouldBe(4); + } + + [Fact] + public void Struct_member_flag_surfaces_Structure_type() + { + var bytes = BuildTemplate("ContainerUdt", instanceSize: 32, + ("InnerStruct", info: 0x8042, arraySize: 0, offset: 0)); // struct flag + template-id 0x42 + + var shape = CipTemplateObjectDecoder.Decode(bytes); + + shape.ShouldNotBeNull(); + shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure); + } + + [Fact] + public void Array_member_carries_non_one_ArrayLength() + { + var bytes = BuildTemplate("ArrayUdt", instanceSize: 40, + ("Values", info: 0xC4, arraySize: 10, offset: 0)); + + var shape = CipTemplateObjectDecoder.Decode(bytes); + shape.ShouldNotBeNull(); + shape.Members.Single().ArrayLength.ShouldBe(10); + } + + [Fact] + public void Multiple_atomic_types_preserve_offsets_and_types() + { + var bytes = BuildTemplate("MixedUdt", instanceSize: 24, + ("A", 0xC1, 0, 0), // BOOL + ("B", 0xC2, 0, 1), // SINT + ("C", 0xC3, 0, 2), // INT + ("D", 0xC4, 0, 4), // DINT + ("E", 0xCA, 0, 8), // REAL + ("F", 0xCB, 0, 16)); // LREAL + + var shape = CipTemplateObjectDecoder.Decode(bytes); + + shape.ShouldNotBeNull(); + shape.Members.Count.ShouldBe(6); + shape.Members.Select(m => m.DataType).ShouldBe( + [AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int, + AbCipDataType.DInt, AbCipDataType.Real, AbCipDataType.LReal]); + shape.Members.Select(m => m.Offset).ShouldBe([0, 1, 2, 4, 8, 16]); + } + + [Fact] + public void Unknown_atomic_type_code_falls_back_to_Structure() + { + var bytes = BuildTemplate("WeirdUdt", instanceSize: 4, + ("Unknown", info: 0xFF, 0, 0)); + + var shape = CipTemplateObjectDecoder.Decode(bytes); + shape.ShouldNotBeNull(); + shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure); + } + + [Fact] + public void Zero_member_count_returns_null() + { + var buf = new byte[12]; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), 0); + CipTemplateObjectDecoder.Decode(buf).ShouldBeNull(); + } + + [Fact] + public void Short_buffer_returns_null() + { + CipTemplateObjectDecoder.Decode([0x01, 0x00]).ShouldBeNull(); // only 2 bytes — less than header + } + + [Fact] + public void Missing_member_name_surfaces_placeholder() + { + // Header says 3 members but strings list has only UDT name + 2 member names. + var memberCount = (ushort)3; + var buf = new byte[12 + 8 * 3]; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), 12); + for (var i = 0; i < 3; i++) + { + var o = 12 + i * 8; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), 0xC4); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), (uint)(i * 4)); + } + // strings: only UDT + 2 members, missing the third. + var strings = Encoding.ASCII.GetBytes("MyUdt;\0A;\0B;\0"); + var combined = buf.Concat(strings).ToArray(); + + var shape = CipTemplateObjectDecoder.Decode(combined); + shape.ShouldNotBeNull(); + shape.Members.Count.ShouldBe(3); + shape.Members[2].Name.ShouldBe(""); + } + + [Theory] + [InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })] + [InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls + [InlineData("Only;\0", new[] { "Only" })] + [InlineData(";\0", new string[] { })] // empty + [InlineData("", new string[] { })] + public void ParseSemicolonTerminatedStrings_handles_shapes(string input, string[] expected) + { + var bytes = Encoding.ASCII.GetBytes(input); + var result = CipTemplateObjectDecoder.ParseSemicolonTerminatedStrings(bytes); + result.ShouldBe(expected); + } +}