AB CIP UDT Template Object shape reader. Closes the shape-reader half of task #179. CipTemplateObjectDecoder (pure-managed) parses the Read Template blob per Rockwell CIP Vol 1 + libplctag ab/cip.c handle_read_template_reply — 12-byte header (u16 member_count + u16 struct_handle + u32 instance_size + u32 member_def_size) followed by memberCount × 8-byte member blocks (u16 info with bit-15 struct flag + lower-12-bit type code matching the Symbol Object encoding, u16 array_size, u32 struct_offset) followed by semicolon-terminated strings (UDT name first, then one per member). ParseSemicolonTerminatedStrings handles the observed firmware variations — name;\0 vs name; delimiters, optional null/space padding after the semicolon, trailing-name-without-semicolon corner case. Struct-flag members decode as AbCipDataType.Structure; unknown atomic codes fall back to Structure so the shape remains valid even with unrecognised members. Zero member count + short buffer both return null; missing member names yield <member_N> placeholders. IAbCipTemplateReader + IAbCipTemplateReaderFactory abstraction — one call per template instance id returning the raw blob. LibplctagTemplateReader is the production implementation creating a libplctag Tag with name @udt/{templateId} + handing the buffer to the decoder. AbCipDriver ctor gains optional templateReaderFactory parameter (defaults to LibplctagTemplateReaderFactory) + new internal FetchUdtShapeAsync that — checks AbCipTemplateCache first, misses call the reader + decode + cache, template-read exceptions + decode failures return null so callers can fall back to declaration-driven fan-out without the whole discovery blowing up. OperationCanceledException rethrows for shutdown propagation. Unknown device host returns null without attempting a fetch. FlushOptionalCachesAsync empties the cache so a subsequent fetch re-reads. 16 new decoder tests — simple two-member UDT, struct-member flag → Structure, array member ArrayLength, 6-member mixed-type with correct offsets, unknown type code → Structure, zero member count → null, short buffer → null, missing member name → placeholder, ParseSemicolonTerminatedStrings theory across 5 shapes. 6 new AbCipFetchUdtShapeTests exercising the driver integration via reflection (method is internal) — happy-path decode + cache, different template ids get separate fetches, unknown device → null without reader creation, decode failure returns null + doesn't cache (next call retries), reader exception returns null, FlushOptionalCachesAsync clears the cache. Total AbCip unit tests now 211/211 passing (+19 from the @tags merge's 192); full solution builds 0 errors; other drivers untouched. Whole-UDT read optimization (single libplctag call returning the packed buffer + client-side member decode using the template offsets) is left as a follow-up — requires rethinking the per-tag read path + careful hardware validation; current per-member fan-out still works correctly, just with N round-trips instead of 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, DeviceState> _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)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>(deviceHostAddress, templateInstanceId)</c> return the cached shape without
|
||||
/// additional network traffic. <c>null</c> on template-not-found / decode failure so
|
||||
/// callers can fall back to declaration-driven UDT fan-out.
|
||||
/// </summary>
|
||||
internal async Task<AbCipUdtShape?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics.</summary>
|
||||
internal AbCipTemplateCache TemplateCache => _templateCache;
|
||||
|
||||
|
||||
140
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs
Normal file
140
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Decoder for the CIP Template Object (class 0x6C) blob returned by a <c>Read Template</c>
|
||||
/// service. Produces an <see cref="AbCipUdtShape"/> describing the UDT's name, total size,
|
||||
/// + ordered member list with per-member offset + type + array length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Wire format per Rockwell CIP Vol 1 §5A + Logix 5000 CIP Programming Manual
|
||||
/// 1756-PM019 §"Template Object", cross-checked against libplctag's <c>ab/cip.c</c>
|
||||
/// <c>handle_read_template_reply</c>:</para>
|
||||
///
|
||||
/// <para>Header (fixed-size, little-endian):</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>u16</term><description>Member count.</description></item>
|
||||
/// <item><term>u16</term><description>Struct handle (opaque id).</description></item>
|
||||
/// <item><term>u32</term><description>Instance size — bytes per structure instance.</description></item>
|
||||
/// <item><term>u32</term><description>Member-definition total size — not used here.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Then <c>member_count</c> member blocks (8 bytes each):</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>u16</term><description>Member info — type code + flags (same encoding
|
||||
/// as Symbol Object: bit 15 = struct, lower 12 = CIP type code).</description></item>
|
||||
/// <item><term>u16</term><description>Array size — 0 for scalar members.</description></item>
|
||||
/// <item><term>u32</term><description>Struct offset — byte offset from struct start.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Then strings: UDT name followed by each member name, each terminated by a
|
||||
/// semicolon <c>;</c> followed by a null <c>\0</c>. The UDT name may itself contain the
|
||||
/// sequence <c>UDTName;0\0</c> where <c>0</c> after the semicolon is an ASCII flag byte.
|
||||
/// Decoder trims to the first semicolon.</para>
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Decode the raw Template Object blob. Returns <c>null</c> when the header indicates
|
||||
/// zero members or the buffer is too short to hold the fixed header.
|
||||
/// </summary>
|
||||
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 <member_N> placeholders below.
|
||||
var udtName = names[0];
|
||||
var memberNames = names.Skip(1).ToArray();
|
||||
|
||||
var members = new List<AbCipUdtMember>(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] : $"<member_{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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk a span of <c>NAME;\0NAME;\0…</c> 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.
|
||||
/// </summary>
|
||||
internal static List<string> ParseSemicolonTerminatedStrings(ReadOnlySpan<byte> span)
|
||||
{
|
||||
var result = new List<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
26
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTemplateReader.cs
Normal file
26
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTemplateReader.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the raw Template Object (class 0x6C) blob for a given UDT template instance id
|
||||
/// off a Logix controller. The default production implementation (see
|
||||
/// <see cref="LibplctagTemplateReader"/>) uses libplctag's <c>@udt/{id}</c> pseudo-tag.
|
||||
/// Tests swap in a fake via <see cref="IAbCipTemplateReaderFactory"/>.
|
||||
/// </summary>
|
||||
public interface IAbCipTemplateReader : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Read the raw template bytes for <paramref name="templateInstanceId"/>. Returns the
|
||||
/// full blob the Read Template service produced — the managed <see cref="CipTemplateObjectDecoder"/>
|
||||
/// parses it into an <see cref="AbCipUdtShape"/>.
|
||||
/// </summary>
|
||||
Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
uint templateInstanceId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="IAbCipTemplateReader"/>.</summary>
|
||||
public interface IAbCipTemplateReaderFactory
|
||||
{
|
||||
IAbCipTemplateReader Create();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// libplctag-backed <see cref="IAbCipTemplateReader"/>. Opens the <c>@udt/{templateId}</c>
|
||||
/// pseudo-tag libplctag exposes for Template Object reads, issues a <c>Read Template</c>
|
||||
/// internally via a normal read call, + returns the raw byte buffer so
|
||||
/// <see cref="CipTemplateObjectDecoder"/> can decode it.
|
||||
/// </summary>
|
||||
internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
private Tag? _tag;
|
||||
|
||||
public async Task<byte[]> 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();
|
||||
}
|
||||
@@ -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<byte[]> 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<IAbCipTemplateReader> Readers { get; } = new();
|
||||
public Func<IAbCipTemplateReader>? 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<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
|
||||
{
|
||||
var mi = typeof(AbCipDriver).GetMethod("FetchUdtShapeAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
return (Task<AbCipUdtShape?>)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<byte[]> ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) =>
|
||||
throw new InvalidOperationException("fake read failure");
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Construct a Template Object blob — header + member blocks + semicolon-delimited
|
||||
/// strings (UDT name first, then member names).
|
||||
/// </summary>
|
||||
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("<member_2>");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user