AB CIP @tags walker � CIP Symbol Object decoder + LibplctagTagEnumerator #130
@@ -44,7 +44,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
_options = options;
|
_options = options;
|
||||||
_driverInstanceId = driverInstanceId;
|
_driverInstanceId = driverInstanceId;
|
||||||
_tagFactory = tagFactory ?? new LibplctagTagFactory();
|
_tagFactory = tagFactory ?? new LibplctagTagFactory();
|
||||||
_enumeratorFactory = enumeratorFactory ?? new EmptyAbCipTagEnumeratorFactory();
|
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
|
||||||
_poll = new PollGroupEngine(
|
_poll = new PollGroupEngine(
|
||||||
reader: ReadAsync,
|
reader: ReadAsync,
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
onChange: (handle, tagRef, snapshot) =>
|
||||||
@@ -559,9 +559,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
|
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Controller-discovered tags — optional. Default enumerator returns an empty sequence;
|
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
|
||||||
// tests + the follow-up real @tags walker plug in via the ctor parameter.
|
// walker (LibplctagTagEnumerator) is the factory default since task #178 shipped,
|
||||||
if (_devices.TryGetValue(device.HostAddress, out var state))
|
// so leaving the flag off keeps the strict-config path for deployments where only
|
||||||
|
// declared tags should appear.
|
||||||
|
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||||||
{
|
{
|
||||||
using var enumerator = _enumeratorFactory.Create();
|
using var enumerator = _enumeratorFactory.Create();
|
||||||
var deviceParams = new AbCipTagCreateParams(
|
var deviceParams = new AbCipTagCreateParams(
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ public sealed class AbCipDriverOptions
|
|||||||
/// not pass a more specific value. Matches the Modbus driver's 2-second default.
|
/// not pass a more specific value. Matches the Modbus driver's 2-second default.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's Logix symbol table via
|
||||||
|
/// the <c>@tags</c> pseudo-tag + surfaces controller-resident globals under a
|
||||||
|
/// <c>Discovered/</c> sub-folder. Pre-declared tags always emit regardless. Default
|
||||||
|
/// <c>false</c> to keep the strict-config path for deployments where only declared tags
|
||||||
|
/// should appear in the address space.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableControllerBrowse { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
128
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
Normal file
128
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decoder for the CIP Symbol Object (class 0x6B) response returned by Logix controllers
|
||||||
|
/// when a client reads the <c>@tags</c> pseudo-tag. Parses the concatenated tag-info
|
||||||
|
/// entries into a sequence of <see cref="AbCipDiscoveredTag"/>s that the driver can stream
|
||||||
|
/// into the address-space builder.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Entry layout (little-endian) per Rockwell CIP Vol 1 + Logix 5000 CIP Programming
|
||||||
|
/// Manual (1756-PM019 chapter "Symbol Object"), cross-checked against libplctag's
|
||||||
|
/// <c>ab/cip.c</c> <c>handle_listed_tags_reply</c>:</para>
|
||||||
|
/// <list type="table">
|
||||||
|
/// <item><term>u32</term><description>Symbol Instance ID — opaque identifier for the tag.</description></item>
|
||||||
|
/// <item><term>u16</term><description>Symbol Type — lower 12 bits = CIP type code (0xC1 BOOL,
|
||||||
|
/// 0xC2 SINT, …, 0xD0 STRING). Bit 12 = system-tag flag. Bit 13 = reserved.
|
||||||
|
/// Bit 15 = struct flag; when set, the lower 12 bits are the template instance id
|
||||||
|
/// (not a primitive type code).</description></item>
|
||||||
|
/// <item><term>u16</term><description>Element length — bytes per element (e.g. 4 for DINT).</description></item>
|
||||||
|
/// <item><term>u32 × 3</term><description>Array dimensions — zero for scalar tags.</description></item>
|
||||||
|
/// <item><term>u16</term><description>Symbol name length in bytes.</description></item>
|
||||||
|
/// <item><term>u8 × N</term><description>ASCII symbol name, padded to an even byte boundary.</description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para><c>Program:</c>-scope tags arrive with their scope prefix baked into the name
|
||||||
|
/// (<c>Program:MainProgram.StepIndex</c>); decoder strips the prefix + emits the scope
|
||||||
|
/// separately so the driver's IAddressSpaceBuilder can organise them.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class CipSymbolObjectDecoder
|
||||||
|
{
|
||||||
|
// Fixed header size in bytes — instance-id(4) + symbol-type(2) + element-length(2)
|
||||||
|
// + array-dims(4×3) + name-length(2) = 22.
|
||||||
|
private const int FixedHeaderSize = 22;
|
||||||
|
|
||||||
|
private const ushort SymbolTypeSystemFlag = 0x1000;
|
||||||
|
private const ushort SymbolTypeStructFlag = 0x8000;
|
||||||
|
private const ushort SymbolTypeTypeCodeMask = 0x0FFF;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode the raw <c>@tags</c> blob into an enumerable sequence. Malformed entries at
|
||||||
|
/// the tail cause decoding to stop gracefully — the caller gets whatever it could parse
|
||||||
|
/// cleanly before the corruption.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(buffer);
|
||||||
|
return DecodeImpl(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer)
|
||||||
|
{
|
||||||
|
var pos = 0;
|
||||||
|
while (pos + FixedHeaderSize <= buffer.Length)
|
||||||
|
{
|
||||||
|
var instanceId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos));
|
||||||
|
var symbolType = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 4));
|
||||||
|
// element_length at pos+6 (u16) — useful for array sizing but not surfaced here
|
||||||
|
// array_dims at pos+8, pos+12, pos+16 — same (scalar-tag case has all zeros)
|
||||||
|
var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20));
|
||||||
|
pos += FixedHeaderSize;
|
||||||
|
|
||||||
|
if (pos + nameLength > buffer.Length) break;
|
||||||
|
var name = Encoding.ASCII.GetString(buffer, pos, nameLength);
|
||||||
|
pos += nameLength;
|
||||||
|
if ((pos & 1) != 0) pos++; // even-align for the next entry
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||||
|
|
||||||
|
var isSystem = (symbolType & SymbolTypeSystemFlag) != 0;
|
||||||
|
var isStruct = (symbolType & SymbolTypeStructFlag) != 0;
|
||||||
|
var typeCode = symbolType & SymbolTypeTypeCodeMask;
|
||||||
|
|
||||||
|
var (programScope, simpleName) = SplitProgramScope(name);
|
||||||
|
var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode);
|
||||||
|
|
||||||
|
yield return new AbCipDiscoveredTag(
|
||||||
|
Name: simpleName,
|
||||||
|
ProgramScope: programScope,
|
||||||
|
DataType: dataType ?? AbCipDataType.Structure, // unknown type code → treat as opaque
|
||||||
|
ReadOnly: false, // Symbol Object doesn't carry write-protection bits; lift via AccessControl Object later
|
||||||
|
IsSystemTag: isSystem);
|
||||||
|
|
||||||
|
_ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Split a <c>Program:MainProgram.StepIndex</c>-style name into its scope + local
|
||||||
|
/// parts. Names without the <c>Program:</c> prefix pass through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
internal static (string? programScope, string simpleName) SplitProgramScope(string fullName)
|
||||||
|
{
|
||||||
|
const string prefix = "Program:";
|
||||||
|
if (!fullName.StartsWith(prefix, StringComparison.Ordinal)) return (null, fullName);
|
||||||
|
var afterPrefix = fullName[prefix.Length..];
|
||||||
|
var dot = afterPrefix.IndexOf('.');
|
||||||
|
if (dot <= 0) return (null, fullName); // malformed scope — surface the raw name
|
||||||
|
return (afterPrefix[..dot], afterPrefix[(dot + 1)..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map a CIP atomic type code (lower 12 bits of the symbol-type field) to our
|
||||||
|
/// <see cref="AbCipDataType"/> surface. Returns <c>null</c> for unrecognised codes —
|
||||||
|
/// caller treats those as <see cref="AbCipDataType.Structure"/> so the symbol is still
|
||||||
|
/// surfaced + downstream config can add a concrete type override.
|
||||||
|
/// </summary>
|
||||||
|
internal static AbCipDataType? MapTypeCode(byte typeCode) => typeCode switch
|
||||||
|
{
|
||||||
|
0xC1 => AbCipDataType.Bool,
|
||||||
|
0xC2 => AbCipDataType.SInt,
|
||||||
|
0xC3 => AbCipDataType.Int,
|
||||||
|
0xC4 => AbCipDataType.DInt,
|
||||||
|
0xC5 => AbCipDataType.LInt,
|
||||||
|
0xC6 => AbCipDataType.USInt,
|
||||||
|
0xC7 => AbCipDataType.UInt,
|
||||||
|
0xC8 => AbCipDataType.UDInt,
|
||||||
|
0xC9 => AbCipDataType.ULInt,
|
||||||
|
0xCA => AbCipDataType.Real,
|
||||||
|
0xCB => AbCipDataType.LReal,
|
||||||
|
0xCD => AbCipDataType.Dt, // DATE
|
||||||
|
0xCF => AbCipDataType.Dt, // DATE_AND_TIME
|
||||||
|
0xD0 => AbCipDataType.String,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
63
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
Normal file
63
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using libplctag;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Real <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table by
|
||||||
|
/// reading the <c>@tags</c> pseudo-tag via libplctag + decoding the CIP Symbol Object
|
||||||
|
/// response with <see cref="CipSymbolObjectDecoder"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>libplctag's <c>Tag.GetBuffer()</c> returns the raw Symbol Object bytes when the
|
||||||
|
/// tag name is <c>@tags</c>. The decoder walks the concatenated entries + emits
|
||||||
|
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
|
||||||
|
///
|
||||||
|
/// <para>Task #178 closed the stub gap from PR 5 — <see cref="EmptyAbCipTagEnumerator"/>
|
||||||
|
/// is still available for tests that don't want to touch the native library, but the
|
||||||
|
/// production factory default now wires this implementation in.</para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||||
|
{
|
||||||
|
private Tag? _tag;
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||||
|
AbCipTagCreateParams deviceParams,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Build a tag specifically for the @tags pseudo — same gateway + path as the device,
|
||||||
|
// distinguished by the name alone.
|
||||||
|
_tag = new Tag
|
||||||
|
{
|
||||||
|
Gateway = deviceParams.Gateway,
|
||||||
|
Path = deviceParams.CipPath,
|
||||||
|
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
|
||||||
|
Protocol = Protocol.ab_eip,
|
||||||
|
Name = "@tags",
|
||||||
|
Timeout = deviceParams.Timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var buffer = _tag.GetBuffer();
|
||||||
|
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
|
||||||
|
yield return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _tag?.Dispose();
|
||||||
|
|
||||||
|
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||||
|
{
|
||||||
|
"controllogix" => PlcType.ControlLogix,
|
||||||
|
"compactlogix" => PlcType.ControlLogix,
|
||||||
|
"micro800" => PlcType.Micro800,
|
||||||
|
_ => PlcType.ControlLogix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Factory for <see cref="LibplctagTagEnumerator"/>.</summary>
|
||||||
|
internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||||
|
{
|
||||||
|
public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ public sealed class AbCipDriverDiscoveryTests
|
|||||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
{
|
{
|
||||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
EnableControllerBrowse = true,
|
||||||
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ public sealed class AbCipDriverDiscoveryTests
|
|||||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
{
|
{
|
||||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
EnableControllerBrowse = true,
|
||||||
}, "drv-1", enumeratorFactory: factory);
|
}, "drv-1", enumeratorFactory: factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
@@ -137,6 +139,7 @@ public sealed class AbCipDriverDiscoveryTests
|
|||||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
{
|
{
|
||||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
EnableControllerBrowse = true,
|
||||||
}, "drv-1", enumeratorFactory: factory);
|
}, "drv-1", enumeratorFactory: factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
@@ -153,6 +156,7 @@ public sealed class AbCipDriverDiscoveryTests
|
|||||||
{
|
{
|
||||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
|
||||||
Timeout = TimeSpan.FromSeconds(7),
|
Timeout = TimeSpan.FromSeconds(7),
|
||||||
|
EnableControllerBrowse = true,
|
||||||
}, "drv-1", enumeratorFactory: factory);
|
}, "drv-1", enumeratorFactory: factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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 CipSymbolObjectDecoderTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build one Symbol Object entry in the byte layout
|
||||||
|
/// <c>instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len] pad</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] BuildEntry(
|
||||||
|
uint instanceId,
|
||||||
|
ushort symbolType,
|
||||||
|
ushort elementLength,
|
||||||
|
(uint, uint, uint) arrayDims,
|
||||||
|
string name)
|
||||||
|
{
|
||||||
|
var nameBytes = Encoding.ASCII.GetBytes(name);
|
||||||
|
var nameLen = nameBytes.Length;
|
||||||
|
var totalLen = 22 + nameLen;
|
||||||
|
if ((totalLen & 1) != 0) totalLen++; // pad to even
|
||||||
|
|
||||||
|
var buf = new byte[totalLen];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId);
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType);
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3);
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen);
|
||||||
|
Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Concat(params byte[][] chunks)
|
||||||
|
{
|
||||||
|
var total = chunks.Sum(c => c.Length);
|
||||||
|
var result = new byte[total];
|
||||||
|
var pos = 0;
|
||||||
|
foreach (var c in chunks)
|
||||||
|
{
|
||||||
|
Buffer.BlockCopy(c, 0, result, pos, c.Length);
|
||||||
|
pos += c.Length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
|
||||||
|
{
|
||||||
|
var bytes = BuildEntry(
|
||||||
|
instanceId: 42,
|
||||||
|
symbolType: 0xC4,
|
||||||
|
elementLength: 4,
|
||||||
|
arrayDims: (0, 0, 0),
|
||||||
|
name: "Counter");
|
||||||
|
|
||||||
|
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||||||
|
|
||||||
|
tags.Count.ShouldBe(1);
|
||||||
|
tags[0].Name.ShouldBe("Counter");
|
||||||
|
tags[0].ProgramScope.ShouldBeNull();
|
||||||
|
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||||
|
tags[0].IsSystemTag.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData((byte)0xC1, AbCipDataType.Bool)]
|
||||||
|
[InlineData((byte)0xC2, AbCipDataType.SInt)]
|
||||||
|
[InlineData((byte)0xC3, AbCipDataType.Int)]
|
||||||
|
[InlineData((byte)0xC4, AbCipDataType.DInt)]
|
||||||
|
[InlineData((byte)0xC5, AbCipDataType.LInt)]
|
||||||
|
[InlineData((byte)0xC6, AbCipDataType.USInt)]
|
||||||
|
[InlineData((byte)0xC7, AbCipDataType.UInt)]
|
||||||
|
[InlineData((byte)0xC8, AbCipDataType.UDInt)]
|
||||||
|
[InlineData((byte)0xC9, AbCipDataType.ULInt)]
|
||||||
|
[InlineData((byte)0xCA, AbCipDataType.Real)]
|
||||||
|
[InlineData((byte)0xCB, AbCipDataType.LReal)]
|
||||||
|
[InlineData((byte)0xD0, AbCipDataType.String)]
|
||||||
|
public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected)
|
||||||
|
{
|
||||||
|
CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
|
||||||
|
{
|
||||||
|
CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Struct_flag_overrides_type_code_and_yields_Structure()
|
||||||
|
{
|
||||||
|
// 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234)
|
||||||
|
var bytes = BuildEntry(
|
||||||
|
instanceId: 5,
|
||||||
|
symbolType: 0x8000 | 0x0234,
|
||||||
|
elementLength: 16,
|
||||||
|
arrayDims: (0, 0, 0),
|
||||||
|
name: "Motor1");
|
||||||
|
|
||||||
|
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||||
|
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void System_flag_surfaces_as_IsSystemTag_true()
|
||||||
|
{
|
||||||
|
var bytes = BuildEntry(
|
||||||
|
instanceId: 99,
|
||||||
|
symbolType: 0x1000 | 0xC4, // system flag + DINT
|
||||||
|
elementLength: 4,
|
||||||
|
arrayDims: (0, 0, 0),
|
||||||
|
name: "__Reserved_1");
|
||||||
|
|
||||||
|
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||||
|
tag.IsSystemTag.ShouldBeTrue();
|
||||||
|
tag.DataType.ShouldBe(AbCipDataType.DInt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Program_scope_name_splits_prefix_into_ProgramScope()
|
||||||
|
{
|
||||||
|
var bytes = BuildEntry(
|
||||||
|
instanceId: 1,
|
||||||
|
symbolType: 0xC4,
|
||||||
|
elementLength: 4,
|
||||||
|
arrayDims: (0, 0, 0),
|
||||||
|
name: "Program:MainProgram.StepIndex");
|
||||||
|
|
||||||
|
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||||
|
tag.ProgramScope.ShouldBe("MainProgram");
|
||||||
|
tag.Name.ShouldBe("StepIndex");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Multiple_entries_decode_in_wire_order_with_even_padding()
|
||||||
|
{
|
||||||
|
// Name "Abc" is 3 bytes — triggers the even-pad branch between entries.
|
||||||
|
var bytes = Concat(
|
||||||
|
BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // DINT named "Abc" (3-byte name, pads to 4)
|
||||||
|
BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi")); // REAL named "Pi"
|
||||||
|
|
||||||
|
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||||||
|
tags.Count.ShouldBe(2);
|
||||||
|
tags[0].Name.ShouldBe("Abc");
|
||||||
|
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||||
|
tags[1].Name.ShouldBe("Pi");
|
||||||
|
tags[1].DataType.ShouldBe(AbCipDataType.Real);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Truncated_buffer_stops_decoding_gracefully()
|
||||||
|
{
|
||||||
|
var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter");
|
||||||
|
// Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw.
|
||||||
|
var truncated = full.Take(full.Length - 5).ToArray();
|
||||||
|
|
||||||
|
CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_buffer_yields_no_tags()
|
||||||
|
{
|
||||||
|
CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Counter", null, "Counter")]
|
||||||
|
[InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
|
||||||
|
[InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")]
|
||||||
|
[InlineData("Program:", null, "Program:")] // malformed — no dot
|
||||||
|
[InlineData("Program:OnlyProg", null, "Program:OnlyProg")]
|
||||||
|
[InlineData("Motor.Status.Running", null, "Motor.Status.Running")]
|
||||||
|
public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName)
|
||||||
|
{
|
||||||
|
var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input);
|
||||||
|
scope.ShouldBe(expectedScope);
|
||||||
|
name.ShouldBe(expectedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user