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