From ece530d133a4202b9fc9cfef37462b0bc2154e92 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 21:21:42 -0400 Subject: [PATCH] =?UTF-8?q?AB=20CIP=20UDT=20Template=20Object=20shape=20re?= =?UTF-8?q?ader.=20Closes=20the=20shape-reader=20half=20of=20task=20#179.?= =?UTF-8?q?=20CipTemplateObjectDecoder=20(pure-managed)=20parses=20the=20R?= =?UTF-8?q?ead=20Template=20blob=20per=20Rockwell=20CIP=20Vol=201=20+=20li?= =?UTF-8?q?bplctag=20ab/cip.c=20handle=5Fread=5Ftemplate=5Freply=20?= =?UTF-8?q?=E2=80=94=2012-byte=20header=20(u16=20member=5Fcount=20+=20u16?= =?UTF-8?q?=20struct=5Fhandle=20+=20u32=20instance=5Fsize=20+=20u32=20memb?= =?UTF-8?q?er=5Fdef=5Fsize)=20followed=20by=20memberCount=20=C3=97=208-byt?= =?UTF-8?q?e=20member=20blocks=20(u16=20info=20with=20bit-15=20struct=20fl?= =?UTF-8?q?ag=20+=20lower-12-bit=20type=20code=20matching=20the=20Symbol?= =?UTF-8?q?=20Object=20encoding,=20u16=20array=5Fsize,=20u32=20struct=5Fof?= =?UTF-8?q?fset)=20followed=20by=20semicolon-terminated=20strings=20(UDT?= =?UTF-8?q?=20name=20first,=20then=20one=20per=20member).=20ParseSemicolon?= =?UTF-8?q?TerminatedStrings=20handles=20the=20observed=20firmware=20varia?= =?UTF-8?q?tions=20=E2=80=94=20name;\0=20vs=20name;=20delimiters,=20option?= =?UTF-8?q?al=20null/space=20padding=20after=20the=20semicolon,=20trailing?= =?UTF-8?q?-name-without-semicolon=20corner=20case.=20Struct-flag=20member?= =?UTF-8?q?s=20decode=20as=20AbCipDataType.Structure;=20unknown=20atomic?= =?UTF-8?q?=20codes=20fall=20back=20to=20Structure=20so=20the=20shape=20re?= =?UTF-8?q?mains=20valid=20even=20with=20unrecognised=20members.=20Zero=20?= =?UTF-8?q?member=20count=20+=20short=20buffer=20both=20return=20null;=20m?= =?UTF-8?q?issing=20member=20names=20yield=20=20placeholders.?= =?UTF-8?q?=20IAbCipTemplateReader=20+=20IAbCipTemplateReaderFactory=20abs?= =?UTF-8?q?traction=20=E2=80=94=20one=20call=20per=20template=20instance?= =?UTF-8?q?=20id=20returning=20the=20raw=20blob.=20LibplctagTemplateReader?= =?UTF-8?q?=20is=20the=20production=20implementation=20creating=20a=20libp?= =?UTF-8?q?lctag=20Tag=20with=20name=20@udt/{templateId}=20+=20handing=20t?= =?UTF-8?q?he=20buffer=20to=20the=20decoder.=20AbCipDriver=20ctor=20gains?= =?UTF-8?q?=20optional=20templateReaderFactory=20parameter=20(defaults=20t?= =?UTF-8?q?o=20LibplctagTemplateReaderFactory)=20+=20new=20internal=20Fetc?= =?UTF-8?q?hUdtShapeAsync=20that=20=E2=80=94=20checks=20AbCipTemplateCache?= =?UTF-8?q?=20first,=20misses=20call=20the=20reader=20+=20decode=20+=20cac?= =?UTF-8?q?he,=20template-read=20exceptions=20+=20decode=20failures=20retu?= =?UTF-8?q?rn=20null=20so=20callers=20can=20fall=20back=20to=20declaration?= =?UTF-8?q?-driven=20fan-out=20without=20the=20whole=20discovery=20blowing?= =?UTF-8?q?=20up.=20OperationCanceledException=20rethrows=20for=20shutdown?= =?UTF-8?q?=20propagation.=20Unknown=20device=20host=20returns=20null=20wi?= =?UTF-8?q?thout=20attempting=20a=20fetch.=20FlushOptionalCachesAsync=20em?= =?UTF-8?q?pties=20the=20cache=20so=20a=20subsequent=20fetch=20re-reads.?= =?UTF-8?q?=2016=20new=20decoder=20tests=20=E2=80=94=20simple=20two-member?= =?UTF-8?q?=20UDT,=20struct-member=20flag=20=E2=86=92=20Structure,=20array?= =?UTF-8?q?=20member=20ArrayLength,=206-member=20mixed-type=20with=20corre?= =?UTF-8?q?ct=20offsets,=20unknown=20type=20code=20=E2=86=92=20Structure,?= =?UTF-8?q?=20zero=20member=20count=20=E2=86=92=20null,=20short=20buffer?= =?UTF-8?q?=20=E2=86=92=20null,=20missing=20member=20name=20=E2=86=92=20pl?= =?UTF-8?q?aceholder,=20ParseSemicolonTerminatedStrings=20theory=20across?= =?UTF-8?q?=205=20shapes.=206=20new=20AbCipFetchUdtShapeTests=20exercising?= =?UTF-8?q?=20the=20driver=20integration=20via=20reflection=20(method=20is?= =?UTF-8?q?=20internal)=20=E2=80=94=20happy-path=20decode=20+=20cache,=20d?= =?UTF-8?q?ifferent=20template=20ids=20get=20separate=20fetches,=20unknown?= =?UTF-8?q?=20device=20=E2=86=92=20null=20without=20reader=20creation,=20d?= =?UTF-8?q?ecode=20failure=20returns=20null=20+=20doesn't=20cache=20(next?= =?UTF-8?q?=20call=20retries),=20reader=20exception=20returns=20null,=20Fl?= =?UTF-8?q?ushOptionalCachesAsync=20clears=20the=20cache.=20Total=20AbCip?= =?UTF-8?q?=20unit=20tests=20now=20211/211=20passing=20(+19=20from=20the?= =?UTF-8?q?=20@tags=20merge's=20192);=20full=20solution=20builds=200=20err?= =?UTF-8?q?ors;=20other=20drivers=20untouched.=20Whole-UDT=20read=20optimi?= =?UTF-8?q?zation=20(single=20libplctag=20call=20returning=20the=20packed?= =?UTF-8?q?=20buffer=20+=20client-side=20member=20decode=20using=20the=20t?= =?UTF-8?q?emplate=20offsets)=20is=20left=20as=20a=20follow-up=20=E2=80=94?= =?UTF-8?q?=20requires=20rethinking=20the=20per-tag=20read=20path=20+=20ca?= =?UTF-8?q?reful=20hardware=20validation;=20current=20per-member=20fan-out?= =?UTF-8?q?=20still=20works=20correctly,=20just=20with=20N=20round-trips?= =?UTF-8?q?=20instead=20of=201.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AbCipDriver.cs | 47 +++- .../CipTemplateObjectDecoder.cs | 140 +++++++++++ .../IAbCipTemplateReader.cs | 26 +++ .../LibplctagTemplateReader.cs | 49 ++++ .../AbCipFetchUdtShapeTests.cs | 221 ++++++++++++++++++ .../CipTemplateObjectDecoderTests.cs | 180 ++++++++++++++ 6 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTemplateReader.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipTemplateObjectDecoderTests.cs 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); + } +}