merge: Task 6 — CIP path encoding + Logix type codes

This commit is contained in:
Joseph Doherty
2026-10-07 15:46:31 -04:00
4 changed files with 209 additions and 0 deletions
@@ -0,0 +1,105 @@
using System.Globalization;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
public static class CipPath
{
/// <summary>Route path from "port,link[,port,link…]". Links that are not a byte value are IP
/// addresses, encoded as extended link-address port segments.</summary>
public static byte[] EncodeRoute(string cipPath)
{
if (string.IsNullOrWhiteSpace(cipPath)) return [];
var w = new CipWriter();
var parts = cipPath.Split(',', StringSplitOptions.TrimEntries);
if (parts.Length % 2 != 0) throw new ArgumentException($"CIP path '{cipPath}' must be port,link pairs.", nameof(cipPath));
for (var i = 0; i < parts.Length; i += 2)
{
var port = int.Parse(parts[i], CultureInfo.InvariantCulture);
var link = parts[i + 1];
var isByte = byte.TryParse(link, NumberStyles.None, CultureInfo.InvariantCulture, out var linkByte);
if (port < 15)
{
if (isByte) w.U8((byte)port).U8(linkByte);
else { var a = Encoding.ASCII.GetBytes(link); w.U8((byte)(0x10 | port)).U8((byte)a.Length).Bytes(a).PadToEven(); }
}
else
{
if (isByte) w.U8(0x0F).U16((ushort)port).U8(linkByte);
else { var a = Encoding.ASCII.GetBytes(link); w.U8(0x1F).U8((byte)a.Length).U16((ushort)port).Bytes(a).PadToEven(); }
}
}
return w.ToArray();
}
/// <summary>Splits a trailing purely numeric member ("Tag.5") into the tag name and bit index.</summary>
public static (string Name, int? Bit) SplitBitSelector(string tagName)
{
var dot = tagName.LastIndexOf('.');
if (dot > 0 && dot < tagName.Length - 1 && tagName[(dot + 1)..].All(char.IsAsciiDigit))
return (tagName[..dot], int.Parse(tagName[(dot + 1)..], CultureInfo.InvariantCulture));
return (tagName, null);
}
public static byte[] EncodeSymbolic(string tagName)
{
if (SplitBitSelector(tagName).Bit is not null)
throw new ArgumentException($"'{tagName}' ends in a bit selector; strip it with SplitBitSelector first.", nameof(tagName));
var w = new CipWriter();
foreach (var part in SplitMembers(tagName))
{
var bracket = part.IndexOf('[');
var name = bracket < 0 ? part : part[..bracket];
var a = Encoding.ASCII.GetBytes(name);
w.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
if (bracket >= 0)
{
foreach (var idxText in part[(bracket + 1)..part.IndexOf(']')].Split(',', StringSplitOptions.TrimEntries))
{
var idx = uint.Parse(idxText, CultureInfo.InvariantCulture);
if (idx < 0x100) w.U8(0x28).U8((byte)idx);
else if (idx < 0x10000) w.U8(0x29).U8(0).U16((ushort)idx);
else w.U8(0x2A).U8(0).U32(idx);
}
}
}
return w.ToArray();
}
/// <summary>Split on '.' outside brackets, keeping "Program:X" whole.</summary>
private static IEnumerable<string> SplitMembers(string name)
{
var depth = 0; var start = 0;
for (var i = 0; i < name.Length; i++)
{
if (name[i] == '[') depth++;
else if (name[i] == ']') depth--;
else if (name[i] == '.' && depth == 0) { yield return name[start..i]; start = i + 1; }
}
yield return name[start..];
}
public static byte[] EncodeClassInstance(ushort cls, uint instance)
{
var w = new CipWriter();
if (cls < 0x100) w.U8(0x20).U8((byte)cls); else w.U8(0x21).U8(0).U16(cls);
if (instance < 0x100) w.U8(0x24).U8((byte)instance);
else if (instance < 0x10000) w.U8(0x25).U8(0).U16((ushort)instance);
else w.U8(0x26).U8(0).U32(instance);
return w.ToArray();
}
/// <summary>Prefixes the path with its size in 16-bit words.</summary>
public static byte[] WithPathSize(byte[] segments)
{
var w = new CipWriter();
w.U8((byte)(segments.Length / 2)).Bytes(segments);
return w.ToArray();
}
/// <summary>Message Router (class 2, instance 1).</summary>
public static readonly byte[] MessageRouter = [0x20, 0x02, 0x24, 0x01];
/// <summary>Connection Manager (class 6, instance 1).</summary>
public static readonly byte[] ConnectionManager = [0x20, 0x06, 0x24, 0x01];
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
public static class LogixTypeCode
{
public const ushort Bool = 0xC1, SInt = 0xC2, Int = 0xC3, DInt = 0xC4, LInt = 0xC5, USInt = 0xC6,
UInt = 0xC7, UDInt = 0xC8, ULInt = 0xC9, Real = 0xCA, LReal = 0xCB, BoolArray = 0xD3, Struct = 0x02A0;
/// <summary>Byte width of an atomic type, or -1 when the code is not atomic (e.g. <see cref="Struct"/>).</summary>
public static int ByteWidth(ushort code) => code switch
{
Bool or SInt or USInt => 1,
Int or UInt => 2,
DInt or UDInt or Real or BoolArray => 4,
LInt or ULInt or LReal => 8,
_ => -1,
};
}
@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
public sealed class CipPathTests
{
[Theory]
[InlineData("1,0", "0100")]
[InlineData("", "")]
[InlineData("1,0,2,10.0.0.5", "0100" + "1208" + "31302E302E302E35")]
[InlineData("2,10.0.0.55", "1209" + "31302E302E302E3535" + "00")]
[InlineData("15,3", "0F0F0003")]
[InlineData("16,10.0.0.5", "1F08" + "1000" + "31302E302E302E35")]
public void Encodes_route(string path, string hex) =>
Convert.ToHexString(CipPath.EncodeRoute(path)).ShouldBe(hex);
[Fact]
public void Odd_route_parts_throw() =>
Should.Throw<ArgumentException>(() => CipPath.EncodeRoute("1,0,2"));
[Theory]
[InlineData("TestDINT", "9108546573744449 4E54")]
[InlineData("Abc", "910341626300")]
[InlineData("Program:Main.Arr[3].Val", "910C50726F6772616D3A4D61696E" + "910341727200" + "2803" + "910356616C00")]
[InlineData("Big[300]", "910342696700" + "29002C01")]
[InlineData("Huge[70000]", "910448756765" + "2A00" + "70110100")]
[InlineData("Grid[1,2]", "910447726964" + "2801" + "2802")]
public void Encodes_symbolic(string name, string hex) =>
Convert.ToHexString(CipPath.EncodeSymbolic(name)).ShouldBe(hex.Replace(" ", ""));
[Fact]
public void Symbolic_rejects_bit_selector() =>
Should.Throw<ArgumentException>(() => CipPath.EncodeSymbolic("Tag.5"));
[Fact]
public void Splits_bit_selector()
{
CipPath.SplitBitSelector("Tag.5").ShouldBe(("Tag", 5));
CipPath.SplitBitSelector("Tag.Member").ShouldBe(("Tag.Member", null));
CipPath.SplitBitSelector("Tag").ShouldBe(("Tag", null));
}
[Theory]
[InlineData(2, 1u, "20022401")]
[InlineData(0x6B, 0x1234u, "206B25003412")]
[InlineData(0x300, 1u, "21000003" + "2401")]
[InlineData(2, 70000u, "2002" + "2600" + "70110100")]
public void Encodes_class_instance(ushort cls, uint inst, string hex) =>
Convert.ToHexString(CipPath.EncodeClassInstance(cls, inst)).ShouldBe(hex);
[Fact]
public void Prefixes_path_size_in_words() =>
Convert.ToHexString(CipPath.WithPathSize([0x20, 0x02, 0x24, 0x01])).ShouldBe("0220022401");
}
@@ -0,0 +1,32 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix;
public sealed class LogixTypeCodeTests
{
[Theory]
[InlineData(LogixTypeCode.Bool, 1)]
[InlineData(LogixTypeCode.SInt, 1)]
[InlineData(LogixTypeCode.Int, 2)]
[InlineData(LogixTypeCode.DInt, 4)]
[InlineData(LogixTypeCode.LInt, 8)]
[InlineData(LogixTypeCode.USInt, 1)]
[InlineData(LogixTypeCode.UInt, 2)]
[InlineData(LogixTypeCode.UDInt, 4)]
[InlineData(LogixTypeCode.ULInt, 8)]
[InlineData(LogixTypeCode.Real, 4)]
[InlineData(LogixTypeCode.LReal, 8)]
[InlineData(LogixTypeCode.BoolArray, 4)]
[InlineData(LogixTypeCode.Struct, -1)]
public void Byte_width(ushort code, int width) => LogixTypeCode.ByteWidth(code).ShouldBe(width);
[Fact]
public void Code_values()
{
LogixTypeCode.Bool.ShouldBe((ushort)0xC1);
LogixTypeCode.LReal.ShouldBe((ushort)0xCB);
LogixTypeCode.Struct.ShouldBe((ushort)0x02A0);
LogixTypeCode.BoolArray.ShouldBe((ushort)0xD3);
}
}