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. /// /// /// Once the CRC passes, the response's address byte is validated against the addressed unit. /// On an RS-485 multi-drop bus a CRC-valid reply from the wrong slave would otherwise be /// silently accepted as the addressed unit's data; such a frame is a unit-mismatch /// . This ordering is deliberate — a corrupt frame /// stays a CRC/desync failure, and only a coherent-but-mis-addressed frame becomes a /// unit-mismatch desync. /// /// 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. The response's address byte is validated against /// it after the CRC passes; a mismatch is a unit-mismatch desync. /// /// A token to cancel the read. /// The bare response PDU (function code followed by its data). /// /// The frame was truncated (stream closed mid-frame), its trailing CRC did not validate, or its /// address byte did not match . /// /// /// 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) { // 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(expectedUnit, header, bc, rest); } else { // Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future // variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and // surface as a desync — revisit the FC-shape table if the driver starts emitting one. trailing = 4 + 2; } var tail = new byte[trailing]; await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false); return ValidateAndStrip(expectedUnit, header, ReadOnlySpan.Empty, tail); } /// /// Assembles the full frame from its pieces, validates the trailing CRC over everything /// except the CRC bytes, then validates the response address against /// and strips addr + CRC to return the bare PDU. /// A CRC-valid exception PDU throws . /// private static byte[] ValidateAndStrip( byte expectedUnit, 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}"); // Unit-address validation runs only AFTER the CRC passes: a corrupt frame is a CRC/desync // failure, and only a coherent-but-mis-addressed frame (a CRC-valid reply from the wrong // RS-485 slave) is a unit-mismatch desync. Never silently accept another unit's data. var addr = frame[0]; if (addr != expectedUnit) throw new ModbusTransportDesyncException( ModbusTransportDesyncException.DesyncReason.TruncatedFrame, $"Modbus RTU unit mismatch: expected {expectedUnit} got {addr}"); // 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; } } }