diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs new file mode 100644 index 00000000..e653a932 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs @@ -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 +{ + /// Route path from "port,link[,port,link…]". Links that are not a byte value are IP + /// addresses, encoded as extended link-address port segments. + 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(); + } + + /// Splits a trailing purely numeric member ("Tag.5") into the tag name and bit index. + 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(); + } + + /// Split on '.' outside brackets, keeping "Program:X" whole. + private static IEnumerable 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(); + } + + /// Prefixes the path with its size in 16-bit words. + public static byte[] WithPathSize(byte[] segments) + { + var w = new CipWriter(); + w.U8((byte)(segments.Length / 2)).Bytes(segments); + return w.ToArray(); + } + + /// Message Router (class 2, instance 1). + public static readonly byte[] MessageRouter = [0x20, 0x02, 0x24, 0x01]; + + /// Connection Manager (class 6, instance 1). + public static readonly byte[] ConnectionManager = [0x20, 0x06, 0x24, 0x01]; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs new file mode 100644 index 00000000..eeb9cfb9 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs @@ -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; + + /// Byte width of an atomic type, or -1 when the code is not atomic (e.g. ). + 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, + }; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs new file mode 100644 index 00000000..8b3b5415 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs @@ -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(() => 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(() => 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"); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs new file mode 100644 index 00000000..8a570da5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs @@ -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); + } +}