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);
+ }
+}