diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs
new file mode 100644
index 00000000..e3ab9b00
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs
@@ -0,0 +1,169 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// Modbus RTU framing: builds a request ADU ([unitId] + PDU + CRC) and deframes a
+/// response back to its bare PDU. Byte-stream-agnostic — it operates on a
+/// and knows nothing about sockets or serial ports, so a future serial transport can reuse it.
+///
+///
+///
+/// The single genuinely-new correctness risk of the RTU path: an RTU frame carries no
+/// length field (unlike TCP's MBAP header), so the response size must be derived from
+/// the function code. After reading addr(1) + fc(1), the remaining byte count
+/// is one of three shapes:
+///
+///
+/// ShapeTrailing bytes after addr,fc
+/// -
+/// Exception (fc & 0x80)
+/// excCode(1) + CRC(2)
+///
+/// -
+/// Read (FC 01/02/03/04)
+/// byteCount(1) then byteCount data bytes + CRC(2)
+///
+/// -
+/// Write echo (FC 05/06/15/16)
+/// fixed 4 bytes + CRC(2)
+///
+///
+///
+/// The trailing CRC is validated over [addr, ...pdu] (everything except the two CRC
+/// bytes) before the frame is interpreted — so a corrupt exception frame surfaces as a
+/// desync, never as a bogus . A CRC mismatch or a short read
+/// (truncation / stream closed mid-frame) throws ;
+/// a CRC-valid exception PDU throws carrying the original
+/// function code (fc & 0x7F) and exception code.
+///
+///
+public static class ModbusRtuFraming
+{
+ ///
+ /// Builds an RTU request ADU: the unit id, the PDU, and the trailing CRC-16/MODBUS
+ /// (appended low byte first).
+ ///
+ /// The RTU slave/unit address.
+ /// The bare PDU (function code + data).
+ /// A new array: [unitId, ...pdu, crcLo, crcHi].
+ public static byte[] BuildAdu(byte unitId, ReadOnlySpan pdu)
+ {
+ var frame = new byte[1 + pdu.Length];
+ frame[0] = unitId;
+ pdu.CopyTo(frame.AsSpan(1));
+ return ModbusCrc.Append(frame);
+ }
+
+ ///
+ /// Reads a single RTU response frame from and returns its bare PDU
+ /// ([fc, ...data]), with the leading unit-id and trailing CRC stripped.
+ ///
+ /// The byte stream to read the response from.
+ ///
+ /// The unit id the request was addressed to. Read from the wire and consumed when stripping
+ /// to the bare PDU; no mismatch validation is performed here.
+ ///
+ /// A token to cancel the read.
+ /// The bare response PDU (function code followed by its data).
+ ///
+ /// The frame was truncated (stream closed mid-frame) or its trailing CRC did not validate.
+ ///
+ ///
+ /// The frame was a CRC-valid Modbus exception PDU (high bit set on the function code).
+ ///
+ public static async Task ReadResponsePduAsync(
+ Stream stream, byte expectedUnit, CancellationToken ct)
+ {
+ _ = expectedUnit; // read off the wire below; no mismatch validation is specified here.
+
+ // Header: addr(1) + fc(1). The function code selects how many more bytes to read.
+ var header = new byte[2];
+ await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
+ var fc = header[1];
+
+ int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
+ if ((fc & 0x80) != 0)
+ {
+ // Exception frame: excCode(1) + CRC(2).
+ trailing = 1 + 2;
+ }
+ else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
+ {
+ // Read response: byteCount(1) then byteCount data bytes + CRC(2).
+ var bc = new byte[1];
+ await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
+ var byteCount = bc[0];
+ var rest = new byte[byteCount + 2];
+ await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
+ return ValidateAndStrip(header, bc, rest);
+ }
+ else
+ {
+ // Write-echo response (FC 05/06/15/16) or any other length-less fixed shape:
+ // fixed 4 payload bytes + CRC(2).
+ trailing = 4 + 2;
+ }
+
+ var tail = new byte[trailing];
+ await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
+ return ValidateAndStrip(header, ReadOnlySpan.Empty, tail);
+ }
+
+ ///
+ /// Assembles the full frame from its pieces, validates the trailing CRC over everything
+ /// except the CRC bytes, then strips addr + CRC to return the bare PDU.
+ /// A CRC-valid exception PDU throws .
+ ///
+ private static byte[] ValidateAndStrip(
+ ReadOnlySpan header, ReadOnlySpan middle, ReadOnlySpan tail)
+ {
+ // Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
+ var frame = new byte[header.Length + middle.Length + tail.Length];
+ header.CopyTo(frame);
+ middle.CopyTo(frame.AsSpan(header.Length));
+ tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
+
+ // CRC covers everything except the trailing 2 CRC bytes.
+ var body = frame.AsSpan(0, frame.Length - 2);
+ var expected = ModbusCrc.Compute(body);
+ var actual = (ushort)(frame[^2] | (frame[^1] << 8));
+ if (actual != expected)
+ throw new ModbusTransportDesyncException(
+ ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
+ $"Modbus RTU CRC mismatch: computed {expected:X4} got {actual:X4}");
+
+ // Bare PDU = frame minus leading addr and trailing CRC.
+ var pdu = body[1..].ToArray();
+
+ // Exception PDU: high bit set on the function code. The CRC already validated, so this is a
+ // coherent protocol-level error — surface the ORIGINAL function code (fc & 0x7F).
+ if ((pdu[0] & 0x80) != 0)
+ {
+ var fc = (byte)(pdu[0] & 0x7F);
+ var exCode = pdu[1];
+ throw new ModbusException(fc, exCode, $"Modbus exception fc={fc} code={exCode}");
+ }
+
+ return pdu;
+ }
+
+ ///
+ /// Fills completely from , throwing a
+ /// truncation desync if the stream ends first. Mirrors
+ /// ModbusTcpTransport.ReadExactlyAsync, but normalises the end-of-stream to a
+ /// so a length-less RTU frame that arrives
+ /// short is classified as a desync rather than a bare .
+ ///
+ private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
+ {
+ var read = 0;
+ while (read < buf.Length)
+ {
+ var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
+ if (n == 0)
+ throw new ModbusTransportDesyncException(
+ ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
+ $"Modbus RTU frame truncated: expected {buf.Length} bytes, got {read}");
+ read += n;
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs
new file mode 100644
index 00000000..6e00614d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs
@@ -0,0 +1,59 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+public sealed class ModbusRtuFramingTests
+{
+ private static MemoryStream Canned(params byte[] frame) => new(frame);
+
+ [Fact]
+ public void BuildAdu_prefixes_unit_and_appends_crc()
+ {
+ var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
+ adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
+ }
+
+ [Fact]
+ public async Task ReadResponse_FC03_returns_bare_pdu()
+ {
+ // addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi)
+ var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
+ var frame = ModbusCrc.Append(body);
+ await using var s = Canned(frame);
+ var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
+ pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
+ }
+
+ [Fact]
+ public async Task ReadResponse_exception_frame_throws_ModbusException()
+ {
+ // addr=01 fc=0x83 exc=0x02 + CRC
+ var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
+ await using var s = Canned(frame);
+ var ex = await Should.ThrowAsync(
+ ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
+ ex.FunctionCode.ShouldBe((byte)0x03);
+ ex.ExceptionCode.ShouldBe((byte)0x02);
+ }
+
+ [Fact]
+ public async Task ReadResponse_bad_crc_throws_desync()
+ {
+ var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
+ frame[^1] ^= 0xFF; // corrupt CRC high byte
+ await using var s = Canned(frame);
+ await Should.ThrowAsync(
+ ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
+ {
+ // addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A
+ var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
+ await using var s = Canned(frame);
+ var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
+ pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
+ }
+}